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

Merge branch 'pro' into gdrtvu

...@@ -11,5 +11,6 @@ ...@@ -11,5 +11,6 @@
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script src="https://webapp-pub.ezijing.com/plugins/sky-agents/sky-agent.umd.cjs?v=1"></script> <script src="https://webapp-pub.ezijing.com/plugins/sky-agents/sky-agent.umd.cjs?v=1"></script>
<script src="https://webapp-pub.ezijing.com/plugins/tinymce/tinymce.min.js"></script>
</body> </body>
</html> </html>
...@@ -9,11 +9,13 @@ ...@@ -9,11 +9,13 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@chuangkit/chuangkit-design": "^2.0.5", "@chuangkit/chuangkit-design": "^2.0.5",
"@dagrejs/dagre": "^1.1.3",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@fortaine/fetch-event-source": "^3.0.6", "@fortaine/fetch-event-source": "^3.0.6",
"@tinymce/tinymce-vue": "^5.0.1", "@tinymce/tinymce-vue": "^5.0.1",
"@vue-flow/controls": "^1.0.4", "@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.17.4", "@vue-flow/core": "^1.39.0",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"axios": "^1.6.8", "axios": "^1.6.8",
"blueimp-md5": "^2.19.0", "blueimp-md5": "^2.19.0",
...@@ -21,9 +23,13 @@ ...@@ -21,9 +23,13 @@
"echarts": "^5.5.0", "echarts": "^5.5.0",
"echarts-wordcloud": "^2.1.0", "echarts-wordcloud": "^2.1.0",
"element-plus": "^2.6.3", "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", "lodash-es": "^4.17.21",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"scroll-into-view-if-needed": "^3.1.0",
"vue": "^3.4.26", "vue": "^3.4.26",
"vue-echarts": "^6.6.9", "vue-echarts": "^6.6.9",
"vue-router": "^4.3.2", "vue-router": "^4.3.2",
...@@ -490,6 +496,17 @@ ...@@ -490,6 +496,17 @@
"node": ">=6.0.0" "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": { "node_modules/@babel/standalone": {
"version": "7.21.2", "version": "7.21.2",
"resolved": "https://registry.npmmirror.com/@babel/standalone/-/standalone-7.21.2.tgz", "resolved": "https://registry.npmmirror.com/@babel/standalone/-/standalone-7.21.2.tgz",
...@@ -582,6 +599,24 @@ ...@@ -582,6 +599,24 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@dagrejs/dagre": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.3.tgz",
"integrity": "sha512-umT7fBPECI4zgxxXW07H3vJN7W1WZcnBjk613eOEAKcwoFrYNyMZO+1SHmoC8zPZWR18DquK2wRUp9VHUE+94g==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "2.2.2"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.2.tgz",
"integrity": "sha512-CbyGpCDKsiTg/wuk79S7Muoj8mghDGAESWGxcSyhHX5jD35vYMBZochYVFzlHxynpE9unpu6O+4ZuhrLxASsOg==",
"license": "MIT",
"engines": {
"node": ">17.0.0"
}
},
"node_modules/@element-plus/icons-vue": { "node_modules/@element-plus/icons-vue": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz", "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz",
...@@ -1657,6 +1692,12 @@ ...@@ -1657,6 +1692,12 @@
"integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==", "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==",
"dev": true "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": { "node_modules/@types/semver": {
"version": "7.5.8", "version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
...@@ -1930,113 +1971,27 @@ ...@@ -1930,113 +1971,27 @@
} }
}, },
"node_modules/@vue-flow/controls": { "node_modules/@vue-flow/controls": {
"version": "1.0.4", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@vue-flow/controls/-/controls-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.2.tgz",
"integrity": "sha512-M3bA5uWDp+tbeaQ2iKszk4fsnazErvNKqaojkUOE1xgYSnBs8Rz9YTBrcog/sHW5gK3geo/TS6D9N/6b2evIcw==", "integrity": "sha512-6dtl/JnwDBNau5h3pDBdOCK6tdxiVAOL3cyruRL61gItwq5E97Hmjmj2BIIqX2p7gU1ENg3z80Z4zlu58fGlsg==",
"peerDependencies": { "peerDependencies": {
"@vue-flow/core": "^1.12.2" "@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
} }
}, },
"node_modules/@vue-flow/core": { "node_modules/@vue-flow/core": {
"version": "1.17.4", "version": "1.39.0",
"resolved": "https://registry.npmmirror.com/@vue-flow/core/-/core-1.17.4.tgz", "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.39.0.tgz",
"integrity": "sha512-fTixCHNbO/xIksV+9w7Fnpio2zE0CVevZQq/wNFjnrqDc2+2Oyo4DuMXmOZuvo2qo6YM30FQJ3DaapM3bQm4Hg==", "integrity": "sha512-QBcKFrLV5a6f+DmTALQQtNRKKF7q6HRd2KOVx0ygoHOUK0vwcawoOPd3KNpKVDOVjbf/D/L9zM5hrqruW6JjmQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@vueuse/core": "^9.13.0", "@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0", "d3-drag": "^3.0.0",
"d3-selection": "^3.0.0", "d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0" "d3-zoom": "^3.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "^3.2.25" "vue": "^3.3.0"
}
},
"node_modules/@vue-flow/core/node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ=="
},
"node_modules/@vue-flow/core/node_modules/@vueuse/core": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz",
"integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==",
"dependencies": {
"@types/web-bluetooth": "^0.0.16",
"@vueuse/metadata": "9.13.0",
"@vueuse/shared": "9.13.0",
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz",
"integrity": "sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==",
"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/@vue-flow/core/node_modules/@vueuse/metadata": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz",
"integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz",
"integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz",
"integrity": "sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==",
"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/@vue-macros/common": { "node_modules/@vue-macros/common": {
...@@ -2274,24 +2229,59 @@ ...@@ -2274,24 +2229,59 @@
"integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==",
"dev": true "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": { "node_modules/@vueuse/core": {
"version": "10.9.0", "version": "10.11.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.0.tgz",
"integrity": "sha512-/1vjTol8SXnx6xewDEKfS0Ra//ncg4Hb0DaZiwKf7drgfMsKFExQ+FnnENcN6efPen+1kIzhLQoGSy0eDUVOMg==", "integrity": "sha512-x3sD4Mkm7PJ+pcq3HX8PLPBadXCAlSDR/waK87dz0gQE+qJnaaFhc/dZVfJz+IUYzTMVGum2QlR7ImiJQN4s6g==",
"dependencies": { "dependencies": {
"@types/web-bluetooth": "^0.0.20", "@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.9.0", "@vueuse/metadata": "10.11.0",
"@vueuse/shared": "10.9.0", "@vueuse/shared": "10.11.0",
"vue-demi": ">=0.14.7" "vue-demi": ">=0.14.8"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vueuse/core/node_modules/vue-demi": { "node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.7", "version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", "integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
"vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-fix": "bin/vue-demi-fix.js",
...@@ -2314,28 +2304,28 @@ ...@@ -2314,28 +2304,28 @@
} }
}, },
"node_modules/@vueuse/metadata": { "node_modules/@vueuse/metadata": {
"version": "10.9.0", "version": "10.11.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.9.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.0.tgz",
"integrity": "sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==", "integrity": "sha512-kQX7l6l8dVWNqlqyN3ePW3KmjCQO3ZMgXuBMddIu83CmucrsBfXlH+JoviYyRBws/yLTQO8g3Pbw+bdIoVm4oQ==",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vueuse/shared": { "node_modules/@vueuse/shared": {
"version": "10.9.0", "version": "10.11.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.9.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.0.tgz",
"integrity": "sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==", "integrity": "sha512-fyNoIXEq3PfX1L3NkNhtVQUSRtqYwJtJg+Bp9rIzculIZWHTkKSysujrOk2J+NrRulLTQH9+3gGSfYLWSEWU1A==",
"dependencies": { "dependencies": {
"vue-demi": ">=0.14.7" "vue-demi": ">=0.14.8"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vueuse/shared/node_modules/vue-demi": { "node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.7", "version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", "integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
"vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-fix": "bin/vue-demi-fix.js",
...@@ -2852,7 +2842,6 @@ ...@@ -2852,7 +2842,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"peer": true,
"bin": { "bin": {
"atob": "bin/atob.js" "atob": "bin/atob.js"
}, },
...@@ -2918,6 +2907,15 @@ ...@@ -2918,6 +2907,15 @@
"node": ">= 0.4" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
...@@ -3137,6 +3135,17 @@ ...@@ -3137,6 +3135,17 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "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": { "node_modules/buffer": {
"version": "4.9.2", "version": "4.9.2",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
...@@ -3288,6 +3297,31 @@ ...@@ -3288,6 +3297,31 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/chalk": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.2.0.tgz", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.2.0.tgz",
...@@ -3447,6 +3481,11 @@ ...@@ -3447,6 +3481,11 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/computeds": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz",
...@@ -3548,6 +3587,17 @@ ...@@ -3548,6 +3587,17 @@
"integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==", "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==",
"dev": true "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": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
...@@ -3640,6 +3690,15 @@ ...@@ -3640,6 +3690,15 @@
"node": "*" "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": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
...@@ -3669,7 +3728,7 @@ ...@@ -3669,7 +3728,7 @@
}, },
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
...@@ -3677,7 +3736,7 @@ ...@@ -3677,7 +3736,7 @@
}, },
"node_modules/d3-dispatch": { "node_modules/d3-dispatch": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
...@@ -3685,7 +3744,7 @@ ...@@ -3685,7 +3744,7 @@
}, },
"node_modules/d3-drag": { "node_modules/d3-drag": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": { "dependencies": {
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
...@@ -3697,7 +3756,7 @@ ...@@ -3697,7 +3756,7 @@
}, },
"node_modules/d3-ease": { "node_modules/d3-ease": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
...@@ -3705,7 +3764,7 @@ ...@@ -3705,7 +3764,7 @@
}, },
"node_modules/d3-interpolate": { "node_modules/d3-interpolate": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": { "dependencies": {
"d3-color": "1 - 3" "d3-color": "1 - 3"
...@@ -3716,7 +3775,7 @@ ...@@ -3716,7 +3775,7 @@
}, },
"node_modules/d3-selection": { "node_modules/d3-selection": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
...@@ -3724,7 +3783,7 @@ ...@@ -3724,7 +3783,7 @@
}, },
"node_modules/d3-timer": { "node_modules/d3-timer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
...@@ -3732,7 +3791,7 @@ ...@@ -3732,7 +3791,7 @@
}, },
"node_modules/d3-transition": { "node_modules/d3-transition": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dependencies": { "dependencies": {
"d3-color": "1 - 3", "d3-color": "1 - 3",
...@@ -3750,7 +3809,7 @@ ...@@ -3750,7 +3809,7 @@
}, },
"node_modules/d3-zoom": { "node_modules/d3-zoom": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dependencies": { "dependencies": {
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
...@@ -3976,6 +4035,12 @@ ...@@ -3976,6 +4035,12 @@
"npm": ">=1.2" "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": { "node_modules/dotenv": {
"version": "16.0.3", "version": "16.0.3",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.0.3.tgz", "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.0.3.tgz",
...@@ -4646,6 +4711,11 @@ ...@@ -4646,6 +4711,11 @@
"reusify": "^1.0.4" "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": { "node_modules/figgy-pudding": {
"version": "3.5.2", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
...@@ -4683,6 +4753,11 @@ ...@@ -4683,6 +4753,11 @@
"webpack": "^4.0.0 || ^5.0.0" "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": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
...@@ -5218,6 +5293,24 @@ ...@@ -5218,6 +5293,24 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/https-browserify": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
...@@ -5608,6 +5701,23 @@ ...@@ -5608,6 +5701,23 @@
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true "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": { "node_modules/jstoxml": {
"version": "2.2.9", "version": "2.2.9",
"resolved": "https://registry.npmmirror.com/jstoxml/-/jstoxml-2.2.9.tgz", "resolved": "https://registry.npmmirror.com/jstoxml/-/jstoxml-2.2.9.tgz",
...@@ -6672,6 +6782,12 @@ ...@@ -6672,6 +6782,12 @@
"node": ">=0.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": { "node_modules/picocolors": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.0.tgz",
...@@ -7025,6 +7141,15 @@ ...@@ -7025,6 +7141,15 @@
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true "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": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
...@@ -7083,6 +7208,11 @@ ...@@ -7083,6 +7208,11 @@
"node": ">=8.10.0" "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": { "node_modules/regex-not": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
...@@ -7186,6 +7316,15 @@ ...@@ -7186,6 +7316,15 @@
"node": ">=0.10.0" "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": { "node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz",
...@@ -7320,6 +7459,14 @@ ...@@ -7320,6 +7459,14 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/scule": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
...@@ -7651,6 +7798,15 @@ ...@@ -7651,6 +7798,15 @@
"figgy-pudding": "^3.5.1" "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": { "node_modules/static-extend": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
...@@ -7782,6 +7938,15 @@ ...@@ -7782,6 +7938,15 @@
"node": ">=8" "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": { "node_modules/tapable": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmmirror.com/tapable/-/tapable-1.1.3.tgz", "resolved": "https://registry.npmmirror.com/tapable/-/tapable-1.1.3.tgz",
...@@ -7917,6 +8082,15 @@ ...@@ -7917,6 +8082,15 @@
"source-map": "~0.6.1" "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": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz",
...@@ -8662,6 +8836,15 @@ ...@@ -8662,6 +8836,15 @@
"node": ">= 0.12.0" "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": { "node_modules/vite": {
"version": "5.2.10", "version": "5.2.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.10.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.10.tgz",
......
...@@ -16,11 +16,13 @@ ...@@ -16,11 +16,13 @@
}, },
"dependencies": { "dependencies": {
"@chuangkit/chuangkit-design": "^2.0.5", "@chuangkit/chuangkit-design": "^2.0.5",
"@dagrejs/dagre": "^1.1.3",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@fortaine/fetch-event-source": "^3.0.6", "@fortaine/fetch-event-source": "^3.0.6",
"@tinymce/tinymce-vue": "^5.0.1", "@tinymce/tinymce-vue": "^5.0.1",
"@vue-flow/controls": "^1.0.4", "@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.17.4", "@vue-flow/core": "^1.39.0",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"axios": "^1.6.8", "axios": "^1.6.8",
"blueimp-md5": "^2.19.0", "blueimp-md5": "^2.19.0",
...@@ -28,9 +30,13 @@ ...@@ -28,9 +30,13 @@
"echarts": "^5.5.0", "echarts": "^5.5.0",
"echarts-wordcloud": "^2.1.0", "echarts-wordcloud": "^2.1.0",
"element-plus": "^2.6.3", "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", "lodash-es": "^4.17.21",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"scroll-into-view-if-needed": "^3.1.0",
"vue": "^3.4.26", "vue": "^3.4.26",
"vue-echarts": "^6.6.9", "vue-echarts": "^6.6.9",
"vue-router": "^4.3.2", "vue-router": "^4.3.2",
......
import httpRequest from '@/utils/axios'
// 旅程生成用户事件数据列表(搜索条件)
export function getGenerateListFilter() {
return httpRequest.get('/api/lab/v1/experiment/member/itinerary-generate-data-condition')
}
// 旅程生成用户事件数据列表
export function getGenerateList(params: any) {
return httpRequest.get('/api/lab/v1/experiment/member/itinerary-generate-data', { params })
}
...@@ -91,3 +91,8 @@ textarea:focus { ...@@ -91,3 +91,8 @@ textarea:focus {
.info tr:last-child td { .info tr:last-child td {
padding-bottom: 0 !important; padding-bottom: 0 !important;
} }
.el-button a {
margin: -8px -15px;
padding: 8px 15px;
}
This source diff could not be displayed because it is too large. You can view the blob instead.
<script setup>
import AppList from '@/components/base/AppList.vue'
import { getGenerateListFilter, getGenerateList } from '@/api/generateEvent'
const ViewEvent = defineAsyncComponent(() => import('@/components/ViewEvent.vue'))
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const filter = reactive({ students: [], connections: [], events: [] })
async function fetchListFilters() {
const { data } = await getGenerateListFilter()
Object.assign(filter, data)
}
onMounted(() => {
fetchListFilters()
})
const students = computed(() => {
return filter.students.map((item) => {
return { ...item, name: `${item.name}/${item.mobile}` }
})
})
// 列表配置
const listOptions = computed(() => {
const userId = userStore.role.id == 1 ? userStore.user?.id : ''
return {
remote: {
httpRequest: getGenerateList,
params: { created_operator: userId, name: '', connection_id: '', experiment_meta_event_id: '', created_time_start: '', created_time_end: '' }
},
filters: [
{
type: 'select',
prop: 'created_operator',
placeholder: '请选择学生姓名',
options: students.value,
labelKey: 'name',
valueKey: 'sso_id',
disabled: !!userId
},
{ type: 'input', prop: 'name', placeholder: '请输入用户姓名' },
{ type: 'select', prop: 'connection_id', placeholder: '请选择来源连接', options: filter.connections, labelKey: 'type_name', valueKey: 'id' },
{ type: 'select', prop: 'experiment_meta_event_id', placeholder: '请选择事件', options: filter.events, labelKey: 'name', valueKey: 'id' },
{ type: 'input', prop: 'created_time_start', slots: 'filter-start' },
{ type: 'input', prop: 'created_time_end', slots: 'filter-end' }
],
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '连接', prop: 'connection_name' },
{ label: '事件名称', prop: 'event_name' },
{ label: '用户名称', prop: 'member.name' },
{ label: '手机号', prop: 'member.mobile' },
{ label: '事件发生时间', prop: 'created_time' },
{ label: '操作', slots: 'table-x', width: 220 }
]
}
})
const viewEventVisible = ref(false)
const currentViewEvent = ref()
function handleViewEvent(item) {
viewEventVisible.value = true
currentViewEvent.value = item
}
</script>
<template>
<el-dialog title="用户事件数据" width="1000px">
<AppList v-bind="listOptions" ref="appList">
<template #filter-start="{ params }">
<el-date-picker v-model="params.created_time_start" type="datetime" placeholder="请选择事件发生起始时间" value-format="YYYY-MM-DD HH:mm:ss" />
</template>
<template #filter-end="{ params }">
<el-date-picker v-model="params.created_time_end" type="datetime" placeholder="请选择事件发生截止时间" value-format="YYYY-MM-DD HH:mm:ss" />
</template>
<template #table-x="{ row }">
<el-button type="primary" plain @click="handleViewEvent(row)">事件详情</el-button>
<el-button type="primary" plain>
<router-link target="_blank" :to="{ path: '/user/image', query: { user_id: row.member.id, experiment_id: row.experiment_id } }">用户详情</router-link>
</el-button>
</template>
</AppList>
<!-- 事件详情 -->
<ViewEvent v-model="viewEventVisible" :event="currentViewEvent" :user="currentViewEvent.member" v-if="viewEventVisible && currentViewEvent"></ViewEvent>
</el-dialog>
</template>
...@@ -51,7 +51,7 @@ const listOptions = computed(() => { ...@@ -51,7 +51,7 @@ const listOptions = computed(() => {
</script> </script>
<template> <template>
<el-dialog title="标签用户" width="1000px"> <el-dialog title="群组用户" width="1000px">
<AppList v-bind="listOptions" ref="appList"> <AppList v-bind="listOptions" ref="appList">
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button type="primary" plain> <el-button type="primary" plain>
......
...@@ -51,7 +51,7 @@ const listOptions = computed(() => { ...@@ -51,7 +51,7 @@ const listOptions = computed(() => {
</script> </script>
<template> <template>
<el-dialog title="群组用户" width="1000px"> <el-dialog title="标签用户" width="1000px">
<AppList v-bind="listOptions" ref="appList"> <AppList v-bind="listOptions" ref="appList">
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button type="primary" plain> <el-button type="primary" plain>
......
...@@ -5,7 +5,6 @@ const styleHeight = computed(() => { ...@@ -5,7 +5,6 @@ const styleHeight = computed(() => {
}) })
</script> </script>
<template> <template>
<div class="app-card"> <div class="app-card">
<div class="app-card-hd"> <div class="app-card-hd">
...@@ -24,6 +23,8 @@ const styleHeight = computed(() => { ...@@ -24,6 +23,8 @@ const styleHeight = computed(() => {
<style lang="scss"> <style lang="scss">
.app-card { .app-card {
display: flex;
flex-direction: column;
min-height: v-bind(styleHeight); min-height: v-bind(styleHeight);
background: #fff; background: #fff;
box-shadow: 0 1px 6px 0 rgb(228 232 235 / 20%); box-shadow: 0 1px 6px 0 rgb(228 232 235 / 20%);
...@@ -49,4 +50,21 @@ const styleHeight = computed(() => { ...@@ -49,4 +50,21 @@ const styleHeight = computed(() => {
font-size: 14px; font-size: 14px;
} }
} }
.app-card-bd {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
}
.h2-title {
padding-left: 5px;
font-size: 18px;
font-weight: 500;
line-height: 1;
color: #aa1941;
margin: 20px 0;
border-left: 3px solid #aa1941;
display: flex;
align-items: center;
justify-content: space-between;
}
</style> </style>
...@@ -150,14 +150,15 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => { ...@@ -150,14 +150,15 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => {
overflow: hidden; overflow: hidden;
} }
.avatar-uploader { .avatar-uploader {
width: 178px; width: 180px;
height: 178px; height: 180px;
border: 1px dashed var(--el-border-color); border: 1px dashed var(--el-border-color);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
transition: var(--el-transition-duration-fast); transition: var(--el-transition-duration-fast);
box-sizing: border-box;
.el-image { .el-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
...@@ -173,4 +174,7 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => { ...@@ -173,4 +174,7 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => {
height: 100%; height: 100%;
text-align: center; text-align: center;
} }
.el-upload__tip:empty {
display: none;
}
</style> </style>
...@@ -27,12 +27,15 @@ const component = computed(() => { ...@@ -27,12 +27,15 @@ const component = computed(() => {
MAWeibo: markRaw(defineAsyncComponent(() => import('./components/marketingAction/weibo/Index.vue'))), MAWeibo: markRaw(defineAsyncComponent(() => import('./components/marketingAction/weibo/Index.vue'))),
MADingTalk: markRaw(defineAsyncComponent(() => import('./components/marketingAction/dingtalk/Index.vue'))), MADingTalk: markRaw(defineAsyncComponent(() => import('./components/marketingAction/dingtalk/Index.vue'))),
MAAB: markRaw(defineAsyncComponent(() => import('./components/marketingAction/ab/Index.vue'))), MAAB: markRaw(defineAsyncComponent(() => import('./components/marketingAction/ab/Index.vue'))),
MAXiaohongshu: markRaw(defineAsyncComponent(() => import('./components/marketingAction/xiaohongshu/Index.vue'))),
CBAttributeJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/attributeJudgment/Index.vue'))), CBAttributeJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/attributeJudgment/Index.vue'))),
CBGroupJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/groupJudgment/Index.vue'))), CBGroupJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/groupJudgment/Index.vue'))),
CBEventJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/eventJudgment/Index.vue'))), CBEventJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/eventJudgment/Index.vue'))),
CBTimeJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/timeJudgment/Index.vue'))), CBTimeJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/timeJudgment/Index.vue'))),
CBOffiaccount: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/offiaccount/Index.vue'))), CBOffiaccount: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/offiaccount/Index.vue'))),
CBLabelJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/labelJudgment/Index.vue'))) CBLabelJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/labelJudgment/Index.vue'))),
CBXiaohongshu: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/xiaohongshu/Index.vue'))),
CBDouyin: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/douyin/Index.vue')))
} }
return allComponent[props.node?.data.component_name || props.node?.data.componentName] return allComponent[props.node?.data.component_name || props.node?.data.componentName]
}) })
......
...@@ -15,6 +15,7 @@ const connections = computed(() => { ...@@ -15,6 +15,7 @@ const connections = computed(() => {
// return connectionList.value.filter(item => props.connectionIds?.includes(item.id)) // return connectionList.value.filter(item => props.connectionIds?.includes(item.id))
}) })
// component_type https://gitlab-pro.ezijing.com/ezijing-server/api-documents/blob/master/%E7%BB%9F%E4%B8%80%E8%B5%84%E6%BA%90%E7%AE%A1%E7%90%86%E7%B3%BB%E7%BB%9F/DML-%E5%AE%9E%E9%AA%8C%E5%B9%B3%E5%8F%B0%E7%AE%A1%E7%90%86-%E5%AE%9E%E9%AA%8C%E6%95%B0%E6%8D%AE%E7%AE%A1%E7%90%86.md#%E8%8E%B7%E5%8F%96%E5%AE%9E%E9%AA%8C%E6%97%85%E7%A8%8B%E6%A3%80%E7%B4%A2%E7%94%A8%E6%88%B7%E7%9A%84%E7%BB%93%E6%9E%9C
const list = ref([ const list = ref([
{ {
name: '触发条件', name: '触发条件',
...@@ -151,7 +152,7 @@ const list = ref([ ...@@ -151,7 +152,7 @@ const list = ref([
type: 2, type: 2,
type_name: '营销动作', type_name: '营销动作',
icon: '18', icon: '18',
component_type: 2, component_type: 4,
component_name: 'MADelayProcess' component_name: 'MADelayProcess'
}, },
{ {
...@@ -198,7 +199,7 @@ const list = ref([ ...@@ -198,7 +199,7 @@ const list = ref([
component_name: 'MADouyin', component_name: 'MADouyin',
connection_type: 6 connection_type: 6
}, },
// { name: '小红书', type: 2, type_name: '营销动作', icon: '8',component_type:2, component_name: 'MAXiaohongshu', connection_type: 8 }, { name: '小红书', type: 2, type_name: '营销动作', icon: '8', component_type: 13, component_name: 'MAXiaohongshu', connection_type: 8 },
{ {
name: '微博', name: '微博',
type: 2, type: 2,
...@@ -279,6 +280,24 @@ const list = ref([ ...@@ -279,6 +280,24 @@ const list = ref([
component_type: 6, component_type: 6,
component_name: 'CBOffiaccount', component_name: 'CBOffiaccount',
connection_type: 1 connection_type: 1
},
{
name: '小红书',
type: 3,
type_name: '条件分支',
icon: '8',
component_type: 7,
component_name: 'CBXiaohongshu',
connection_type: 8
},
{
name: '抖音',
type: 3,
type_name: '条件分支',
icon: '6',
component_type: 8,
component_name: 'CBDouyin',
connection_type: 6
} }
] ]
} }
......
...@@ -56,7 +56,7 @@ function updateNode() { ...@@ -56,7 +56,7 @@ function updateNode() {
</script> </script>
<template> <template>
<el-dialog title="设置组件" append-to-body width="600px"> <el-dialog title="设置组件" append-to-body width="800px">
<el-form ref="formRef" :model="form" :rules="rules" label-suffix=":"> <el-form ref="formRef" :model="form" :rules="rules" label-suffix=":">
<!-- 学生设置组件 --> <!-- 学生设置组件 -->
<template v-if="role === 'student'"> <template v-if="role === 'student'">
...@@ -102,19 +102,11 @@ function updateNode() { ...@@ -102,19 +102,11 @@ function updateNode() {
<!-- <el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button> --> <!-- <el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button> -->
<el-button @click="step--" plain auto-insert-space v-if="step >= 1">上一步</el-button> <el-button @click="step--" plain auto-insert-space v-if="step >= 1">上一步</el-button>
<el-button @click="step++" plain auto-insert-space v-if="step < stepNum">下一步</el-button> <el-button @click="step++" plain auto-insert-space v-if="step < stepNum">下一步</el-button>
<el-button <el-button type="primary" auto-insert-space @click="submit().then(() => $emit('update:modelValue', false))" v-if="step === stepNum">保存</el-button>
type="primary"
auto-insert-space
@click="submit().then(() => $emit('update:modelValue', false))"
v-if="step === stepNum"
>保存</el-button
>
</el-row> </el-row>
<el-row justify="center" v-else> <el-row justify="center" v-else>
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button> <el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="submit().then(() => $emit('update:modelValue', false))" <el-button type="primary" auto-insert-space @click="submit().then(() => $emit('update:modelValue', false))">保存</el-button>
>保存</el-button
>
</el-row> </el-row>
</template> </template>
</el-dialog> </el-dialog>
......
...@@ -38,7 +38,7 @@ function updateNode() { ...@@ -38,7 +38,7 @@ function updateNode() {
</script> </script>
<template> <template>
<el-dialog title="组件配置" append-to-body width="600px"> <el-dialog title="组件配置" append-to-body width="800px">
<el-form ref="formRef" :model="form" :rules="rules" label-suffix=":"> <el-form ref="formRef" :model="form" :rules="rules" label-suffix=":">
<el-row justify="space-between"> <el-row justify="space-between">
<el-form-item label="组件类型">{{ node.data.type_name }}</el-form-item> <el-form-item label="组件类型">{{ node.data.type_name }}</el-form-item>
...@@ -67,9 +67,7 @@ function updateNode() { ...@@ -67,9 +67,7 @@ function updateNode() {
<template #footer> <template #footer>
<el-row justify="center"> <el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button> <el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="submit" v-if="role === 'teacher' && templateType === '2'" <el-button type="primary" auto-insert-space @click="submit" v-if="role === 'teacher' && templateType === '2'">保存</el-button>
>保存</el-button
>
</el-row> </el-row>
</template> </template>
</el-dialog> </el-dialog>
......
...@@ -28,26 +28,26 @@ function onRemove() { ...@@ -28,26 +28,26 @@ function onRemove() {
<div class="custom-node__inner"><slot :node="node" /></div> <div class="custom-node__inner"><slot :node="node" /></div>
<p class="node-label">{{ node.label }}</p> <p class="node-label">{{ node.label }}</p>
</div> </div>
<Handle id="handle-left" class="handle" :position="Position.Left" :connectable="false" /> <Handle id="handle-left" class="handle" :position="Position.Left" type="target" />
<Handle id="handle-top" class="handle" :position="Position.Top" :connectable="false" /> <Handle id="handle-top" class="handle" :position="Position.Top" type="target" />
<Handle id="handle-bottom" class="handle" :position="Position.Bottom" :connectable="false" /> <Handle id="handle-bottom" class="handle" :position="Position.Bottom" type="target" />
<Handle id="handle-right" class="handle" :position="Position.Right" :connectable="false" /> <Handle id="handle-right" class="handle" :position="Position.Right" type="target" />
<div class="handle-wrap" v-if="!!(nodesConnectable && canConnect)"> <div class="handle-wrap" v-if="!!(nodesConnectable && canConnect)">
<template v-if="connectionType === 1"> <template v-if="connectionType === 1">
<Handle id="handle-yes" class="handle-link is-yes" :position="Position.Left"></Handle> <Handle id="handle-yes" class="handle-link is-yes" :position="Position.Left" type="source"></Handle>
<Handle id="handle-no" class="handle-link is-no" :position="Position.Left"></Handle> <Handle id="handle-no" class="handle-link is-no" :position="Position.Left" type="source"></Handle>
</template> </template>
<template v-else-if="connectionType === 2"> <template v-else-if="connectionType === 2">
<Handle id="handle-success" class="handle-link is-yes" :position="Position.Left">成功</Handle> <Handle id="handle-success" class="handle-link is-yes" :position="Position.Left" type="source">成功</Handle>
<Handle id="handle-failure" class="handle-link is-no" :position="Position.Left">失败</Handle> <Handle id="handle-failure" class="handle-link is-no" :position="Position.Left" type="source">失败</Handle>
<Handle id="handle-any" class="handle-link is-any" :position="Position.Left">继续</Handle> <Handle id="handle-any" class="handle-link is-any" :position="Position.Left" type="source">继续</Handle>
</template> </template>
<template v-else-if="connectionType === 3"> <template v-else-if="connectionType === 3">
<Handle id="handle-a" class="handle-link is-yes" :position="Position.Left">A</Handle> <Handle id="handle-a" class="handle-link is-yes" :position="Position.Left" type="source">A</Handle>
<Handle id="handle-b" class="handle-link is-no" :position="Position.Left">B</Handle> <Handle id="handle-b" class="handle-link is-no" :position="Position.Left" type="source">B</Handle>
</template> </template>
<template v-else> <template v-else>
<Handle id="handle-any" class="handle-default" :position="Position.Left"> <Handle id="handle-any" class="handle-default" :position="Position.Left" type="source">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="styles__StyledSVGIconPathComponent-sc-16fsqc8-0 dGjRCM svg-icon-path-icon fill" class="styles__StyledSVGIconPathComponent-sc-16fsqc8-0 dGjRCM svg-icon-path-icon fill"
...@@ -98,6 +98,7 @@ function onRemove() { ...@@ -98,6 +98,7 @@ function onRemove() {
} }
.handle-default { .handle-default {
left: 0; left: 0;
transform: translateY(-50%);
svg { svg {
pointer-events: none; pointer-events: none;
} }
......
...@@ -8,7 +8,7 @@ const props = defineProps<{ node: any }>() ...@@ -8,7 +8,7 @@ const props = defineProps<{ node: any }>()
const role = inject('role') as string const role = inject('role') as string
const form = reactive({ const form: any = reactive({
rules: [{ attr_id: '', attr_type: '', operate: '', value: '' }] rules: [{ attr_id: '', attr_type: '', operate: '', value: '' }]
}) })
watchEffect(() => { watchEffect(() => {
...@@ -33,7 +33,7 @@ function onAttrChange(rule: any) { ...@@ -33,7 +33,7 @@ function onAttrChange(rule: any) {
} }
function addRule() { function addRule() {
form.rules.push({ attr_id: '', attr_type: '', operate: '=', value: '' }) form.rules.push({ attr_id: '', attr_type: '', operate: '', value: '' })
} }
function removeRule(index: number) { function removeRule(index: number) {
...@@ -43,6 +43,15 @@ function removeRule(index: number) { ...@@ -43,6 +43,15 @@ function removeRule(index: number) {
function getAttr(attrId: string) { function getAttr(attrId: string) {
return userAttrList.value.find(item => item.id === attrId) return userAttrList.value.find(item => item.id === attrId)
} }
// 条件改变
function handleOperateChange(value: string, item: any) {
item.value = ''
// 区间
if (value === 'range') {
item.value = { start: undefined, end: undefined }
}
}
</script> </script>
<template> <template>
...@@ -52,27 +61,39 @@ function getAttr(attrId: string) { ...@@ -52,27 +61,39 @@ function getAttr(attrId: string) {
<el-select v-model="rule.attr_id" @change="onAttrChange(rule)" placeholder="请选择属性" style="width: 120px"> <el-select v-model="rule.attr_id" @change="onAttrChange(rule)" placeholder="请选择属性" style="width: 120px">
<el-option :key="item.id" v-for="item in userAttrList" :value="item.id" :label="item.name"></el-option> <el-option :key="item.id" v-for="item in userAttrList" :value="item.id" :label="item.name"></el-option>
</el-select> </el-select>
<el-select v-model="rule.operate" style="margin: 0 10px; width: 120px"> <el-select v-model="rule.operate" style="margin: 0 10px; width: 120px" @change="value => handleOperateChange(value, rule)">
<el-option <el-option v-for="item in getOperatorList(rule.attr_type)" :key="item.value" :value="item.value" :label="item.label"></el-option>
v-for="item in getOperatorList(rule.attr_type)"
:key="item.value"
:value="item.value"
:label="item.label"></el-option>
</el-select> </el-select>
<el-input <el-form-item v-if="!['null', 'not null'].includes(rule.operate)">
v-model="rule.value" <!-- 数字区间 -->
placeholder="请输入属性值" <template v-if="['2', '3'].includes(rule.attr_type) && rule.operate === 'range'">
:maxlength="getAttr(rule.attr_id)?.format" <el-input-number step-strictly :controls="false" :min="0" v-model="rule.value.start" />
style="width: 180px"></el-input> <el-input-number step-strictly :controls="false" :min="0" v-model="rule.value.end" style="margin-left: 10px" />
</template>
<!-- 日期区间 -->
<template v-else-if="rule.attr_type === '4' && rule.operate === 'range'">
<el-date-picker v-model="rule.value.start" type="date" value-format="YYYY-MM-DD" style="width: 180px" />
<el-date-picker v-model="rule.value.end" type="date" value-format="YYYY-MM-DD" style="width: 180px; margin-left: 10px" />
</template>
<!-- 时间区间 -->
<template v-else-if="rule.attr_type === '5' && rule.operate === 'range'">
<el-date-picker v-model="rule.value.start" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width: 180px" />
<el-date-picker v-model="rule.value.end" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width: 180px; margin-left: 10px" />
</template>
<template v-else-if="rule.attr_type === '4' && (rule.operate === 'after' || rule.operate === 'before')">
<el-date-picker v-model="rule.value" type="date" value-format="YYYY-MM-DD" />
</template>
<template v-else-if="rule.attr_type === '5' && (rule.operate === 'after' || rule.operate === 'before')">
<el-date-picker v-model="rule.value" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width: 180px; margin-left: 10px" />
</template>
<template v-else>
<el-input v-model="rule.value" placeholder="请输入属性值" :maxlength="getAttr(rule.attr_id)?.format" style="width: 180px"></el-input>
</template>
</el-form-item>
<el-icon style="margin-left: 10px" size="20" color="#cf5b78" @click="removeRule(index)" v-if="index !== 0"> <el-icon style="margin-left: 10px" size="20" color="#cf5b78" @click="removeRule(index)" v-if="index !== 0">
<RemoveFilled /> <RemoveFilled />
</el-icon> </el-icon>
<el-icon <el-icon style="margin-left: 10px" size="20" color="#cf5b78" @click="addRule" v-if="index === form.rules.length - 1">
style="margin-left: 10px"
size="20"
color="#cf5b78"
@click="addRule"
v-if="index === form.rules.length - 1">
<CirclePlusFilled /> <CirclePlusFilled />
</el-icon> </el-icon>
</el-row> </el-row>
......
<script setup lang="ts">
import ConfigTemplate from '../../ConfigTemplate.vue'
import { useConnection } from '../../../composables/useAllData'
const props = defineProps<{ node: any }>()
const role = inject('role') as string
const form = reactive({
operate: '',
operate_name: '',
connection_id: '',
connection_name: ''
})
watchEffect(() => {
Object.assign(form, props.node.data[role])
})
const { connectionList } = useConnection(6)
const operateList = ref([{ label: '关注中', value: '0' }])
watchEffect(() => {
form.operate_name = operateList.value.find(item => item.value === form.operate)?.label || ''
})
watchEffect(() => {
form.connection_name = connectionList.value.find(item => item.id === form.connection_id)?.name || ''
})
</script>
<template>
<ConfigTemplate :model="form" :node="node" :stepNum="1">
<template #default="{ step }: { step: number }">
<el-form-item>
<template v-if="step === 0">
<el-radio-group v-model="form.operate" v-if="step === 0">
<el-radio v-for="item in operateList" :key="item.value" :value="item.value" style="width: 105px">
{{ item.label }}
</el-radio>
</el-radio-group>
</template>
<template v-else-if="step === 1">
<el-select placeholder="请选择关联使用抖音" style="width: 100%" v-model="form.connection_id">
<el-option v-for="item in connectionList" :key="item.id" :value="item.id" :label="item.name"></el-option>
</el-select>
</template>
</el-form-item>
</template>
</ConfigTemplate>
</template>
<script setup lang="ts">
import ConfigViewTemplate from '../../ConfigViewTemplate.vue'
import { useConnection } from '../../../composables/useAllData'
const role = inject('role') as string
defineProps<{ node: any }>()
const operateList = [{ label: '关注中', value: '0' }]
const { connectionList } = useConnection()
const getConnectionLabel = function (id: string) {
return connectionList.value.find(item => item.id === id)?.name || ''
}
</script>
<template>
<ConfigViewTemplate :node="node">
<el-form-item :label="role === 'student' ? '我的答案' : '学生答案'">
{{ operateList.find(item => item.value === node.data.student?.operate)?.label || '' }}
{{ getConnectionLabel(node.data.student?.connection_id) }}
</el-form-item>
<el-form-item label="正确答案">
{{ operateList.find(item => item.value === node.data.teacher?.operate)?.label || '' }}
{{ getConnectionLabel(node.data.teacher?.connection_id) }}
</el-form-item>
</ConfigViewTemplate>
</template>
<!-- 公众号 -->
<script setup lang="ts">
import NodeTemplate from '../../NodeTemplate.vue'
import Icon from '@/components/ConnectionIcon.vue'
const Config = defineAsyncComponent(() => import('./Config.vue'))
const ConfigView = defineAsyncComponent(() => import('./ConfigView.vue'))
const Rule = defineAsyncComponent(() => import('./Rule.vue'))
const props = defineProps<{ node: any }>()
const action = inject('action') as string
const role = inject('role') as string
const templateType = inject('templateType') as string
// 是否置灰
const isGray = computed(() => {
return templateType === '2' && role === 'student' && action === 'edit' && !props.node.data[role]
})
// 设置
const settingVisible = ref(false)
</script>
<template>
<NodeTemplate :node="node" :connectionType="1" @setting="settingVisible = true">
<div class="node-item">
<Icon class="circle" name="hexagon" :color="isGray ? '#9a9a9a' : '#ceaa62'" w="60" h="60"></Icon>
<Icon class="icon" name="6" color="#fff" w="24" h="24"></Icon>
</div>
</NodeTemplate>
<!-- 配置 -->
<Config v-model="settingVisible" :node="node" v-if="settingVisible && action === 'edit'" />
<!-- 查看配置 -->
<ConfigView v-model="settingVisible" :node="node" v-if="settingVisible && action === 'view'" />
<!-- 数据生成规则 -->
<Rule v-model="settingVisible" :node="node" v-if="settingVisible && action === 'rule'" />
</template>
<script setup lang="ts">
import RuleTemplate from '../../RuleTemplate.vue'
import { useConnection } from '../../../composables/useAllData'
const { getConnection } = useConnection()
const props = defineProps<{ node: any }>()
const config = computed(() => {
return props.node.data.teacher || {}
})
const connection = computed(() => {
return getConnection(config.value?.connection_id)
})
</script>
<template>
<RuleTemplate :node="node" step>
<template #header-answer>
连接:<span class="is-answer">{{ connection?.name }}</span> &nbsp;&nbsp; 事件:<span class="is-answer">关注抖音</span>
</template>
</RuleTemplate>
</template>
...@@ -52,12 +52,7 @@ watchEffect(() => { ...@@ -52,12 +52,7 @@ watchEffect(() => {
<el-select v-model="form.date_rule" placeholder="请选择" style="width: 115px; margin-right: 10px"> <el-select v-model="form.date_rule" placeholder="请选择" style="width: 115px; margin-right: 10px">
<el-option v-for="item in dateRuleList" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in dateRuleList" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
<el-date-picker <el-date-picker v-if="['0', '1'].includes(form.date_rule)" v-model="form.date" type="date" placeholder="请选择" value-format="YYYY-MM-DD" />
v-if="['0', '1'].includes(form.date_rule)"
v-model="form.date"
type="date"
placeholder="请选择"
value-format="YYYY-MM-DD" />
<el-date-picker <el-date-picker
v-else v-else
v-model="form.date" v-model="form.date"
...@@ -69,7 +64,7 @@ watchEffect(() => { ...@@ -69,7 +64,7 @@ watchEffect(() => {
</template> </template>
<template v-else> <template v-else>
<el-checkbox-group v-model="form.week"> <el-checkbox-group v-model="form.week">
<el-checkbox v-for="item in weekList" :key="item" :value="item" /> <el-checkbox v-for="item in weekList" :key="item" :label="item" :value="item" />
</el-checkbox-group> </el-checkbox-group>
</template> </template>
</el-form-item> </el-form-item>
......
<script setup lang="ts">
import ConfigTemplate from '../../ConfigTemplate.vue'
import { useConnection } from '../../../composables/useAllData'
const props = defineProps<{ node: any }>()
const role = inject('role') as string
const form = reactive({
operate: '',
operate_name: '',
connection_id: '',
connection_name: ''
})
watchEffect(() => {
Object.assign(form, props.node.data[role])
})
const { connectionList } = useConnection(8)
const operateList = ref([{ label: '关注中', value: '0' }])
watchEffect(() => {
form.operate_name = operateList.value.find(item => item.value === form.operate)?.label || ''
})
watchEffect(() => {
form.connection_name = connectionList.value.find(item => item.id === form.connection_id)?.name || ''
})
</script>
<template>
<ConfigTemplate :model="form" :node="node" :stepNum="1">
<template #default="{ step }: { step: number }">
<el-form-item>
<template v-if="step === 0">
<el-radio-group v-model="form.operate" v-if="step === 0">
<el-radio v-for="item in operateList" :key="item.value" :value="item.value" style="width: 105px">
{{ item.label }}
</el-radio>
</el-radio-group>
</template>
<template v-else-if="step === 1">
<el-select placeholder="请选择关联使用小红书" style="width: 100%" v-model="form.connection_id">
<el-option v-for="item in connectionList" :key="item.id" :value="item.id" :label="item.name"></el-option>
</el-select>
</template>
</el-form-item>
</template>
</ConfigTemplate>
</template>
<script setup lang="ts">
import ConfigViewTemplate from '../../ConfigViewTemplate.vue'
import { useConnection } from '../../../composables/useAllData'
const role = inject('role') as string
defineProps<{ node: any }>()
const operateList = [{ label: '关注中', value: '0' }]
const { connectionList } = useConnection()
const getConnectionLabel = function (id: string) {
return connectionList.value.find(item => item.id === id)?.name || ''
}
</script>
<template>
<ConfigViewTemplate :node="node">
<el-form-item :label="role === 'student' ? '我的答案' : '学生答案'">
{{ operateList.find(item => item.value === node.data.student?.operate)?.label || '' }}
{{ getConnectionLabel(node.data.student?.connection_id) }}
</el-form-item>
<el-form-item label="正确答案">
{{ operateList.find(item => item.value === node.data.teacher?.operate)?.label || '' }}
{{ getConnectionLabel(node.data.teacher?.connection_id) }}
</el-form-item>
</ConfigViewTemplate>
</template>
<!-- 公众号 -->
<script setup lang="ts">
import NodeTemplate from '../../NodeTemplate.vue'
import Icon from '@/components/ConnectionIcon.vue'
const Config = defineAsyncComponent(() => import('./Config.vue'))
const ConfigView = defineAsyncComponent(() => import('./ConfigView.vue'))
const Rule = defineAsyncComponent(() => import('./Rule.vue'))
const props = defineProps<{ node: any }>()
const action = inject('action') as string
const role = inject('role') as string
const templateType = inject('templateType') as string
// 是否置灰
const isGray = computed(() => {
return templateType === '2' && role === 'student' && action === 'edit' && !props.node.data[role]
})
// 设置
const settingVisible = ref(false)
</script>
<template>
<NodeTemplate :node="node" :connectionType="1" @setting="settingVisible = true">
<div class="node-item">
<Icon class="circle" name="hexagon" :color="isGray ? '#9a9a9a' : '#ceaa62'" w="60" h="60"></Icon>
<Icon class="icon" name="8" color="#fff" w="24" h="24"></Icon>
</div>
</NodeTemplate>
<!-- 配置 -->
<Config v-model="settingVisible" :node="node" v-if="settingVisible && action === 'edit'" />
<!-- 查看配置 -->
<ConfigView v-model="settingVisible" :node="node" v-if="settingVisible && action === 'view'" />
<!-- 数据生成规则 -->
<Rule v-model="settingVisible" :node="node" v-if="settingVisible && action === 'rule'" />
</template>
<script setup lang="ts">
import RuleTemplate from '../../RuleTemplate.vue'
import { useConnection } from '../../../composables/useAllData'
const { getConnection } = useConnection()
const props = defineProps<{ node: any }>()
const config = computed(() => {
return props.node.data.teacher || {}
})
const connection = computed(() => {
return getConnection(config.value?.connection_id)
})
</script>
<template>
<RuleTemplate :node="node" step>
<template #header-answer>
连接:<span class="is-answer">{{ connection?.name }}</span> &nbsp;&nbsp; 事件:<span class="is-answer"
>关注小红书</span
>
</template>
</RuleTemplate>
</template>
<script setup lang="ts">
import ConfigTemplate from '../../ConfigTemplate.vue'
import { useMaterial, useConnection } from '../../../composables/useAllData'
import { useMapStore } from '@/stores/map'
const props = defineProps<{ node: any }>()
const role = inject('role') as string
const form = reactive({
operate: '',
material_type: '',
material_id: '',
connection_id: ''
})
watchEffect(() => {
Object.assign(form, props.node.data[role])
})
const operateList = ref([
{ label: '向用户发送文本私信', value: '0' },
{ label: '向用户发送图片私信', value: '1' },
{ label: '向用户发送视频私信', value: '2' }
])
const materialTypeList = useMapStore().getMapValuesByKey('experiment_marketing_material_type')
const { materialList, materialType } = useMaterial()
watchEffect(() => {
materialType.value = form.material_type
})
const { connectionList } = useConnection(8)
</script>
<template>
<ConfigTemplate :model="form" :node="node" :stepNum="2">
<template #default="{ step }: { step: number }">
<template v-if="step === 0">
<el-form-item>
<el-radio-group v-model="form.operate" v-if="step === 0">
<el-radio v-for="item in operateList" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</template>
<template v-else-if="step === 1">
<el-form-item>
<el-select placeholder="请选择" style="width: 100%" v-model="form.connection_id">
<el-option v-for="item in connectionList" :key="item.id" :value="item.id" :label="item.name"></el-option>
</el-select>
</el-form-item>
</template>
<template v-else-if="step === 2">
<el-form-item>
<el-radio-group v-model="form.material_type" @change="form.material_id = ''">
<el-radio v-for="item in materialTypeList" :key="item.id" :value="item.value" style="width: 105px">
发送{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-select placeholder="请选择资料内容" v-model="form.material_id" style="width: 100%">
<el-option v-for="item in materialList" :key="item.id" :value="item.id" :label="item.name"></el-option>
</el-select>
</el-form-item>
</template>
</template>
</ConfigTemplate>
</template>
<script setup lang="ts">
import ConfigViewTemplate from '../../ConfigViewTemplate.vue'
import { useMaterial, useConnection } from '../../../composables/useAllData'
import { useMapStore } from '@/stores/map'
const role = inject('role') as string
defineProps<{ node: any }>()
const operateList = ref([
{ label: '向用户发送文本私信', value: '0' },
{ label: '向用户发送图片私信', value: '1' },
{ label: '向用户发送视频私信', value: '2' }
])
const { connectionList } = useConnection()
const getConnectionLabel = function (id: string) {
return connectionList.value.find(item => item.id === id)?.name || ''
}
const materialTypeList = useMapStore().getMapValuesByKey('experiment_marketing_material_type')
const { materialList } = useMaterial()
</script>
<template>
<ConfigViewTemplate :node="node">
<el-form-item :label="role === 'student' ? '我的答案' : '学生答案'">
{{ operateList.find(item => item.value === node.data.student?.operate)?.label || '' }}
{{ getConnectionLabel(node.data.student?.connection_id) }}
发送{{ materialTypeList.find(item => item.value === node.data.student?.material_type)?.label }}
{{ materialList.find(item => item.id === node.data.student?.material_id)?.name }}
</el-form-item>
<el-form-item label="正确答案">
{{ operateList.find(item => item.value === node.data.teacher?.operate)?.label || '' }}
{{ getConnectionLabel(node.data.teacher?.connection_id) }}
发送{{ materialTypeList.find(item => item.value === node.data.teacher?.material_type)?.label }}
{{ materialList.find(item => item.id === node.data.teacher?.material_id)?.name }}
</el-form-item>
</ConfigViewTemplate>
</template>
<!-- 抖音 -->
<script setup lang="ts">
import NodeTemplate from '../../NodeTemplate.vue'
import Icon from '@/components/ConnectionIcon.vue'
const Config = defineAsyncComponent(() => import('./Config.vue'))
const ConfigView = defineAsyncComponent(() => import('./ConfigView.vue'))
const Rule = defineAsyncComponent(() => import('./Rule.vue'))
const props = defineProps<{ node: any }>()
const action = inject('action') as string
const role = inject('role') as string
const templateType = inject('templateType') as string
// 是否置灰
const isGray = computed(() => {
return templateType === '2' && role === 'student' && action === 'edit' && !props.node.data[role]
})
// 设置
const settingVisible = ref(false)
</script>
<template>
<NodeTemplate :node="node" :connectionType="2" @setting="settingVisible = true">
<div class="node-item">
<Icon class="circle" name="square" :color="isGray ? '#9a9a9a' : '#19AAA5'" w="60" h="60"></Icon>
<Icon class="icon" name="8" color="#fff" w="24" h="24"></Icon>
</div>
</NodeTemplate>
<!-- 配置 -->
<Config v-model="settingVisible" :node="node" v-if="settingVisible && action === 'edit'" />
<!-- 查看配置 -->
<ConfigView v-model="settingVisible" :node="node" v-if="settingVisible && action === 'view'" />
<!-- 数据生成规则 -->
<Rule v-model="settingVisible" :node="node" v-if="settingVisible && action === 'rule'" />
</template>
<script setup lang="ts">
import RuleTemplate from '../../RuleTemplate.vue'
defineProps<{ node: any }>()
</script>
<template>
<RuleTemplate :node="node" step>
<template #header-answer>答案</template>
</RuleTemplate>
</template>
...@@ -7,8 +7,9 @@ const props = defineProps<{ node: any }>() ...@@ -7,8 +7,9 @@ const props = defineProps<{ node: any }>()
const role = inject('role') as string const role = inject('role') as string
const form = reactive({ const form: any = reactive({
attr_id: '', attr_id: '',
attr_type: '',
operate: '', operate: '',
value: '' value: ''
}) })
...@@ -32,6 +33,8 @@ const operatorList = computed(() => { ...@@ -32,6 +33,8 @@ const operatorList = computed(() => {
}) })
function onAttrChange() { function onAttrChange() {
const currentUserAttr = userAttrList.value.find(item => item.id === form.attr_id)
form.attr_type = currentUserAttr ? currentUserAttr.type : ''
form.operate = '' form.operate = ''
form.value = '' form.value = ''
} }
...@@ -39,6 +42,15 @@ function onAttrChange() { ...@@ -39,6 +42,15 @@ function onAttrChange() {
function getAttr(attrId: string) { function getAttr(attrId: string) {
return userAttrList.value.find(item => item.id === attrId) return userAttrList.value.find(item => item.id === attrId)
} }
// 条件改变
function handleOperateChange(value: string) {
form.value = ''
// 区间
if (value === 'range') {
form.value = { start: undefined, end: undefined }
}
}
</script> </script>
<template> <template>
...@@ -49,18 +61,33 @@ function getAttr(attrId: string) { ...@@ -49,18 +61,33 @@ function getAttr(attrId: string) {
<el-select v-model="form.attr_id" @change="onAttrChange" placeholder="请选择属性" style="width: 130px"> <el-select v-model="form.attr_id" @change="onAttrChange" placeholder="请选择属性" style="width: 130px">
<el-option :key="item.id" v-for="item in userAttrList" :value="item.id" :label="item.name"></el-option> <el-option :key="item.id" v-for="item in userAttrList" :value="item.id" :label="item.name"></el-option>
</el-select> </el-select>
<el-select v-model="form.operate" style="margin: 0 10px; width: 130px"> <el-select v-model="form.operate" style="margin: 0 10px; width: 130px" @change="handleOperateChange">
<el-option <el-option v-for="item in operatorList" :key="item.value" :value="item.value" :label="item.label"></el-option>
v-for="item in operatorList"
:key="item.value"
:value="item.value"
:label="item.label"></el-option>
</el-select> </el-select>
<el-input <!-- 数字区间 -->
v-model="form.value" <template v-if="['2', '3'].includes(form.attr_type) && form.operate === 'range'">
placeholder="请输入" <el-input-number step-strictly :controls="false" :min="0" v-model="form.value.start" />
:maxlength="getAttr(form.attr_id)?.format" <el-input-number step-strictly :controls="false" :min="0" v-model="form.value.end" style="margin-left: 10px" />
style="width: 200px"></el-input> </template>
<!-- 日期区间 -->
<template v-else-if="form.attr_type === '4' && form.operate === 'range'">
<el-date-picker v-model="form.value.start" type="date" value-format="YYYY-MM-DD" style="width: 180px" />
<el-date-picker v-model="form.value.end" type="date" value-format="YYYY-MM-DD" style="width: 180px; margin-left: 10px" />
</template>
<!-- 时间区间 -->
<template v-else-if="form.attr_type === '5' && form.operate === 'range'">
<el-date-picker v-model="form.value.start" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width: 180px" />
<el-date-picker v-model="form.value.end" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width: 180px; margin-left: 10px" />
</template>
<template v-else-if="form.attr_type === '4' && (form.operate === 'after' || form.operate === 'before')">
<el-date-picker v-model="form.value" type="date" value-format="YYYY-MM-DD" />
</template>
<template v-else-if="form.attr_type === '5' && (form.operate === 'after' || form.operate === 'before')">
<el-date-picker v-model="form.value" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width: 180px; margin-left: 10px" />
</template>
<template v-else>
<el-input v-model="form.value" placeholder="请输入属性值" :maxlength="getAttr(form.attr_id)?.format" style="width: 180px"></el-input>
</template>
</div> </div>
<p style="font-size: 12px; text-align: right; color: #ccc">所选择属性满足该条件的用户,将会触发该旅程</p> <p style="font-size: 12px; text-align: right; color: #ccc">所选择属性满足该条件的用户,将会触发该旅程</p>
</div> </div>
......
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="64 64 896 896">
<g>
<path
d="M384 912h496c17.7 0 32-14.3 32-32V340H384v572zm496-800H384v164h528V144c0-17.7-14.3-32-32-32zm-768 32v736c0 17.7 14.3 32 32 32h176V112H144c-17.7 0-32 14.3-32 32z"></path>
</g>
</svg>
</template>
...@@ -38,16 +38,16 @@ function handleRfmChange(rfmKey: string, item: any) { ...@@ -38,16 +38,16 @@ function handleRfmChange(rfmKey: string, item: any) {
const found = rfmResList.value.find(item => item.frm_key === rfmKey) const found = rfmResList.value.find(item => item.frm_key === rfmKey)
item.rfm_value = found?.frm_value item.rfm_value = found?.frm_value
} }
const a = [ // const a = [
{ label: '重要价值用户', label_des: '最近使用,使用频率高,消费金额大', r: '高', f: '高', m: '高', group: '组合1', guide: '留存与促活' }, // { label: '重要价值用户', label_des: '最近使用,使用频率高,消费金额大', r: '高', f: '高', m: '高', group: '组合1', guide: '留存与促活' },
{ label: '一般价值用户', label_des: '最近使用,使用频率高,消费金额小', r: '高', f: '高', m: '低', group: '组合2', guide: '放弃' }, // { label: '一般价值用户', label_des: '最近使用,使用频率高,消费金额小', r: '高', f: '高', m: '低', group: '组合2', guide: '放弃' },
{ label: '重要发展用户', label_des: '最近使用,使用频率低,消费金额大', r: '高', f: '低', m: '高', group: '组合3', guide: '拉新客户' }, // { label: '重要发展用户', label_des: '最近使用,使用频率低,消费金额大', r: '高', f: '低', m: '高', group: '组合3', guide: '拉新客户' },
{ label: '一般发展用户', label_des: '最近使用,使用频率低,消费金额小', r: '高', f: '低', m: '低', group: '组合4', guide: '放弃' }, // { label: '一般发展用户', label_des: '最近使用,使用频率低,消费金额小', r: '高', f: '低', m: '低', group: '组合4', guide: '放弃' },
{ label: '重要保持用户', label_des: '较长时间未使用,使用频率高,消费金额大', r: '低', f: '高', m: '高', group: '组合5', guide: '留存与促活' }, // { label: '重要保持用户', label_des: '较长时间未使用,使用频率高,消费金额大', r: '低', f: '高', m: '高', group: '组合5', guide: '留存与促活' },
{ label: '一般保持用户', label_des: '较长时间未使用,使用频率高,消费金额小', r: '低', f: '高', m: '低', group: '组合6', guide: '放弃' }, // { label: '一般保持用户', label_des: '较长时间未使用,使用频率高,消费金额小', r: '低', f: '高', m: '低', group: '组合6', guide: '放弃' },
{ label: '重要挽留用户', label_des: '较长时间未使用,使用频率低,消费金额大', r: '低', f: '低', m: '高', group: '组合7', guide: '流失客户' }, // { label: '重要挽留用户', label_des: '较长时间未使用,使用频率低,消费金额大', r: '低', f: '低', m: '高', group: '组合7', guide: '流失客户' },
{ label: '一般挽留用户', label_des: '较长时间未使用,使用频率低,消费金额小', r: '低', f: '低', m: '低', group: '组合8', guide: '放弃' } // { label: '一般挽留用户', label_des: '较长时间未使用,使用频率低,消费金额小', r: '低', f: '低', m: '低', group: '组合8', guide: '放弃' }
] // ]
</script> </script>
<template> <template>
...@@ -83,14 +83,14 @@ const a = [ ...@@ -83,14 +83,14 @@ const a = [
</el-option> </el-option>
</el-select> </el-select>
<el-popover popper-class="rfm-popover" placement="top" :width="800" trigger="hover"> <el-popover popper-class="rfm-popover" placement="top" :width="800" trigger="hover">
<el-table :data="a" border stripe> <el-table :data="rfmResList" border stripe>
<el-table-column prop="group" label="组合" width="70" /> <el-table-column prop="frm_extend_info.group" label="组合" width="70" />
<el-table-column prop="r" label="R值" width="52" /> <el-table-column prop="frm_extend_info.r" label="R值" width="52" />
<el-table-column prop="f" label="F值" width="52" /> <el-table-column prop="frm_extend_info.f" label="F值" width="52" />
<el-table-column prop="m" label="M值" width="52" /> <el-table-column prop="frm_extend_info.m" label="M值" width="52" />
<el-table-column prop="label" label="标签值" width="110" /> <el-table-column prop="frm_value" label="标签值" width="110" />
<el-table-column prop="label_des" label="标签说明" /> <el-table-column prop="frm_extend_info.label_desc" label="标签说明" />
<el-table-column prop="guide" label="客户营销策略" width="110" /> <el-table-column prop="frm_extend_info.customer_marketing_strategy" label="客户营销策略" width="110" />
</el-table> </el-table>
<template #reference> <template #reference>
<el-icon><QuestionFilled /></el-icon> <el-icon><QuestionFilled /></el-icon>
......
...@@ -26,7 +26,18 @@ const defaultScore = [ ...@@ -26,7 +26,18 @@ const defaultScore = [
] ]
onMounted(() => { onMounted(() => {
form.value = Object.assign({ basis: '1', rule: '101', event_id: '-1', attr_id: '', attr_type: '', config: [...defaultScore] }, form.value) form.value = Object.assign(
{
basis: '1',
rule: '101',
event_id: '-1',
attr_id: '',
attr_type: '',
config: [...defaultScore],
extend_config: { default_score_config: { switch: false, score: undefined } }
},
form.value
)
}) })
const { userAttrList, fetchUserAttrList } = useUserAttr() const { userAttrList, fetchUserAttrList } = useUserAttr()
...@@ -111,6 +122,8 @@ const a = [ ...@@ -111,6 +122,8 @@ const a = [
{ id: '004', label: '2200' }, { id: '004', label: '2200' },
{ id: '005', label: '1800' } { id: '005', label: '1800' }
] ]
const defaultOptions = Array.from({ length: 5 }).map((_, index) => ({ value: index + 1, label: index + 1 }))
</script> </script>
<template> <template>
...@@ -169,9 +182,15 @@ const a = [ ...@@ -169,9 +182,15 @@ const a = [
<el-select v-model="form.attr_id" placeholder="选择属性" style="width: 160px" @change="handleAttrChange" v-else> <el-select v-model="form.attr_id" placeholder="选择属性" style="width: 160px" @change="handleAttrChange" v-else>
<el-option v-for="item in userAttrList" :key="item.id" :label="item.name" :value="item.id"></el-option> <el-option v-for="item in userAttrList" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select> </el-select>
<template v-if="form.basis == 1 && form.rule != '102' && form.attr_id"> <div style="flex: 1; display: flex; justify-content: space-between" v-if="form.basis == 1 && form.rule != '102' && form.attr_id">
<p>最小值:{{ userAttrRange.min }} <br />最大值:{{ userAttrRange.max }}<br />平均值:{{ userAttrRange.avg }}</p> <p>最小值:{{ userAttrRange.min }}<br />最大值:{{ userAttrRange.max }}<br />"0"值数量:{{ userAttrRange.zero_count }}</p>
</template> <p>平均值:{{ userAttrRange.avg }}<br />中位数:{{ userAttrRange.median }}<br />中位数(不含0):{{ userAttrRange.no_zero_median }}</p>
</div>
</div>
<div class="rfm-header-extra" v-if="form.rule === '101' && form.extend_config">
<p>未匹配数据默认赋值</p>
<el-select-v2 v-model="form.extend_config.default_score_config.score" :options="defaultOptions" style="width: 100px; margin: 0 10px" clearable />
<el-switch v-model="form.extend_config.default_score_config.switch"></el-switch>
</div> </div>
<div class="rfm-body"> <div class="rfm-body">
<template v-if="form.rule === '102'"> <template v-if="form.rule === '102'">
...@@ -254,4 +273,8 @@ const a = [ ...@@ -254,4 +273,8 @@ const a = [
margin: 10px 0; margin: 10px 0;
} }
} }
.rfm-header-extra {
display: flex;
align-items: center;
}
</style> </style>
...@@ -41,7 +41,14 @@ export function useMetaEvent() { ...@@ -41,7 +41,14 @@ export function useMetaEvent() {
// 最大值最小值 // 最大值最小值
export function useUserAttrRange() { export function useUserAttrRange() {
const userAttrRange = ref<{ min: string; max: string; avg: string }>({ min: '', max: '', avg: '' }) const userAttrRange = ref<{ min: string; max: string; avg: string; median: string; zero_count: string; no_zero_median: string }>({
min: '',
max: '',
avg: '',
median: '',
zero_count: '',
no_zero_median: ''
})
async function fetchUserAttrRange(member_meta_id: string) { async function fetchUserAttrRange(member_meta_id: string) {
await getMemberAttrRange({ member_meta_id }).then((res: any) => { await getMemberAttrRange({ member_meta_id }).then((res: any) => {
userAttrRange.value = res.data.detail userAttrRange.value = res.data.detail
......
<script setup> <script setup>
import { ElMessage } from 'element-plus'
import ChartCard from '@/components/ChartCard.vue' import ChartCard from '@/components/ChartCard.vue'
import { DataLine } from '@element-plus/icons-vue' import { DataLine } from '@element-plus/icons-vue'
import { useUser } from '@/composables/useAllData' import { useUser } from '@/composables/useAllData'
...@@ -8,7 +9,7 @@ import * as api from '../api' ...@@ -8,7 +9,7 @@ import * as api from '../api'
const { userValue, userList } = useUser() const { userValue, userList } = useUser()
const { eventValues, eventList } = useEvent() const { eventValues, eventList } = useEvent()
const date = ref('') const date = ref([])
const info = ref() const info = ref()
async function fetchInfo() { async function fetchInfo() {
...@@ -18,6 +19,14 @@ async function fetchInfo() { ...@@ -18,6 +19,14 @@ async function fetchInfo() {
onMounted(fetchInfo) onMounted(fetchInfo)
async function handleStart() { async function handleStart() {
if (!date.value.length) {
ElMessage.error('请选择时间区间')
return
}
if (!eventValues.value.length) {
ElMessage.error('请选择事件')
return
}
fetchEventAction() fetchEventAction()
fetchEventActionTrend() fetchEventActionTrend()
fetchEventMember() fetchEventMember()
...@@ -38,9 +47,12 @@ const eventAction = ref([]) ...@@ -38,9 +47,12 @@ const eventAction = ref([])
async function fetchEventAction() { async function fetchEventAction() {
if (!userValue.value) return if (!userValue.value) return
loading1.value = true loading1.value = true
const res = await api.getEventActionList(params.value) try {
eventAction.value = res.data.items const res = await api.getEventActionList(params.value)
loading1.value = false eventAction.value = res.data.items
} finally {
loading1.value = false
}
} }
const eventActionOption = computed(() => { const eventActionOption = computed(() => {
if (!eventAction.value.length) return if (!eventAction.value.length) return
...@@ -68,9 +80,12 @@ const eventActionTrend = ref([]) ...@@ -68,9 +80,12 @@ const eventActionTrend = ref([])
async function fetchEventActionTrend() { async function fetchEventActionTrend() {
if (!userValue.value) return if (!userValue.value) return
loading2.value = true loading2.value = true
const res = await api.getEventActionTrendList(params.value) try {
eventActionTrend.value = res.data.items const res = await api.getEventActionTrendList(params.value)
loading2.value = false eventActionTrend.value = res.data.items
} finally {
loading2.value = false
}
} }
const eventActionTrendOption = computed(() => { const eventActionTrendOption = computed(() => {
if (!eventActionTrend.value.length) return if (!eventActionTrend.value.length) return
...@@ -85,7 +100,7 @@ const eventActionTrendOption = computed(() => { ...@@ -85,7 +100,7 @@ const eventActionTrendOption = computed(() => {
}) })
const [first = {}] = eventActionTrend.value || [] const [first = {}] = eventActionTrend.value || []
return { return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true }, grid: { left: '5%', top: '10%', right: '5%', bottom: '15%', containLabel: true },
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { legend: {
bottom: '10', bottom: '10',
...@@ -107,9 +122,12 @@ const eventMember = ref([]) ...@@ -107,9 +122,12 @@ const eventMember = ref([])
async function fetchEventMember() { async function fetchEventMember() {
if (!userValue.value) return if (!userValue.value) return
loading3.value = true loading3.value = true
const res = await api.getEventMemberList(params.value) try {
eventMember.value = res.data.items const res = await api.getEventMemberList(params.value)
loading3.value = false eventMember.value = res.data.items
} finally {
loading3.value = false
}
} }
const eventMemberOption = computed(() => { const eventMemberOption = computed(() => {
if (!eventMember.value.length) return if (!eventMember.value.length) return
...@@ -142,9 +160,12 @@ const eventTime = ref([]) ...@@ -142,9 +160,12 @@ const eventTime = ref([])
async function fetchEventTime() { async function fetchEventTime() {
if (!userValue.value) return if (!userValue.value) return
loading4.value = true loading4.value = true
const res = await api.getEventTimeList(params.value) try {
eventTime.value = res.data.items const res = await api.getEventTimeList(params.value)
loading4.value = false eventTime.value = res.data.items
} finally {
loading4.value = false
}
} }
const eventTimeOption = computed(() => { const eventTimeOption = computed(() => {
if (!eventTime.value.length) return if (!eventTime.value.length) return
...@@ -158,7 +179,7 @@ const eventTimeOption = computed(() => { ...@@ -158,7 +179,7 @@ const eventTimeOption = computed(() => {
}) })
const [first = {}] = eventTime.value || [] const [first = {}] = eventTime.value || []
return { return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true }, grid: { left: '5%', top: '10%', right: '5%', bottom: '15%', containLabel: true },
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { legend: {
bottom: '10', bottom: '10',
......
...@@ -34,9 +34,12 @@ const labelHot = ref([]) ...@@ -34,9 +34,12 @@ const labelHot = ref([])
async function fetchLabelHot() { async function fetchLabelHot() {
if (!userValue.value) return if (!userValue.value) return
loading1.value = true loading1.value = true
const res = await api.getHotTags({ sso_id: userValue.value }) try {
labelHot.value = res.data.items const res = await api.getHotTags({ sso_id: userValue.value })
loading1.value = false labelHot.value = res.data.items
} finally {
loading1.value = false
}
} }
const labelHotOption = computed(() => { const labelHotOption = computed(() => {
if (!labelHot.value.length) return if (!labelHot.value.length) return
...@@ -79,9 +82,12 @@ const labelTop = ref([]) ...@@ -79,9 +82,12 @@ const labelTop = ref([])
async function fetchLabelTop() { async function fetchLabelTop() {
if (!userValue.value) return if (!userValue.value) return
loading2.value = true loading2.value = true
const res = await api.getTagTop({ sso_id: userValue.value }) try {
labelTop.value = res.data.items const res = await api.getTagTop({ sso_id: userValue.value })
loading2.value = false labelTop.value = res.data.items
} finally {
loading2.value = false
}
} }
const labelTopOption = computed(() => { const labelTopOption = computed(() => {
if (!labelTop.value.length) return if (!labelTop.value.length) return
...@@ -115,9 +121,12 @@ const labelMemberTop = ref([]) ...@@ -115,9 +121,12 @@ const labelMemberTop = ref([])
async function fetchLabelMemberTop() { async function fetchLabelMemberTop() {
if (!userValue.value) return if (!userValue.value) return
loading3.value = true loading3.value = true
const res = await api.getMemberTagTop({ sso_id: userValue.value }) try {
labelMemberTop.value = res.data.items const res = await api.getMemberTagTop({ sso_id: userValue.value })
loading3.value = false labelMemberTop.value = res.data.items
} finally {
loading3.value = false
}
} }
const labelMemberTopOption = computed(() => { const labelMemberTopOption = computed(() => {
if (!labelMemberTop.value.length) return if (!labelMemberTop.value.length) return
...@@ -149,9 +158,12 @@ const groupHot = ref([]) ...@@ -149,9 +158,12 @@ const groupHot = ref([])
async function fetchGroupHot() { async function fetchGroupHot() {
if (!userValue.value) return if (!userValue.value) return
loading4.value = true loading4.value = true
const res = await api.getHotGroups({ sso_id: userValue.value }) try {
groupHot.value = res.data.items const res = await api.getHotGroups({ sso_id: userValue.value })
loading4.value = false groupHot.value = res.data.items
} finally {
loading4.value = false
}
} }
const groupHotOption = computed(() => { const groupHotOption = computed(() => {
if (!groupHot.value.length) return if (!groupHot.value.length) return
...@@ -231,9 +243,12 @@ const groupMemberTop = ref([]) ...@@ -231,9 +243,12 @@ const groupMemberTop = ref([])
async function fetchGroupMemberTop() { async function fetchGroupMemberTop() {
if (!userValue.value) return if (!userValue.value) return
loading6.value = true loading6.value = true
const res = await api.getMemberGroupTop({ sso_id: userValue.value }) try {
groupMemberTop.value = res.data.items const res = await api.getMemberGroupTop({ sso_id: userValue.value })
loading6.value = false groupMemberTop.value = res.data.items
} finally {
loading6.value = false
}
} }
const groupMemberTopOption = computed(() => { const groupMemberTopOption = computed(() => {
if (!groupMemberTop.value.length) return if (!groupMemberTop.value.length) return
......
...@@ -38,9 +38,12 @@ async function fetchEventAction() { ...@@ -38,9 +38,12 @@ async function fetchEventAction() {
const [startDate, endDate] = eventActionDate.value const [startDate, endDate] = eventActionDate.value
if (!userValue.value || !eventValue.value || !startDate || !endDate) return if (!userValue.value || !eventValue.value || !startDate || !endDate) return
loading1.value = true loading1.value = true
const res = await api.getEventAction({ sso_id: userValue.value, start_date: startDate, end_date: endDate, event_id: eventValue.value }) try {
eventAction.value = res.data const res = await api.getEventAction({ sso_id: userValue.value, start_date: startDate, end_date: endDate, event_id: eventValue.value })
loading1.value = false eventAction.value = res.data
} finally {
loading1.value = false
}
} }
const eventActionOption = computed(() => { const eventActionOption = computed(() => {
if (!eventAction.value) return if (!eventAction.value) return
...@@ -55,7 +58,7 @@ const eventActionOption = computed(() => { ...@@ -55,7 +58,7 @@ const eventActionOption = computed(() => {
type: 'category', type: 'category',
boundaryGap: ['20%', '20%'], boundaryGap: ['20%', '20%'],
data: eventAction.value.event_action_items.map(item => { data: eventAction.value.event_action_items.map(item => {
return item.group_name + '月' return item.group_name
}) })
}, },
yAxis: { type: 'value' }, yAxis: { type: 'value' },
...@@ -87,9 +90,12 @@ async function fetchMarketing() { ...@@ -87,9 +90,12 @@ async function fetchMarketing() {
const [startDate, endDate] = marketingDate.value const [startDate, endDate] = marketingDate.value
if (!userValue.value || !startDate || !endDate) return if (!userValue.value || !startDate || !endDate) return
loading2.value = true loading2.value = true
const res = await api.getEventMarketing({ sso_id: userValue.value, start_date: startDate, end_date: endDate }) try {
marketing.value = res.data.items const res = await api.getEventMarketing({ sso_id: userValue.value, start_date: startDate, end_date: endDate })
loading2.value = false marketing.value = res.data.items
} finally {
loading2.value = false
}
} }
const marketingOption = computed(() => { const marketingOption = computed(() => {
return marketing.value.map((item, index, items) => { return marketing.value.map((item, index, items) => {
...@@ -116,9 +122,12 @@ const userEndDate = computed(() => { ...@@ -116,9 +122,12 @@ const userEndDate = computed(() => {
async function fetchUser() { async function fetchUser() {
if (!userValue.value || !userStartDate.value || !userEndDate.value) return if (!userValue.value || !userStartDate.value || !userEndDate.value) return
loading3.value = true loading3.value = true
const res = await api.getMemberList({ sso_id: userValue.value, start_date: userStartDate.value, end_date: userEndDate.value }) try {
user.value = res.data.items const res = await api.getMemberList({ sso_id: userValue.value, start_date: userStartDate.value, end_date: userEndDate.value })
loading3.value = false user.value = res.data.items
} finally {
loading3.value = false
}
} }
function format(date) { function format(date) {
...@@ -183,14 +192,14 @@ function format(date) { ...@@ -183,14 +192,14 @@ function format(date) {
<el-table-column label="当日" align="center"> <el-table-column label="当日" align="center">
<template #default="{ row }"> <template #default="{ row }">
<p>{{ row.day0 }}</p> <p>{{ row.day0 }}</p>
<p>{{ row.day0_rate }}%</p> <p class="is-red">{{ row.day0_rate }}%</p>
</template> </template>
</el-table-column> </el-table-column>
<template v-for="index in 7" :key="index"> <template v-for="index in 7" :key="index">
<el-table-column :label="index === 1 ? '次日' : '第' + index + '日'" align="center"> <el-table-column :label="index === 1 ? '次日' : '第' + index + '日'" align="center">
<template #default="{ row }"> <template #default="{ row }">
<p>{{ row['day' + index] }}</p> <p>{{ row['day' + index] }}</p>
<p>{{ row['day' + index + '_rate'] }}%</p> <p class="is-red">{{ row['day' + index + '_rate'] }}%</p>
</template> </template>
</el-table-column> </el-table-column>
</template> </template>
...@@ -313,4 +322,7 @@ function format(date) { ...@@ -313,4 +322,7 @@ function format(date) {
} }
} }
} }
.is-red {
color: var(--main-color);
}
</style> </style>
...@@ -12,11 +12,14 @@ const attrs = ref([]) ...@@ -12,11 +12,14 @@ const attrs = ref([])
async function fetchAttrs() { async function fetchAttrs() {
if (!props.ssoId || !metaAttrValue.value) return if (!props.ssoId || !metaAttrValue.value) return
loading.value = true loading.value = true
const res = await getMemberAttrs({ sso_id: props.ssoId, member_meta_id: metaAttrValue.value }) try {
attrs.value = res.data.items.map(item => { const res = await getMemberAttrs({ sso_id: props.ssoId, member_meta_id: metaAttrValue.value })
return { name: item.group_name, value: item.total } attrs.value = res.data.items.map(item => {
}) return { name: item.group_name, value: item.total }
loading.value = false })
} finally {
loading.value = false
}
} }
watchEffect(() => { watchEffect(() => {
fetchAttrs() fetchAttrs()
......
...@@ -39,10 +39,13 @@ const userTotal = ref(0) ...@@ -39,10 +39,13 @@ const userTotal = ref(0)
async function fetchGender() { async function fetchGender() {
if (!userValue.value) return if (!userValue.value) return
loading1.value = true loading1.value = true
const res = await api.getMemberGender({ sso_id: userValue.value }) try {
gender.value = res.data.items const res = await api.getMemberGender({ sso_id: userValue.value })
userTotal.value = res.data.total gender.value = res.data.items
loading1.value = false userTotal.value = res.data.total
} finally {
loading1.value = false
}
} }
const genderOption = computed(() => { const genderOption = computed(() => {
if (!gender.value.length) return if (!gender.value.length) return
...@@ -96,11 +99,14 @@ const connection = ref([]) ...@@ -96,11 +99,14 @@ const connection = ref([])
async function fetchConnections() { async function fetchConnections() {
if (!userValue.value) return if (!userValue.value) return
loading2.value = true loading2.value = true
const res = await api.getMemberConnections({ sso_id: userValue.value }) try {
connection.value = res.data.items.map(item => { const res = await api.getMemberConnections({ sso_id: userValue.value })
return { ...item, group_name: getNameByValue(item.group_name, connectionTypeList) } connection.value = res.data.items.map(item => {
}) return { ...item, group_name: getNameByValue(item.group_name, connectionTypeList) }
loading2.value = false })
} finally {
loading2.value = false
}
} }
const connectionOption = computed(() => { const connectionOption = computed(() => {
if (!connection.value.length) return if (!connection.value.length) return
...@@ -134,11 +140,14 @@ const status = ref([]) ...@@ -134,11 +140,14 @@ const status = ref([])
async function fetchStatus() { async function fetchStatus() {
if (!userValue.value) return if (!userValue.value) return
loading3.value = true loading3.value = true
const res = await api.getMemberStatus({ sso_id: userValue.value }) try {
status.value = res.data.items.map(item => { const res = await api.getMemberStatus({ sso_id: userValue.value })
return { name: getNameByValue(item.group_name, statusList), value: item.total } status.value = res.data.items.map(item => {
}) return { name: getNameByValue(item.group_name, statusList), value: item.total }
loading3.value = false })
} finally {
loading3.value = false
}
} }
const statusOption = computed(() => { const statusOption = computed(() => {
if (!status.value.length) return if (!status.value.length) return
......
...@@ -112,7 +112,7 @@ const handleStudentFollow = function () { ...@@ -112,7 +112,7 @@ const handleStudentFollow = function () {
<el-icon size="16" color="#000" @click.stop="edit"><Avatar /></el-icon> <el-icon size="16" color="#000" @click.stop="edit"><Avatar /></el-icon>
<span>自动生成事件数据</span> <span>自动生成事件数据</span>
</li> </li>
<li @click.top="viewDataProgress"> <li @click.stop="viewDataProgress">
<el-icon size="16" color="#000" @click.stop="edit"><PieChart /></el-icon> <el-icon size="16" color="#000" @click.stop="edit"><PieChart /></el-icon>
<span>数据生成进度</span> <span>数据生成进度</span>
</li> </li>
......
...@@ -11,6 +11,8 @@ const store = useMapStore() ...@@ -11,6 +11,8 @@ const store = useMapStore()
const userStore = useUserStore() const userStore = useUserStore()
const route = useRoute()
const FormDialog = defineAsyncComponent(() => import('../components/FormDialog.vue')) const FormDialog = defineAsyncComponent(() => import('../components/FormDialog.vue'))
const UserDataDialog = defineAsyncComponent(() => import('../components/UserDataDialog.vue')) const UserDataDialog = defineAsyncComponent(() => import('../components/UserDataDialog.vue'))
const EventDataDialog = defineAsyncComponent(() => import('../components/EventDataDialog.vue')) const EventDataDialog = defineAsyncComponent(() => import('../components/EventDataDialog.vue'))
...@@ -106,15 +108,19 @@ const viewDataProgress = function () { ...@@ -106,15 +108,19 @@ const viewDataProgress = function () {
let studentFollowVisible = $ref(false) let studentFollowVisible = $ref(false)
let studentFollowData = $ref<StudentFollow>() let studentFollowData = $ref<StudentFollow>()
const handleStudentFollow = function (experimentId: string, id: string, type: string) { const handleStudentFollow = function (experimentId: string, id: string, type: string) {
studentFollowVisible = true if (type === '15') {
getStudentFollow({ experiment_id: experimentId, experiment_connection_id: id }).then((res: any) => { window.open(`https://mall-h5-web.ezijing.com?id=${id}&experiment_id=${route.query.experiment_id}`)
if (res.code === 0) { } else {
const data = res.data studentFollowVisible = true
data.connect_id = id getStudentFollow({ experiment_id: experimentId, experiment_connection_id: id }).then((res: any) => {
data.type = type if (res.code === 0) {
studentFollowData = data const data = res.data
} data.connect_id = id
}) data.type = type
studentFollowData = data
}
})
}
} }
</script> </script>
...@@ -179,7 +185,7 @@ const handleStudentFollow = function (experimentId: string, id: string, type: st ...@@ -179,7 +185,7 @@ const handleStudentFollow = function (experimentId: string, id: string, type: st
margin-right: 10px; margin-right: 10px;
} }
} }
.connect-item{ .connect-item {
min-height: 166px; min-height: 166px;
} }
</style> </style>
...@@ -11,6 +11,6 @@ export function getMembersList() { ...@@ -11,6 +11,6 @@ export function getMembersList() {
} }
// 事件 // 事件
export function getEventList(params: { member_id: string }) { export function getEventList(params: { member_id: string; page?: number; 'per-page'?: number }) {
return httpRequest.get('/api/lab/v1/experiment/index/events', { params }) return httpRequest.get('/api/lab/v1/experiment/index/events', { params })
} }
...@@ -18,30 +18,37 @@ getExperimentData().then(res => { ...@@ -18,30 +18,37 @@ getExperimentData().then(res => {
}) })
// 最近活跃客户 // 最近活跃客户
let userList = $ref<{ name: string; id: string; isActive: boolean; gender: string }[]>() let userList = $ref<{ name: string; id: string; isActive: boolean; gender: string }[]>([])
getMembersList().then(res => { getMembersList().then(res => {
userList = res.data.map((element: any, index: number) => { userList = res.data.map((element: any, index: number) => {
element.isActive = index === 0 element.isActive = index === 0
return element return element
}) })
if (userList) getEvent(userList[0]?.id)
}) })
const activeUser = computed(() => {
return userList.find(item => item.isActive)
})
// 最近活跃客户的事件 // 最近活跃客户的事件
let eventData = $ref<{ list: any }>() let eventData = $ref<{ list: Array<any>; total: number }>({ list: [], total: 0 })
const getEvent = function (id: string) { const eventCurrentPage = ref(1)
const getEvent = function (id?: string) {
id = id || activeUser.value?.id
if (id) { if (id) {
getEventList({ member_id: id }).then(res => { getEventList({ member_id: id, page: eventCurrentPage.value, 'per-page': 10 }).then(res => {
eventData = res.data eventData = res.data
}) })
} }
} }
watchEffect(() => {
getEvent()
})
// 切换客户事件 // 切换客户事件
const handleUser = (item: any) => { const handleUser = (item: any) => {
userList?.map(item => (item.isActive = false && item)) userList?.map(item => (item.isActive = false && item))
item.isActive = true item.isActive = true
getEvent(item.id) eventCurrentPage.value = 1
} }
// 获取上下午 // 获取上下午
...@@ -97,27 +104,33 @@ function handleViewEvent(item: any) { ...@@ -97,27 +104,33 @@ function handleViewEvent(item: any) {
<AppCard class="card" title="最近活跃用户跟踪"> <AppCard class="card" title="最近活跃用户跟踪">
<div class="content-user"> <div class="content-user">
<div :class="item.isActive ? 'content-user_item active' : 'content-user_item'" v-for="item in userList" :key="item.id" @click="handleUser(item)"> <div :class="item.isActive ? 'content-user_item active' : 'content-user_item'" v-for="item in userList" :key="item.id" @click="handleUser(item)">
<img :src="item.gender === '1' ? 'https://webapp-pub.ezijing.com/pages/assa/dml_boy.png' : 'https://webapp-pub.ezijing.com/pages/assa/dml_girl.png'" /> <img
:src="item.gender === '1' ? 'https://webapp-pub.ezijing.com/pages/assa/dml_boy.png' : 'https://webapp-pub.ezijing.com/pages/assa/dml_girl.png'" />
<div class="name">{{ item.name }}</div> <div class="name">{{ item.name }}</div>
</div> </div>
</div> </div>
<el-empty v-if="!eventData?.list || !eventData?.list.length" description="暂无数据" :image-size="80" /> <template v-if="eventData.total">
<div class="event-box" v-for="item in eventData?.list" :key="item.id" v-else> <div class="event-box" v-for="item in eventData.list" :key="item.id">
<div class="date">{{ item.updated_time?.slice(0, item.updated_time.indexOf(' ')) }}</div> <div class="date">{{ item.updated_time?.slice(0, item.updated_time.indexOf(' ')) }}</div>
<div class="event-content"> <div class="event-content">
<div class="time"> <div class="time">
{{ item.updated_time?.slice(item.updated_time.indexOf(' '), item.updated_time.length - 3) }} {{ item.updated_time?.slice(item.updated_time.indexOf(' '), item.updated_time.length - 3) }}
{{ getDate(item.updated_time) }} {{ getDate(item.updated_time) }}
</div> </div>
<!-- <Icon :name="item.connection_type" w="30" h="30"></Icon> --> <!-- <Icon :name="item.connection_type" w="30" h="30"></Icon> -->
<div class="event"> <div class="event">
<Icon class="icon" :name="item.connection_type" :multiColor="true" w="24" h="24"></Icon> <Icon class="icon" :name="item.connection_type" :multiColor="true" w="24" h="24"></Icon>
<span>"{{ item.connection_name }}"</span> <span>"{{ item.connection_name }}"</span>
<span style="cursor: pointer" @click="handleViewEvent(item)">"{{ item.event_name }}"</span> <span style="cursor: pointer" @click="handleViewEvent(item)">"{{ item.event_name }}"</span>
</div>
</div> </div>
</div> </div>
</div> <div style="display: flex; align-items: center; justify-content: center; margin-top: 20px">
<el-pagination layout="prev, pager, next" v-model:current-page="eventCurrentPage" :total="eventData.total" hide-on-single-page />
</div>
</template>
<el-empty description="暂无数据" :image-size="80" v-else />
</AppCard> </AppCard>
</div> </div>
</div> </div>
......
...@@ -41,6 +41,11 @@ export function deleteLabel(data: { id: string }) { ...@@ -41,6 +41,11 @@ export function deleteLabel(data: { id: string }) {
return httpRequest.post('/api/lab/v1/experiment/tag/bda-delete', data) return httpRequest.post('/api/lab/v1/experiment/tag/bda-delete', data)
} }
// 删除标签
export function deleteLabels(data: { ids: string }) {
return httpRequest.post('/api/lab/v1/experiment/tag/batch-delete', data)
}
// 获取标签数据信息 // 获取标签数据信息
export function getLabelStatistics(params: { id: string }) { export function getLabelStatistics(params: { id: string }) {
return httpRequest.get('/api/lab/v1/experiment/tag/bda-statistics', { params }) return httpRequest.get('/api/lab/v1/experiment/tag/bda-statistics', { params })
......
...@@ -115,7 +115,7 @@ function handleUpdate() { ...@@ -115,7 +115,7 @@ function handleUpdate() {
</script> </script>
<template> <template>
<el-dialog title="标签规则管理" :close-on-click-modal="false" width="980px" @update:modelValue="value => $emit('update:modelValue', value)"> <el-dialog title="标签规则管理" :close-on-click-modal="false" width="1100px" @update:modelValue="value => $emit('update:modelValue', value)">
<el-form label-suffix=":" label-width="82px"> <el-form label-suffix=":" label-width="82px">
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
......
<script setup lang="ts"> <script setup lang="ts">
import type { Label } from '../types' import type { Label } from '../types'
import { Plus } from '@element-plus/icons-vue' import { Plus, Delete } from '@element-plus/icons-vue'
import AppList from '@/components/base/AppList.vue' import AppList from '@/components/base/AppList.vue'
import LabelType from '../components/LabelType.vue' import LabelType from '../components/LabelType.vue'
import { ElMessageBox, ElMessage } from 'element-plus' import { ElMessageBox, ElMessage } from 'element-plus'
import { getLabelList, deleteLabel } from '../api' import { getLabelList, deleteLabel, deleteLabels } from '../api'
import { useMapStore } from '@/stores/map' import { useMapStore } from '@/stores/map'
import { getNameByValue, updateStatusRuleList, labelList } from '@/utils/dictionary' import { getNameByValue, updateStatusRuleList, labelList } from '@/utils/dictionary'
import { useLabelType } from '../composables/useLabelType' import { useLabelType } from '../composables/useLabelType'
...@@ -58,6 +58,7 @@ const listOptions = computed(() => { ...@@ -58,6 +58,7 @@ const listOptions = computed(() => {
{ type: 'input', prop: 'updated_operator', placeholder: '更新人', slots: 'filter-user' } { type: 'input', prop: 'updated_operator', placeholder: '更新人', slots: 'filter-user' }
], ],
columns: [ columns: [
{ type: 'selection' },
{ label: '序号', type: 'index', width: 60 }, { label: '序号', type: 'index', width: 60 },
{ label: '标签ID', prop: 'id' }, { label: '标签ID', prop: 'id' },
{ label: '标签名称', prop: 'name' }, { label: '标签名称', prop: 'name' },
...@@ -143,16 +144,35 @@ function handleSelect(id: string) { ...@@ -143,16 +144,35 @@ function handleSelect(id: string) {
appList?.refetch() appList?.refetch()
}) })
} }
let multipleSelection = $ref<Label[]>([])
function handleSelectionChange(selection: Label[]) {
multipleSelection = selection
}
const handleRemoves = async function () {
const ids = multipleSelection.map(item => item.id)
await ElMessageBox.confirm('确定要删除选中的标签数据吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
await deleteLabels({ ids: JSON.stringify(ids) })
appList?.refetch(true)
ElMessage({ message: '删除成功', type: 'success' })
}
</script> </script>
<!-- import { useUserStore } from '@/stores/user'
const userStore = useUserStore() -->
<template> <template>
<AppCard> <AppCard>
<div class="label-wrap"> <div class="label-wrap">
<div class="label-left"><LabelType :active-id="listParams.type_id" @select="handleSelect"></LabelType></div> <div class="label-left"><LabelType :active-id="listParams.type_id" @select="handleSelect"></LabelType></div>
<AppList v-bind="listOptions" ref="appList" class="label-right"> <AppList v-bind="listOptions" ref="appList" class="label-right" @selection-change="handleSelectionChange">
<template #header-buttons> <template #header-buttons>
<el-button type="primary" :icon="Plus" @click="handleAdd" v-if="!userStore.status.tag_status">新建</el-button> <el-button type="primary" :icon="Plus" @click="handleAdd" v-if="!userStore.status.tag_status">新建</el-button>
<el-button type="primary" plain :icon="Delete" :disabled="!multipleSelection.length" @click="handleRemoves" v-permission="'experiment_tag_delete'"
>删除</el-button
>
</template> </template>
<template #filter-user> <template #filter-user>
<SelectUser v-model="listParams.updated_operator" placeholder="更新人" @change="handleRefresh"></SelectUser> <SelectUser v-model="listParams.updated_operator" placeholder="更新人" @change="handleRefresh"></SelectUser>
...@@ -161,23 +181,14 @@ const userStore = useUserStore() --> ...@@ -161,23 +181,14 @@ const userStore = useUserStore() -->
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button type="primary" plain @click="handleRule(row)">规则</el-button> <el-button type="primary" plain @click="handleRule(row)">规则</el-button>
<el-button type="primary" plain @click="handleView(row)">查看</el-button> <el-button type="primary" plain @click="handleView(row)">查看</el-button>
<el-button type="primary" plain @click="handleUpdate(row)" v-permission="'experiment_tag_update'" <el-button type="primary" plain @click="handleUpdate(row)" v-permission="'experiment_tag_update'">编辑</el-button>
>编辑</el-button <el-button type="primary" plain @click="handleRemove(row)" v-permission="'experiment_tag_delete'">删除</el-button>
>
<el-button type="primary" plain @click="handleRemove(row)" v-permission="'experiment_tag_delete'"
>删除</el-button
>
</template> </template>
</AppList> </AppList>
</div> </div>
</AppCard> </AppCard>
<!-- 新建/修改标签 --> <!-- 新建/修改标签 -->
<LabelFormDialog <LabelFormDialog v-model="formVisible" :data="currentRow" @update="handleRefresh" v-if="formVisible"></LabelFormDialog>
v-model="formVisible"
:data="currentRow"
@update="handleRefresh"
v-if="formVisible"
></LabelFormDialog>
<!-- 查看标签 --> <!-- 查看标签 -->
<LabelViewDialog v-model="viewVisible" :data="currentRow" v-if="viewVisible && currentRow"></LabelViewDialog> <LabelViewDialog v-model="viewVisible" :data="currentRow" v-if="viewVisible && currentRow"></LabelViewDialog>
<!-- 规则 --> <!-- 规则 -->
......
import httpRequest from '@/utils/axios'
// 获取实验信息
export function getExperiment() {
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 marketStore = useMarketStore()
const { objectiveStore, connectionStore, memberStore, labelStore, groupStore, tripStore, materialStore } = marketStore
watch(
() => props.detail,
() => {
if (props.detail) marketStore.setData(props.detail)
},
{ immediate: true }
)
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, { width: 1000 })
const imgData = canvas.toDataURL('image/png')
const canvasWidth = canvas.width
const canvasHeight = canvas.height
const imgWidth = 595 // 设置 PDF 页面的宽度为 595 像素
const imgHeight = (canvasHeight / canvasWidth) * imgWidth
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'px',
format: [imgWidth, imgHeight]
})
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight)
// 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" 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" 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" 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" 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">
<h4>{{ index + 1 }}{{ label.name }}</h4>
<p>该标签关联“{{ label.data_type }}”,关联字段为:{{ label.attr_name || label.event_name }}</p>
<p>该标签的设置规则为:{{ label.desc }}</p>
</template>
</template>
</section>
<section id="step5" class="section" v-element-visibility="state => onElementVisibility(state, 5)">
<h2>五、用户精准分群设计</h2>
<h3>(一)静态群组</h3>
<p>本项目设计如下静态群组:</p>
<template v-for="(item, index) in groupStore.staticGroups" :key="item.id">
<h4>{{ index + 1 }}{{ item.name }}</h4>
<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">
<h4>{{ index + 1 }}{{ item.name }}</h4>
<p>该群组的加入规则为:{{ item.join_rule }}</p>
<p>该群组的移除规则为:{{ item.remove_rule }}</p>
<p>设计该群组的原因是:{{ item.reason }}</p>
</template>
</section>
<section id="step6" class="section" v-element-visibility="state => onElementVisibility(state, 6)">
<h2>六、自动化营销旅程设计</h2>
<h3>(一)一级流程</h3>
<p>本项目设计一级流程图如下。</p>
<div style="height: 200px; margin: 20px 0">
<Flow :nodes="tripStore.nodes" :edges="tripStore.edges" :zoom-on-scroll="false" :prevent-scrolling="false" :pan-on-drag="false" disabled></Flow>
</div>
<p>相关节点设计说明如下:</p>
<template v-for="(item, index) in tripStore.nodes" :key="item.id">
<h4>{{ index + 1 }}{{ item.data.label || item.label }}节点</h4>
<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">
<h4>{{ index + 1 }}{{ item.data.label || item.label }}节点。该节点设计的二级流程图如下:</h4>
<div style="height: 200px; margin: 20px 0" v-if="item.data.nodes?.length">
<Flow
:process="2"
:nodes="item.data.nodes"
:edges="item.data.edges"
:zoom-on-scroll="false"
:prevent-scrolling="false"
:pan-on-drag="false"
disabled></Flow>
</div>
<p>该二级流程图节点说明如下:</p>
<template v-for="(item, index) in item.data.nodes" :key="item.id">
<h4>{{ index + 1 }}{{ item.data.label || item.label }}节点</h4>
<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" v-element-visibility="state => onElementVisibility(state, 7)">
<h2>七、营销物料设计</h2>
<p>本项目设计如下营销物料。</p>
<AppList v-bind="listOptions" style="margin: 10px"></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 {
margin-top: 30px;
margin-bottom: 10px;
font-size: 20px;
}
h3 {
margin-left: -5px;
margin-top: 10px;
padding: 10px 0;
font-size: 16px;
}
h4 {
margin-top: 10px;
margin-left: 15px;
padding: 10px 0;
font-size: 14px;
}
p {
margin-left: 15px;
font-size: 14px;
line-height: 24px;
strong {
font-weight: bold;
}
}
img {
max-width: 90%;
margin: 20px 0;
}
.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 {
color: #fff;
background-color: var(--main-color);
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" :rows="4" :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" :rows="4" :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" :rows="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);
}
}
p {
line-height: 30px;
}
}
.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 class="custom-form-item">
<div class="custom-form-item-left">
<el-input type="textarea" v-model="memberStore.member.sex" show-word-limit maxlength="200" :rows="5"></el-input>
</div>
<div class="custom-form-item-right">
<AppUpload v-model="memberStore.member.sex_file"></AppUpload>
</div>
</div>
</el-form-item>
<el-form-item label="用户数据来源分析" prop="source">
<div class="custom-form-item">
<div class="custom-form-item-left">
<el-input type="textarea" v-model="memberStore.member.source" show-word-limit maxlength="200" :rows="5"></el-input>
</div>
<div class="custom-form-item-right">
<AppUpload v-model="memberStore.member.source_file"></AppUpload>
</div>
</div>
</el-form-item>
<el-form-item label="用户属性分析">
<ul>
<li v-for="(item, index) in memberStore.member.attrs" :key="index">
<div class="custom-form-item">
<div class="custom-form-item-left">
<el-select-v2 v-model="item.attr_id" :options="memberAttrs" :props="{ label: 'name', value: 'id' }" style="width: 200px"></el-select-v2>
<el-input type="textarea" v-model="item.attr_content" show-word-limit maxlength="200" :rows="5" style="margin-top: 10px"> </el-input>
</div>
<div class="custom-form-item-right">
<AppUpload v-model="item.attr_file"></AppUpload>
<el-button type="primary" :icon="Minus" @click="handleRemove(item)"></el-button>
</div>
</div>
</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;
}
.custom-form-item {
width: 100%;
display: flex;
align-items: flex-end;
gap: 20px;
:deep(.avatar-uploader) {
width: 115px;
height: 115px;
}
.uploader {
line-height: 0;
}
}
.custom-form-item-left {
flex: 1;
}
.custom-form-item-right {
width: 200px;
display: flex;
align-items: center;
justify-content: space-between;
}
</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 v-if="item.children?.length">
<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-empty desc="暂无数据" v-else></el-empty>
<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;
justify-content: space-between;
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" :rows="4"></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'
}
})
// 1字符串 2 数字
const type = computed(() => {
return props.attrType == 2 || props.attrType == 3 ? 2 : 1
})
async function fetchInfo() {
const res = await getMemberAttrAnalysis({ attr_id: props.attrId, attr_type: type.value })
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="type === 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">
<div class="market-group-box-hd">
<h4>静态用户群组</h4>
<el-button type="primary" :icon="Plus" @click="handleAdd({ type: 1, type_name: '静态群组' })" :disabled="isCheck"></el-button>
</div>
<ul v-if="groupStore.staticGroups?.length">
<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-empty desc="暂无数据" v-else></el-empty>
</div>
<div class="market-group-box">
<div class="market-group-box-hd">
<h4>动态用户群组</h4>
<el-button type="primary" :icon="Plus" @click="handleAdd({ type: 2, type_name: '动态群组' })" :disabled="isCheck"></el-button>
</div>
<ul v-if="groupStore.dynamicGroups?.length">
<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-empty desc="暂无数据" v-else></el-empty>
</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;
padding: 20px;
line-height: 24px;
.remove {
display: none;
position: absolute;
color: var(--main-color);
font-size: 30px;
top: 5px;
right: 5px;
}
&:hover {
.remove {
display: block;
}
}
}
}
.market-group-box-hd {
display: flex;
align-items: center;
justify-content: space-between;
}
</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" :rows="4"></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" :rows="4"></el-input>
</el-form-item>
<el-form-item label="设计群组原因" prop="reason">
<el-input type="textarea" v-model="form.reason" show-word-limit maxlength="100" :rows="4"></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>
<div style="height: 60vh">
<Flow id="step-flow-1" v-model:nodes="tripStore.nodes" v-model:edges="tripStore.edges" :disabled="isCheck"></Flow>
</div>
<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 class="problem-list">
<li v-for="(item, index) in objectiveStore.problems" :key="item.id">问题/挑战{{ index + 1 }}{{ item.content }}</li>
</ul>
</el-dialog>
<el-dialog title="业务部门的营销目标" v-model="objectiveDialogVisible" width="600">
<ul class="problem-list">
<li v-for="(item, index) in objectiveStore.objectives" :key="item.id">营销目标{{ index + 1 }}{{ item.content }}</li>
</ul>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.problem-list {
li {
line-height: 24px;
}
li + li {
margin-top: 20px;
}
}
</style>
<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: '',
node1_id: '',
node2: '',
node2_id: '',
type: '',
style: '',
desc: '',
update_rule: '低'
})
watchEffect(() => {
Object.assign(form, props.data)
})
const formRef = ref(null)
const rules = reactive({
node1_id: [{ required: true, message: '请选择', trigger: 'blur' }],
node2_id: [{ 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 = ['专业权威', '详细深入', '时尚年轻', '种草分享', '网红推荐', '生动有趣']
const node2List = computed(() => {
return tripStore.node2List(form.node1_id)
})
function handleNode1Change(nodeId) {
const found = tripStore.node1List.find(node => node.id === nodeId)
if (!found) return
form.node1 = found.label || found.data?.label
form.node2 = ''
form.node2_id = ''
}
function handleNode2Change(nodeId) {
const found = node2List.value.find(node => node.id === nodeId)
if (!found) return
form.node2 = found.label || found.data?.label
}
</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_id">
<el-select-v2
v-model="form.node1_id"
:options="tripStore.node1List"
:props="{ label: 'label', value: 'id' }"
@change="handleNode1Change"></el-select-v2>
</el-form-item>
<el-form-item label="二级流程节点" prop="node2_id">
<el-select-v2 v-model="form.node2_id" :options="node2List" :props="{ label: 'label', value: 'id' }" @change="handleNode2Change"></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>
export default {
inheritAttrs: false
}
</script>
<script setup>
import { StepEdge, EdgeLabelRenderer, getSmoothStepPath, useVueFlow, useEdge, MarkerType } from '@vue-flow/core'
import { Plus } 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 },
disabled: { type: Boolean, required: false }
})
const path = computed(() => getSmoothStepPath(props))
const { addNodes, addEdges, removeEdges } = useVueFlow()
const { edge } = useEdge()
function addNodeBetweenEdges() {
const { source, target, sourceNode, targetNode } = edge
const newNodeId = nanoid(4)
const newNode = {
id: newNodeId,
type: 'custom',
label: `旅程节点`,
data: { label: '旅程节点' },
position: {
x: (sourceNode.position.x + targetNode.position.x) / 2,
y: (sourceNode.position.y + targetNode.position.y) / 2
}
}
addNodes([newNode])
const newEdges = [
{
id: `${source}->${newNodeId}`,
type: 'custom',
source: source,
target: newNodeId,
animated: true,
markerEnd: MarkerType.ArrowClosed
},
{
id: `${newNodeId}->${target}`,
type: 'custom',
source: newNodeId,
target: target,
animated: true,
markerEnd: MarkerType.ArrowClosed
}
]
removeEdges([props.id])
addEdges(newEdges)
}
</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)`
}"
v-if="!disabled">
<el-button-group>
<el-button :icon="Plus" circle @click="addNodeBetweenEdges"></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'
import { useLayout } from './useLayout'
const props = defineProps({
id: {
type: String,
default() {
return nanoid()
}
},
process: { type: Number, default: 1 },
disabled: { type: Boolean, default: false }
})
const { onConnect, addEdges, fitView, nodes, edges, setNodes, findNode } = useVueFlow(props.id)
onConnect(params => {
addEdges([
{
...params,
type: 'custom',
animated: true,
markerEnd: MarkerType.ArrowClosed
}
])
})
const { layout } = useLayout(findNode)
async function layoutGraph(direction) {
if (props.disabled) return
console.log('layoutGraph', direction)
setNodes(layout(nodes.value, edges.value, direction))
nextTick(() => {
fitView()
})
}
</script>
<template>
<VueFlow
:id="id"
fit-view-on-init
:connection-radius="30"
:nodes-draggable="false"
:nodes-connectable="false"
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed, type: ConnectionLineType.Straight }"
@nodes-initialized="layoutGraph('LR')">
<template #node-start="props">
<NodeStart :process="process" :disabled="disabled" v-bind="props" />
</template>
<template #node-end="props">
<NodeEnd :process="process" :disabled="disabled" v-bind="props" />
</template>
<template #node-custom="props">
<NodeCustom :process="process" :disabled="disabled" v-bind="props" @node-remove="layoutGraph('LR')" />
</template>
<template #edge-custom="props">
<EdgeCustom :process="process" :disabled="disabled" 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, useNode, MarkerType } 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', 'disabled'])
const emit = defineEmits(['nodeRemove'])
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, removeEdges, addEdges, edges: parentEdges } = useVueFlow()
const isCompleted = computed(() => {
return !!props.data.label
})
const handleSubmit = async () => {
Object.assign(props.data, { nodes: nodes.value, edges: edges.value })
flowDialogVisible.value = false
}
const { node } = useNode()
function removeNodeBetweenEdges() {
// 获取边
const leftEdge = parentEdges.value.find(edge => edge.target === node.id)
const rightEdge = parentEdges.value.find(edge => edge.source === node.id)
// 删除节点
removeNodes([node.id])
// 删除边
removeEdges([leftEdge.id, rightEdge.id])
// 添加边
addEdges([
{
id: `${leftEdge.source}->${rightEdge.target}`,
type: 'custom',
source: leftEdge.source,
target: rightEdge.target,
animated: true,
markerEnd: MarkerType.ArrowClosed
}
])
emit('nodeRemove')
}
</script>
<template>
<div class="flow-node flow-node-custom" :class="{ 'is-completed': isCompleted }">
<el-icon class="flow-node-custom__remove" @click="removeNodeBetweenEdges" v-if="selected && !disabled"><CircleCloseFilled /></el-icon>
<Handle type="target" :position="Position.Left" />
<div class="flow-node-custom__inner">
<el-button type="primary" size="small" @click="dialogVisible = true" v-if="!disabled">编辑</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 :id="id" :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" :disabled="disabled" 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" :disabled="disabled">保存</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 { useVueFlow } from '@vue-flow/core'
import { useMapStore } from '@/stores/map'
const materialTypeList = useMapStore().getMapValuesByKey('experiment_marketing_material_type')
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['id', '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 { updateNode } = useVueFlow()
const handleSubmit = async () => {
await formRef.value?.validate()
updateNode(props.id, { label: form.label, data: form })
// 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" :rows="3"></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', 'disabled'])
</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 && !disabled">自动化旅程的结束不需要维护</p>
</div>
</template>
<style>
@import './style.css';
</style>
<script setup>
import { Position, Handle } from '@vue-flow/core'
const props = defineProps(['label', 'data', 'process', 'disabled'])
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
}
const handleClick = () => {
if (props.disabled) return
dialogVisible.value = true
}
</script>
<template>
<div class="flow-node flow-node-start" @click="handleClick">
<div class="flow-node__label">{{ label }}</div>
<Handle type="source" :position="Position.Right" />
<p class="flow-node-tips" v-if="process == 1 && !disabled">点击节点维护自动化营销旅程的触发条件</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" :rows="3"></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;
box-sizing: border-box;
}
.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 dagre from '@dagrejs/dagre'
import { Position } from '@vue-flow/core'
import { ref } from 'vue'
/**
* Composable to run the layout algorithm on the graph.
* It uses the `dagre` library to calculate the layout of the nodes and edges.
*/
export function useLayout(findNode) {
const graph = ref(new dagre.graphlib.Graph())
const previousDirection = ref('LR')
function layout(nodes, edges, direction) {
nodes = sortNodes(nodes, edges)
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
const dagreGraph = new dagre.graphlib.Graph()
graph.value = dagreGraph
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = direction === 'LR'
dagreGraph.setGraph({ rankdir: direction, ranksep: 120 })
previousDirection.value = direction
for (const node of nodes) {
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
const graphNode = findNode(node.id)
dagreGraph.setNode(node.id, { width: graphNode.dimensions.width || 180, height: graphNode.dimensions.height || 160 })
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target)
}
dagre.layout(dagreGraph)
// set nodes with updated positions
return nodes.map(node => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
}
})
}
function sortNodes(nodes, edges) {
const nodesMap = new Map()
nodes.forEach(node => {
nodesMap.set(node.id, node)
})
// Perform topological sort to determine the order of nodes
const sortedNodes = []
const visited = new Set()
const visit = nodeId => {
if (!visited.has(nodeId)) {
visited.add(nodeId)
edges.filter(edge => edge.source === nodeId).forEach(edge => visit(edge.target))
sortedNodes.push(nodesMap.get(nodeId))
}
}
nodesMap.forEach((_, nodeId) => visit(nodeId))
return sortedNodes.reverse()
}
return { graph, layout, previousDirection }
}
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 }
}
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/market/my',
component: Layout,
children: [
{ path: '', component: () => import('./views/Index.vue') },
{ path: 'update', component: () => import('./views/Update.vue') }
]
}
]
export { routes }
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 = Object.assign(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
}
function getCustomNodes(nodes: Node[] = []) {
return nodes.filter(node => node.type === 'custom')
}
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 getCustomNodes(state.nodes)
},
// 二级节点
node2List() {
return (node1Id: string) => {
const node1 = this.nodes.find(node => node.id === node1Id)
if (!node1 || !node1?.data?.nodes) return []
return getCustomNodes(node1.data.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>
import { Select } from '@element-plus/icons-vue'
import { useExperiment } from '../composables/useData'
const { experiment } = useExperiment()
import { getRecords } from '../api'
const typeArr = ['营销背景分析', '营销渠道选择', '用户分析', '用户标签体系设计', '用户精准分群', '自动化营销旅程设计', '营销物料设计', '营销策划报告']
// 列表配置
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="实验名称">{{ 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 #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>
<style lang="scss" scoped>
.info {
display: flex;
justify-content: space-between;
}
</style>
<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 @submit="handleSubmit" @next="handleNext"></Step1>
</el-tab-pane>
<el-tab-pane lazy label="第2步" :name="2">
<Step2 @submit="handleSubmit" @next="handleNext"></Step2>
</el-tab-pane>
<el-tab-pane lazy label="第3步" :name="3">
<Step3 @submit="handleSubmit" @next="handleNext"></Step3>
</el-tab-pane>
<el-tab-pane lazy label="第4步" :name="4">
<Step4 @submit="handleSubmit" @next="handleNext"></Step4>
</el-tab-pane>
<el-tab-pane lazy label="第5步" :name="5">
<Step5 @submit="handleSubmit" @next="handleNext"></Step5>
</el-tab-pane>
<el-tab-pane lazy label="第6步" :name="6"> </el-tab-pane>
<el-tab-pane lazy label="第7步" :name="7">
<Step7 @submit="handleSubmit" @next="handleNext"></Step7>
</el-tab-pane>
<el-tab-pane lazy label="第8步" :name="8"> </el-tab-pane>
</el-tabs>
<Step6 @submit="handleSubmit" @next="handleNext" v-if="activeTab == 6"></Step6>
<Step8 @submit="handleSubmit" @next="handleNext" v-if="activeTab == 8"></Step8>
</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>
import httpRequest from '@/utils/axios'
// 获取实验信息
export function getExperiment() {
return httpRequest.get('/api/lab/v1/experiment')
}
// 获取列表搜索条件
export function getSearchCriteria() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/record-condition')
}
// 获取列表
export function getRecordList(params?: { name?: string, sno_number?: any }) {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/record-list', { params })
}
// 获取平分
export function getScore(params?: { record_id: any }) {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/score', { params })
}
// 更新评分
export function updateScore(data: { experiment_id: any; record_id: any,score: string }) {
return httpRequest.post(`/api/lab/v1/experiment/marketing-planning/score?experiment_id=${data.experiment_id}&record_id=${data.record_id}`, data)
}
<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 marketStore = useMarketStore()
const { objectiveStore, connectionStore, memberStore, labelStore, groupStore, tripStore, materialStore } = marketStore
watch(
() => props.detail,
() => {
if (props.detail) marketStore.setData(props.detail)
},
{ immediate: true }
)
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>
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/market/review',
component: Layout,
children: [
{ path: '', component: () => import('./views/Index.vue') },
{ path: 'score', component: () => import('./views/Score.vue') },
{ path: 'view', component: () => import('./views/View.vue') }
]
}
]
export { routes }
export interface ExperimentInfo {
name: string
course_name: string
teacher_name: string
length: string
teacher: { name: string }[]
}
<script setup lang="ts">
import type { ExperimentInfo } from '../types'
import { getSearchCriteria, getRecordList } from '../api'
import Report from '@/modules/market/my/components/Report.vue'
const route = useRoute()
let experimentInfo = $ref<ExperimentInfo>()
getSearchCriteria().then((res: { data: { experiment: ExperimentInfo } }) => {
if (res?.data) {
const data = res.data.experiment
data.teacher_name = data.teacher.reduce((a: any, b: any) => a.push(b.name) && a, []).join(',')
experimentInfo = data
}
})
// 列表配置
const listOptions = computed(() => {
return {
remote: {
httpRequest: getRecordList,
params: { name: '', sno_number: '' }
},
filters: [
{ type: 'input', prop: 'name', placeholder: '请输入学生姓名' },
{ type: 'input', prop: 'sno_number', placeholder: '请输入学生学号' }
],
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '班级', prop: 'class_name' },
{ label: '学号', prop: 'sno_number' },
{ label: '学生姓名', prop: 'name' },
{
label: '营销背景分析',
computed(row: any) {
return isComplete(row.row.step_1.is_complete)
}
},
{
label: '营销渠道选择',
computed(row: any) {
return isComplete(row.row.step_2.is_complete)
}
},
{
label: '用户分析',
computed(row: any) {
return isComplete(row.row.step_3.is_complete)
}
},
{
label: '标签体系设计',
computed(row: any) {
return isComplete(row.row.step_4.is_complete)
}
},
{
label: '用户精准分群',
computed(row: any) {
return isComplete(row.row.step_5.is_complete)
}
},
{
label: '自动化营销旅程设计',
computed(row: any) {
return isComplete(row.row.step_6.is_complete)
}
},
{
label: '营销物料设计',
computed(row: any) {
return isComplete(row.row.step_7.is_complete)
}
},
{ label: '得分', prop: 'score' },
{ label: '操作', slots: 'table-x', width: 200 }
]
}
})
function isComplete(is: Boolean) {
let n = ''
is ? (n = '<div style="color: #009b3b; font-size:20px;">✓</div>') : (n = '<div style="font-size:20px;">-</div>')
return n
}
</script>
<template>
<AppCard>
<el-form label-suffix=":" inline class="info">
<el-form-item label="实验名称">{{ experimentInfo?.name }}</el-form-item>
<el-form-item label="课程名称">{{ experimentInfo?.course_name }}</el-form-item>
<el-form-item label="指导教师">{{ experimentInfo?.teacher_name }}</el-form-item>
<el-form-item label="实验学时">{{ experimentInfo?.length }}</el-form-item>
</el-form>
<el-divider />
<!-- <Report
:experimentName="11"
:teacherName="11"
:studentName="11"
ref="reportRef" /> -->
<h2 class="h2-title">营销策划</h2>
<AppList v-bind="listOptions">
<template #table-x="{ row }">
<!-- <el-button text type="primary">查看营销策划报告</el-button> -->
<router-link
target="_blank"
:to="{
path: '/market/review/view',
query: {
id: row.id,
snoNumber: row.sno_number,
experiment_id: route.query.experiment_id
}
}"
><el-button text type="primary">查看营销策划报告</el-button></router-link
>
<router-link
target="_blank"
:to="{
path: '/market/review/score',
query: {
id: row.id,
name: row.name,
snoNumber: row.sno_number,
className: row.class_name,
experiment_id: route.query.experiment_id
}
}"
><el-button text type="primary">评分</el-button></router-link
>
</template>
</AppList>
</AppCard>
</template>
<style lang="scss" scoped>
.info {
display: flex;
justify-content: space-between;
}
</style>
<script setup lang="ts">
import AppEditor from '@/components/base/AppEditor.vue'
import { getScore, updateScore } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
let data = $ref<any>([])
getScore({ record_id: route.query.id }).then(res => {
console.log(res.data, 'res.data')
data = res.data.map((item: any) => {
item.score = item.score === '' ? 0 : parseInt(item.score)
return item
})
})
// 列表配置
const listOptions = computed(() => {
return {
columns: [
{ type: 'index', width: 60 },
{ label: '', prop: 'name' },
{
label: '',
prop: 'is_complete',
computed(row: any) {
return isComplete(row.row.is_complete)
}
},
{ label: '', prop: 'percent' },
{
label: '',
prop: 'score',
slots: 'table-input'
},
{ slots: 'table-x', width: 200 }
],
['show-header']: false,
border: true,
data: data
}
})
function isComplete(is: Boolean) {
let n = ''
is ? (n = '<div style="color: #009b3b; font-size:20px;">✓</div>') : (n = '<div style=" font-size:20px;">-</div>')
return n
}
let editorText = ref('')
const dialogVisible = ref(false)
let commentId = ref()
const handleComment = function (row: any) {
commentId = row.id
editorText.value = row.comment
dialogVisible.value = true
}
let handleBtn = function () {
const item = data.find((i: any) => i.id === commentId)
item.comment = editorText.value
editorText.value = ''
dialogVisible.value = false
}
const handleSubmit = function () {
updateScore({
experiment_id: route.query.experiment_id,
record_id: route.query.id,
score: JSON.stringify(data)
}).then(res => {
ElMessage.success('保存成功')
})
}
</script>
<template>
<AppCard>
<el-form label-suffix=":" inline class="info">
<el-form-item label="姓名">{{ route.query.name }}</el-form-item>
<el-form-item label="学号">{{ route.query.snoNumber }}</el-form-item>
<el-form-item label="班级">{{ route.query.className }}</el-form-item>
</el-form>
<el-divider />
<h2 class="h2-title">营销策划评分</h2>
<AppList v-bind="listOptions" ref="appList">
<template #table-input="{ row }">
<el-input-number style="width: 150px; text-align: center" v-model="row.score" :min="0" :max="100" />
</template>
<template #table-x="{ row }">
<el-button text type="primary" @click="handleComment(row)">评语</el-button>
</template>
</AppList>
<div style="display: flex; justify-content: center">
<el-button type="primary" @click="handleSubmit">&nbsp;&nbsp;&nbsp;</el-button>
</div>
</AppCard>
<el-dialog v-model="dialogVisible" width="700px" title="评语">
<AppEditor v-model="editorText"></AppEditor>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBtn"> 确认 </el-button>
</div>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.info {
display: flex;
justify-content: space-between;
}
</style>
<script setup lang="ts">
import type { ExperimentInfo } from '../types'
import { getSearchCriteria, getRecordList } from '../api'
import Report from '@/modules/market/my/components/Report.vue'
const route = useRoute()
let experimentInfo = $ref<ExperimentInfo>()
getSearchCriteria().then((res: { data: { experiment: ExperimentInfo } }) => {
if (res?.data) {
const data = res.data.experiment
data.teacher_name = data.teacher.reduce((a: any, b: any) => a.push(b.name) && a, []).join(',')
experimentInfo = data
}
})
let detail = ref<any>({})
getRecordList({ sno_number: route.query.snoNumber }).then(res => {
if (res.data) {
detail.value = res.data.list[0]
}
})
const data = $computed(() => {
return detail.value?.details ? JSON.parse(detail.value.details) : {}
})
</script>
<template>
<AppCard>
<el-form label-suffix=":" inline class="info">
<el-form-item label="实验名称">{{ experimentInfo?.name }}</el-form-item>
<el-form-item label="课程名称">{{ experimentInfo?.course_name }}</el-form-item>
<el-form-item label="指导教师">{{ experimentInfo?.teacher_name }}</el-form-item>
<el-form-item label="实验学时">{{ experimentInfo?.length }}</el-form-item>
</el-form>
<el-divider />
<Report
:detail="data"
:experimentName="experimentInfo?.name"
:teacherName="experimentInfo?.teacher_name"
:studentName="detail?.name"
ref="reportRef"
/>
</AppCard>
</template>
<style lang="scss" scoped>
.info {
display: flex;
justify-content: space-between;
}
</style>
...@@ -26,12 +26,13 @@ const addField = function () { ...@@ -26,12 +26,13 @@ const addField = function () {
} }
// 删除字段 // 删除字段
const deleteField = function (scope: { $index: number; row: { id: string } }) { const deleteField = function (scope: { $index: number; row: EventAttributesProp }) {
if (scope.row.isDefault) return
if (tableData[scope.$index]?.id === '') { if (tableData[scope.$index]?.id === '') {
tableData.splice(scope.$index, 1) tableData.splice(scope.$index, 1)
} else { } else {
// 判断当前属性是否可以删除 // 判断当前属性是否可以删除
getIsDeleteAttribute({ id: scope.row.id }).then(res => { getIsDeleteAttribute({ id: scope.row.id }).then((res) => {
res.data?.can_delete ? tableData.splice(scope.$index, 1) : ElMessage({ message: '不能删除', type: 'warning' }) res.data?.can_delete ? tableData.splice(scope.$index, 1) : ElMessage({ message: '不能删除', type: 'warning' })
}) })
} }
...@@ -41,12 +42,19 @@ const deleteField = function (scope: { $index: number; row: { id: string } }) { ...@@ -41,12 +42,19 @@ const deleteField = function (scope: { $index: number; row: { id: string } }) {
let eventDetail = $ref<EventDetailProp>() let eventDetail = $ref<EventDetailProp>()
onBeforeMount(() => { onBeforeMount(() => {
getMetaEventDetail({ id: props.data?.id || '' }).then(res => { getMetaEventDetail({ id: props.data?.id || '' }).then((res) => {
eventDetail = res.data eventDetail = res.data
tableData = res.data.attributes tableData = defaultFields.concat(res.data.attributes)
}) })
}) })
// 默认字段
const defaultFields = [
{ id: '1', name: '事件ID', english_name: 'action_id', type: '1', format: '25', isDefault: true },
{ id: '2', name: '事件发生时间', english_name: 'action_time', type: '5', format: 'yyyy-mm-dd hh:mm:ss', isDefault: true },
{ id: '3', name: '用户ID', english_name: 'user_id', type: '1', format: '25', isDefault: true }
]
// 字段 // 字段
let tableData = $ref<EventAttributesProp[]>([]) let tableData = $ref<EventAttributesProp[]>([])
...@@ -58,7 +66,7 @@ function handleSubmit() { ...@@ -58,7 +66,7 @@ function handleSubmit() {
function handleUpdate() { function handleUpdate() {
const params = { const params = {
id: eventDetail?.id || '', id: eventDetail?.id || '',
attributes: JSON.stringify(tableData) attributes: JSON.stringify(tableData.filter((item) => !item.isDefault))
} }
updateAttributes(params).then(() => { updateAttributes(params).then(() => {
ElMessage({ message: '保存成功', type: 'success' }) ElMessage({ message: '保存成功', type: 'success' })
...@@ -111,12 +119,7 @@ const popoverText = function (row: any) { ...@@ -111,12 +119,7 @@ const popoverText = function (row: any) {
</script> </script>
<template> <template>
<el-dialog <el-dialog title="事件属性" :close-on-click-modal="false" width="800px" @update:modelValue="(value) => $emit('update:modelValue', value)">
title="事件属性"
:close-on-click-modal="false"
width="800px"
@update:modelValue="value => $emit('update:modelValue', value)"
>
<div style="display: flex; justify-content: space-around"> <div style="display: flex; justify-content: space-around">
<el-form label-width="120px"> <el-form label-width="120px">
<el-form-item label="事件英文名称:">{{ eventDetail?.english_name }}</el-form-item> <el-form-item label="事件英文名称:">{{ eventDetail?.english_name }}</el-form-item>
...@@ -136,71 +139,51 @@ const popoverText = function (row: any) { ...@@ -136,71 +139,51 @@ const popoverText = function (row: any) {
</template> </template>
<el-table :data="tableData" style="width: 100%"> <el-table :data="tableData" style="width: 100%">
<el-table-column label="属性英文名"> <el-table-column label="属性英文名">
<template #default="scope"> <template #default="{ row }">
<el-input v-model="scope.row.english_name" placeholder="请输入"></el-input> <el-input v-model="row.english_name" placeholder="请输入" :disabled="row.isDefault"></el-input>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="属性名称"> <el-table-column label="属性名称">
<template #default="scope"> <template #default="{ row }">
<el-input v-model="scope.row.name" placeholder="请输入"></el-input> <el-input v-model="row.name" placeholder="请输入" :disabled="row.isDefault"></el-input>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="字段类型"> <el-table-column label="字段类型">
<template #default="scope"> <template #default="{ row }">
<el-select <el-select @change="changeFormatType(row)" :disabled="row.id !== ''" v-model="row.type" placeholder="请选择">
@change="changeFormatType(scope.row)" <el-option :label="item.label" :value="item.value" :key="item.id" v-for="item in experimentAttributeOptions"></el-option>
:disabled="scope.row.id !== ''"
v-model="scope.row.type"
placeholder="请选择"
>
<el-option
:label="item.label"
:value="item.value"
:key="item.id"
v-for="item in experimentAttributeOptions"
></el-option>
</el-select> </el-select>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="字段格式"> <el-table-column label="字段格式">
<template #default="scope"> <template #default="{ row }">
<el-select <el-select :disabled="row.id !== ''" v-if="row.type === '4' || row.type === '5'" v-model="row.format" placeholder="请选择">
:disabled="scope.row.id !== ''" <el-option v-if="row.type !== '5'" label="yyyy-mm-dd" value="yyyy-mm-dd"></el-option>
v-if="scope.row.type === '4' || scope.row.type === '5'" <el-option v-if="row.type !== '4'" label="yyyy-mm-dd hh:mm:ss" value="yyyy-mm-dd hh:mm:ss"></el-option>
v-model="scope.row.format"
placeholder="请选择"
>
<el-option v-if="scope.row.type !== '5'" label="yyyy-mm-dd" value="yyyy-mm-dd"></el-option>
<el-option
v-if="scope.row.type !== '4'"
label="yyyy-mm-dd hh:mm:ss"
value="yyyy-mm-dd hh:mm:ss"
></el-option>
</el-select> </el-select>
<el-input <el-input
v-else v-else
:disabled="scope.row.id !== ''" :disabled="row.id !== ''"
type="number" type="number"
v-model="scope.row.format" v-model="row.format"
:placeholder="scope.row.type === '1' ? '请输入字符串长度' : '请输入长度'" :placeholder="row.type === '1' ? '请输入字符串长度' : '请输入长度'"></el-input>
></el-input>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column width="30"> <el-table-column width="30">
<template #default="scope"> <template #default="{ row }">
<el-popover placement="top-start" :width="300" trigger="hover" :title="scope?.row.type_name"> <el-popover placement="top-start" :width="300" trigger="hover" :title="row.type_name">
<template #reference> <template #reference>
<div style="display: flex; justify-content: center; cursor: pointer"> <div style="display: flex; justify-content: center; cursor: pointer">
<el-icon size="20"><QuestionFilled /></el-icon> <el-icon size="20"><QuestionFilled /></el-icon>
</div> </div>
</template> </template>
<div v-html="popoverText(scope.row)"></div> <div v-html="popoverText(row)"></div>
</el-popover> </el-popover>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column width="30" v-if="props.data?.can_edit !== '0'"> <el-table-column width="30" v-if="props.data?.can_edit !== '0'">
<template #default="scope"> <template #default="scope">
<div @click="deleteField(scope)" style="display: flex; justify-content: center; cursor: pointer"> <div @click="deleteField(scope)" style="display: flex; justify-content: center; cursor: pointer" v-if="!scope.row.isDefault">
<el-icon size="20"><Close /></el-icon> <el-icon size="20"><Close /></el-icon>
</div> </div>
</template> </template>
...@@ -210,13 +193,12 @@ const popoverText = function (row: any) { ...@@ -210,13 +193,12 @@ const popoverText = function (row: any) {
<template #footer> <template #footer>
<el-row justify="center"> <el-row justify="center">
<el-button round auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button> <el-button round auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" round auto-insert-space @click="handleSubmit" v-if="props.data?.can_edit !== '0'" <el-button type="primary" round auto-insert-space @click="handleSubmit" v-if="props.data?.can_edit !== '0'">保存</el-button>
>保存</el-button
>
</el-row> </el-row>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<style lang="scss"> <style lang="scss">
.card-header { .card-header {
display: flex; display: flex;
......
...@@ -11,7 +11,10 @@ export interface EventProp { ...@@ -11,7 +11,10 @@ export interface EventProp {
can_edit: string can_edit: string
} }
export interface ConnectionOptionProp { type_name: string; id: string } export interface ConnectionOptionProp {
type_name: string
id: string
}
export interface EventDetailProp { export interface EventDetailProp {
id: string id: string
...@@ -27,4 +30,5 @@ export interface EventAttributesProp { ...@@ -27,4 +30,5 @@ export interface EventAttributesProp {
english_name: string english_name: string
type: string type: string
format: string format: string
isDefault?: boolean
} }
...@@ -19,3 +19,8 @@ export function bindTripConnections(data: { itinerary_id: string; connections_id ...@@ -19,3 +19,8 @@ export function bindTripConnections(data: { itinerary_id: string; connections_id
export function getTripTemplateDemo(params: { itinerary_id?: string }) { export function getTripTemplateDemo(params: { itinerary_id?: string }) {
return httpRequest.get('/api/lab/v1/experiment/itinerary/get-itinerary-demo', { params }) return httpRequest.get('/api/lab/v1/experiment/itinerary/get-itinerary-demo', { params })
} }
// 一键生成旅程数据
export function studentGenerateData(data: { itinerary_id: string; type: number }) {
return httpRequest.post('/api/lab/v1/experiment/itinerary/student-generate-data', data)
}
<script setup lang="ts">
import type { Trip } from '../types'
import { studentGenerateData } from '../api'
import { ElMessage } from 'element-plus'
const props = defineProps<{
data: Trip
}>()
const emit = defineEmits<{
(e: 'update'): void
}>()
const dialogVisible = ref(false)
const generateVisible = ref(false)
const generateLoading = ref(false)
const params = reactive({
type: 1,
itinerary_id: props.data.itinerary_id
})
async function handleGenerate() {
dialogVisible.value = false
generateLoading.value = true
generateVisible.value = true
try {
await studentGenerateData(params)
emit('update')
ElMessage.success('生成数据成功')
} catch (error) {
console.error(error)
} finally {
generateLoading.value = false
generateVisible.value = false
}
}
</script>
<template>
<el-button type="primary" :disabled="generateLoading" @click="dialogVisible = true">一键生成旅程数据</el-button>
<el-dialog v-model="dialogVisible" width="400" title="生成旅程数据">
<p class="dialog-text" v-if="data.has_itinerary_generate_data">
系统检测到您正在重复生成用户旅程事件数据!<br />
该操作将会覆盖您之前生成的数据且无法恢复,请确认!
</p>
<p class="dialog-text" v-else>
即将生成用户旅程相关事件数据,<br />
请确认您的用户旅程的每一个节点已经完成配置!
</p>
<el-radio-group v-model="params.type" class="dialog-radio-group">
<el-radio label="今日" :value="1"></el-radio>
<el-radio label="前7日" :value="2"></el-radio>
<el-radio label="前30日" :value="3"></el-radio>
</el-radio-group>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleGenerate">生成数据</el-button>
</template>
</el-dialog>
<el-dialog v-model="generateVisible" title="提示" width="400">
<p class="dialog-text">正在生成数据中,请耐心等待</p>
<template #footer>
<el-button @click="generateVisible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<style lang="scss">
.dialog-text {
line-height: 24px;
text-align: center;
}
.dialog-radio-group {
margin: 10px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
...@@ -22,4 +22,5 @@ export interface Trip { ...@@ -22,4 +22,5 @@ export interface Trip {
experiment_itinerary_name: string experiment_itinerary_name: string
experiment_itinerary_type: string experiment_itinerary_type: string
experiment_itinerary_type_name: string experiment_itinerary_type_name: string
has_itinerary_generate_data: boolean
} }
...@@ -4,8 +4,13 @@ import TripFlow from '@/components/flow/Index.vue' ...@@ -4,8 +4,13 @@ import TripFlow from '@/components/flow/Index.vue'
import TripFlowSidebar from '@/components/flow/Sidebar.vue' import TripFlowSidebar from '@/components/flow/Sidebar.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getStudentTrip, getTripTemplateDemo, updateTrip } from '../api' import { getStudentTrip, getTripTemplateDemo, updateTrip } from '../api'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const BindConnection = defineAsyncComponent(() => import('../components/BindConnection.vue')) const BindConnection = defineAsyncComponent(() => import('../components/BindConnection.vue'))
const ViewGenerateEventData = defineAsyncComponent(() => import('@/components/ViewGenerateEventData.vue'))
const GenerateData = defineAsyncComponent(() => import('../components/GenerateData.vue'))
const detail = ref<Trip>() const detail = ref<Trip>()
const elements = ref([]) const elements = ref([])
...@@ -61,16 +66,28 @@ function handleConnectionUpdate(value: string[]) { ...@@ -61,16 +66,28 @@ function handleConnectionUpdate(value: string[]) {
if (!detail.value) return if (!detail.value) return
detail.value.connect_ids = value detail.value.connect_ids = value
} }
const generateEventVisible = ref(false)
const canGenerate = computed(() => {
return !(userStore.status.status || userStore.status.group_status || userStore.status.material_status || userStore.status.tag_status)
})
</script> </script>
<template> <template>
<AppCard :title="detail?.experiment_itinerary_type_name" v-if="detail"> <AppCard :title="detail?.experiment_itinerary_type_name" v-if="detail">
<el-card shadow="never" style="margin-bottom: 20px" v-if="detail"> <el-card shadow="never" style="margin-bottom: 20px" v-if="detail">
<el-descriptions class="info"> <div style="display: flex; align-items: center">
<el-descriptions-item label="课程名称:">{{ detail.course_name }}</el-descriptions-item> <el-descriptions class="info" style="flex: 1">
<el-descriptions-item label="实验名称:">{{ detail.experiment_name }}</el-descriptions-item> <el-descriptions-item label="课程名称:">{{ detail.course_name }}</el-descriptions-item>
<el-descriptions-item label="旅程类型:">{{ detail.experiment_itinerary_type_name }}</el-descriptions-item> <el-descriptions-item label="实验名称:">{{ detail.experiment_name }}</el-descriptions-item>
</el-descriptions> <el-descriptions-item label="旅程类型:">{{ detail.experiment_itinerary_type_name }}</el-descriptions-item>
</el-descriptions>
<el-space v-if="canGenerate">
<GenerateData :data="detail" @update="fetchInfo"></GenerateData>
<el-button type="primary" @click="generateEventVisible = true">查看用户旅程数据</el-button>
</el-space>
</div>
</el-card> </el-card>
<TripFlow <TripFlow
v-model="elements" v-model="elements"
...@@ -84,9 +101,7 @@ function handleConnectionUpdate(value: string[]) { ...@@ -84,9 +101,7 @@ function handleConnectionUpdate(value: string[]) {
</template> </template>
<template #header> <template #header>
<el-row align="middle"> <el-row align="middle">
<el-button type="primary" size="large" @click="handleConfig" style="margin-right: 20px" v-if="false" <el-button type="primary" size="large" @click="handleConfig" style="margin-right: 20px" v-if="false">配置连接</el-button>
>配置连接</el-button
>
<el-alert center style="flex: 1"> <el-alert center style="flex: 1">
<p style="text-align: center"> <p style="text-align: center">
<template v-if="isFree"> <template v-if="isFree">
...@@ -107,9 +122,7 @@ function handleConnectionUpdate(value: string[]) { ...@@ -107,9 +122,7 @@ function handleConnectionUpdate(value: string[]) {
</AppCard> </AppCard>
<!-- 配置连接 --> <!-- 配置连接 -->
<BindConnection <BindConnection v-model="configVisible" :data="detail" @update="handleConnectionUpdate" v-if="configVisible && detail"></BindConnection>
v-model="configVisible" <!-- 用户事件数据 -->
:data="detail" <ViewGenerateEventData v-model="generateEventVisible" v-if="generateEventVisible"></ViewGenerateEventData>
@update="handleConnectionUpdate"
v-if="configVisible && detail"></BindConnection>
</template> </template>
...@@ -52,7 +52,13 @@ export function ruleQuery(data: { experiment_id: string; filters: string }) { ...@@ -52,7 +52,13 @@ export function ruleQuery(data: { experiment_id: string; filters: string }) {
} }
// 保存实验旅程数据规则 // 保存实验旅程数据规则
export function saveRule(data: { itinerary_id: string; data_generate_mode: string; data_generate_frequency: string; auto_generate_rule: string; graph: string }) { export function saveRule(data: {
itinerary_id: string
data_generate_mode: string
data_generate_frequency: string
auto_generate_rule: string
graph: string
}) {
return httpRequest.post('/api/resource/v1/backend/experiment-itinerary/save-data-config', data) return httpRequest.post('/api/resource/v1/backend/experiment-itinerary/save-data-config', data)
} }
...@@ -60,3 +66,13 @@ export function saveRule(data: { itinerary_id: string; data_generate_mode: strin ...@@ -60,3 +66,13 @@ export function saveRule(data: { itinerary_id: string; data_generate_mode: strin
export function updateTriggerStatus(data: { itinerary_id: string; trigger_status: 0 | 1 }) { export function updateTriggerStatus(data: { itinerary_id: string; trigger_status: 0 | 1 }) {
return httpRequest.post('/api/lab/v1/experiment/itinerary/trigger', data) return httpRequest.post('/api/lab/v1/experiment/itinerary/trigger', data)
} }
// 获取配置旅程数据规则
export function getGenerateDataRule(params: { itinerary_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/itinerary/generate-data-config', { params })
}
// 保存配置旅程数据规则
export function updateGenerateDataRule(data: { itinerary_id: string; config: any }) {
return httpRequest.post('/api/lab/v1/experiment/itinerary/generate-data-config', data)
}
<script setup lang="ts">
import type { TripTemplate } from '../types'
import { getGenerateDataRule, updateGenerateDataRule } from '../api'
import { ElMessage } from 'element-plus'
const props = defineProps<{ data: TripTemplate }>()
const emit = defineEmits<{
(e: 'update:modelValue', visible: boolean): void
}>()
const dataset: any = reactive({ events: [], list: [] })
async function fetchGenerateDataRule() {
const { data } = await getGenerateDataRule({ itinerary_id: props.data.id })
data.list = data.list.map((item: any) => {
return { ...item, ratio: parseFloat(item.ratio) }
})
Object.assign(dataset, data)
}
onMounted(() => {
fetchGenerateDataRule()
})
const listOptions = computed(() => {
return {
data: dataset.list,
columns: [
{ label: '连接名称', prop: 'connection_name', width: 100 },
{ label: '动作类型', prop: 'action', width: 100 },
{ label: '旅程动作', prop: 'itinerary', width: 100 },
{ label: '旅程事件', prop: 'event' },
{ label: '对应用户事件', prop: 'user_event_id', slots: 'table-event' },
{ label: '生成数据比例规则', prop: 'ratio', slots: 'table-ratio', width: 140 }
]
}
})
function isDisabled(connectionType: string) {
if (connectionType === '15') return true
return false
}
function getConnection(connectionType: string) {
return dataset.events.filter((item: any) => item.connection_type === connectionType)
}
async function handleSubmit() {
const config = dataset.list.reduce((result: any, item: any) => {
const { ratio, user_event_id } = item
result[item.id] = { ratio, user_event_id }
return result
}, {})
await updateGenerateDataRule({ itinerary_id: props.data.id, config: JSON.stringify(config) })
ElMessage.success('保存成功')
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog title="旅程数据规则" width="900" @update:modelValue="value => $emit('update:modelValue', value)">
<AppList v-bind="listOptions" ref="appList">
<template #table-event="{ row }">
<el-select v-model="row.user_event_id" :disabled="isDisabled(row.connection_type)" clearable>
<el-option v-for="item in getConnection(row.connection_type)" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</template>
<template #table-ratio="{ row }">
<div class="table-ratio">
<el-input-number v-model="row.ratio" :controls="false" :max="100" :min="0" :disabled="isDisabled(row.connection_type)"></el-input-number>%
</div>
</template>
</AppList>
<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>
<style lang="scss">
.table-ratio {
display: flex;
align-items: center;
gap: 5px;
.el-input__inner {
text-align: center;
}
}
</style>
...@@ -9,6 +9,7 @@ import { getNameByValue, tripTemplateTypeList } from '@/utils/dictionary' ...@@ -9,6 +9,7 @@ import { getNameByValue, tripTemplateTypeList } from '@/utils/dictionary'
const FormDialog = defineAsyncComponent(() => import('../components/FormDialog.vue')) const FormDialog = defineAsyncComponent(() => import('../components/FormDialog.vue'))
const ViewDialog = defineAsyncComponent(() => import('../components/ViewDialog.vue')) const ViewDialog = defineAsyncComponent(() => import('../components/ViewDialog.vue'))
const BindConnection = defineAsyncComponent(() => import('../components/BindConnection.vue')) const BindConnection = defineAsyncComponent(() => import('../components/BindConnection.vue'))
const GenerateRule = defineAsyncComponent(() => import('../components/GenerateRule.vue'))
const statusList = useMapStore().getMapValuesByKey('system_status') const statusList = useMapStore().getMapValuesByKey('system_status')
...@@ -53,7 +54,7 @@ const listOptions = computed(() => { ...@@ -53,7 +54,7 @@ const listOptions = computed(() => {
}, },
{ label: '更新人', prop: 'updated_operator.real_name' }, { label: '更新人', prop: 'updated_operator.real_name' },
{ label: '更新时间', prop: 'updated_time' }, { label: '更新时间', prop: 'updated_time' },
{ label: '操作', slots: 'table-x', width: 300 } { label: '操作', slots: 'table-x', width: 360 }
] ]
} }
}) })
...@@ -95,6 +96,12 @@ function handleConfig(row: TripTemplate) { ...@@ -95,6 +96,12 @@ function handleConfig(row: TripTemplate) {
configVisible = true configVisible = true
} }
} }
let ruleVisible = $ref(false)
function handleRule(row: TripTemplate) {
currentRow = row
ruleVisible = true
}
</script> </script>
<template> <template>
...@@ -110,6 +117,7 @@ function handleConfig(row: TripTemplate) { ...@@ -110,6 +117,7 @@ function handleConfig(row: TripTemplate) {
<el-button type="primary" plain @click="handleConfig(row)">配置</el-button> <el-button type="primary" plain @click="handleConfig(row)">配置</el-button>
<el-button type="primary" plain @click="handleView(row)">查看</el-button> <el-button type="primary" plain @click="handleView(row)">查看</el-button>
<el-button type="primary" plain @click="handleUpdate(row)">编辑</el-button> <el-button type="primary" plain @click="handleUpdate(row)">编辑</el-button>
<el-button type="primary" plain @click="handleRule(row)">旅程数据规则</el-button>
</template> </template>
</AppList> </AppList>
</AppCard> </AppCard>
...@@ -119,4 +127,6 @@ function handleConfig(row: TripTemplate) { ...@@ -119,4 +127,6 @@ function handleConfig(row: TripTemplate) {
<ViewDialog v-model="viewVisible" :data="currentRow" v-if="viewVisible && currentRow"></ViewDialog> <ViewDialog v-model="viewVisible" :data="currentRow" v-if="viewVisible && currentRow"></ViewDialog>
<!-- 配置 --> <!-- 配置 -->
<BindConnection v-model="configVisible" :data="currentRow" v-if="configVisible && currentRow"></BindConnection> <BindConnection v-model="configVisible" :data="currentRow" v-if="configVisible && currentRow"></BindConnection>
<!-- 旅程数据规则 -->
<GenerateRule v-model="ruleVisible" :data="currentRow" v-if="ruleVisible && currentRow"></GenerateRule>
</template> </template>
...@@ -185,9 +185,11 @@ watch(currentConnection, () => { ...@@ -185,9 +185,11 @@ watch(currentConnection, () => {
</el-tabs> </el-tabs>
</AppCard> </AppCard>
<AppCard class="card" title="用户行为轨迹"> <AppCard class="card" title="用户行为轨迹">
<el-radio-group v-model="currentConnection"> <div style="text-align: center">
<el-radio-button :value="item.id" v-for="item in connectionList" :key="item.id">{{ item.type_name }}</el-radio-button> <el-radio-group v-model="currentConnection">
</el-radio-group> <el-radio-button :value="item.id" v-for="item in connectionList" :key="item.id">{{ item.type_name }}</el-radio-button>
</el-radio-group>
</div>
<template v-if="event.list.length"> <template v-if="event.list.length">
<div class="event-box" v-for="(item, index) in event.list" :key="index"> <div class="event-box" v-for="(item, index) in event.list" :key="index">
<div class="date">{{ item.updated_time?.slice(0, item.updated_time.indexOf(' ')) }}</div> <div class="date">{{ item.updated_time?.slice(0, item.updated_time.indexOf(' ')) }}</div>
......
...@@ -12,6 +12,7 @@ const UpdateDialog = defineAsyncComponent(() => import('../components/UpdateDial ...@@ -12,6 +12,7 @@ const UpdateDialog = defineAsyncComponent(() => import('../components/UpdateDial
const UploadEventsDialog = defineAsyncComponent(() => import('../components/UploadEventsDialog.vue')) const UploadEventsDialog = defineAsyncComponent(() => import('../components/UploadEventsDialog.vue'))
const UploadUserDialog = defineAsyncComponent(() => import('../components/UploadUserDialog.vue')) const UploadUserDialog = defineAsyncComponent(() => import('../components/UploadUserDialog.vue'))
const ViewProgressDialog = defineAsyncComponent(() => import('../components/ViewProgressDialog.vue')) const ViewProgressDialog = defineAsyncComponent(() => import('../components/ViewProgressDialog.vue'))
const ViewGenerateEventData = defineAsyncComponent(() => import('@/components/ViewGenerateEventData.vue'))
const router = useRouter() const router = useRouter()
...@@ -117,7 +118,7 @@ const handleRemove = function (row: { id: string; have_event: boolean }) { ...@@ -117,7 +118,7 @@ const handleRemove = function (row: { id: string; have_event: boolean }) {
} }
} }
const handleRemoves = function (isAll?: boolean) { const handleRemoves = function (isAll?: boolean) {
const ids = multipleSelection.map(item => item.id).join(',') const ids = multipleSelection.map((item) => item.id).join(',')
if (isAll) { if (isAll) {
ElMessageBox.confirm('确定要删除全部用户数据吗?', '提示', { ElMessageBox.confirm('确定要删除全部用户数据吗?', '提示', {
confirmButtonText: '确认', confirmButtonText: '确认',
...@@ -202,6 +203,8 @@ const downloadMember = function (isAll?: boolean) { ...@@ -202,6 +203,8 @@ const downloadMember = function (isAll?: boolean) {
window.open(`/api/lab/v1/experiment/member/download?experiment_id=${route.query.experiment_id}&ids=${ids}`) window.open(`/api/lab/v1/experiment/member/download?experiment_id=${route.query.experiment_id}&ids=${ids}`)
} }
} }
const generateEventVisible = ref(false)
</script> </script>
<template> <template>
...@@ -210,12 +213,7 @@ const downloadMember = function (isAll?: boolean) { ...@@ -210,12 +213,7 @@ const downloadMember = function (isAll?: boolean) {
<template #header-buttons> <template #header-buttons>
<el-row justify="space-between"> <el-row justify="space-between">
<el-space> <el-space>
<el-button <el-button v-if="!userStore.status.status" type="primary" :icon="Plus" @click="handleAdd" v-permission="'v1-experiment-member-create'"
v-if="!userStore.status.status"
type="primary"
:icon="Plus"
@click="handleAdd"
v-permission="'v1-experiment-member-create'"
>新建</el-button >新建</el-button
> >
<el-dropdown v-permission="'v1-experiment-member-download'"> <el-dropdown v-permission="'v1-experiment-member-download'">
...@@ -223,9 +221,7 @@ const downloadMember = function (isAll?: boolean) { ...@@ -223,9 +221,7 @@ const downloadMember = function (isAll?: boolean) {
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="downloadMember(true)">全部用户数据</el-dropdown-item> <el-dropdown-item @click="downloadMember(true)">全部用户数据</el-dropdown-item>
<el-dropdown-item :disabled="!multipleSelection.length" @click="downloadMember(false)" <el-dropdown-item :disabled="!multipleSelection.length" @click="downloadMember(false)">勾选用户数据</el-dropdown-item>
>勾选用户数据</el-dropdown-item
>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
...@@ -239,9 +235,7 @@ const downloadMember = function (isAll?: boolean) { ...@@ -239,9 +235,7 @@ const downloadMember = function (isAll?: boolean) {
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
<el-button type="primary" @click="progressVisible = true" v-permission="'v1-experiment-member-tasks'" <el-button type="primary" @click="progressVisible = true" v-permission="'v1-experiment-member-tasks'">数据导入进度</el-button>
>数据导入进度</el-button
>
<!-- <el-button type="danger" plain :icon="Delete" :disabled="!multipleSelection.length" @click="handleRemoves()" v-permission="'v1-experiment-member-delete'">删除</el-button> --> <!-- <el-button type="danger" plain :icon="Delete" :disabled="!multipleSelection.length" @click="handleRemoves()" v-permission="'v1-experiment-member-delete'">删除</el-button> -->
<!-- v-permission="'v1-experiment-member-delete'" --> <!-- v-permission="'v1-experiment-member-delete'" -->
<el-dropdown> <el-dropdown>
...@@ -249,25 +243,22 @@ const downloadMember = function (isAll?: boolean) { ...@@ -249,25 +243,22 @@ const downloadMember = function (isAll?: boolean) {
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="handleRemoves(true)">删除全部用户</el-dropdown-item> <el-dropdown-item @click="handleRemoves(true)">删除全部用户</el-dropdown-item>
<el-dropdown-item :disabled="!multipleSelection.length" @click="handleRemoves(false)" <el-dropdown-item :disabled="!multipleSelection.length" @click="handleRemoves(false)">删除勾选用户</el-dropdown-item>
>删除勾选用户</el-dropdown-item
>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
</el-space> </el-space>
<router-link to="/analyze/user"><el-button type="primary">用户分析</el-button></router-link> <el-space>
<router-link to="/analyze/user"><el-button type="primary">用户分析</el-button></router-link>
<el-button type="primary" @click="generateEventVisible = true">用户事件数据</el-button>
</el-space>
</el-row> </el-row>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button type="primary" plain @click="handleImage(row)">画像</el-button> <el-button type="primary" plain @click="handleImage(row)">画像</el-button>
<el-button type="primary" plain @click="handleView(row)">查看</el-button> <el-button type="primary" plain @click="handleView(row)">查看</el-button>
<el-button type="primary" plain @click="handleEdit(row)" v-permission="'v1-experiment-member-update'" <el-button type="primary" plain @click="handleEdit(row)" v-permission="'v1-experiment-member-update'">编辑</el-button>
>编辑</el-button <el-button type="primary" plain @click="handleRemove(row)" v-permission="'v1-experiment-member-delete'">删除</el-button>
>
<el-button type="primary" plain @click="handleRemove(row)" v-permission="'v1-experiment-member-delete'"
>删除</el-button
>
<el-button type="primary" plain @click="goPage(row)">事件</el-button> <el-button type="primary" plain @click="goPage(row)">事件</el-button>
</template> </template>
</AppList> </AppList>
...@@ -280,4 +271,6 @@ const downloadMember = function (isAll?: boolean) { ...@@ -280,4 +271,6 @@ const downloadMember = function (isAll?: boolean) {
<UpdateDialog v-if="updateVisible" :data="currentRow" v-model="updateVisible" @update="handleRefresh"></UpdateDialog> <UpdateDialog v-if="updateVisible" :data="currentRow" v-model="updateVisible" @update="handleRefresh"></UpdateDialog>
<!-- 查看上传 --> <!-- 查看上传 -->
<ViewProgressDialog v-if="progressVisible" v-model="progressVisible"></ViewProgressDialog> <ViewProgressDialog v-if="progressVisible" v-model="progressVisible"></ViewProgressDialog>
<!-- 用户事件数据 -->
<ViewGenerateEventData v-model="generateEventVisible" v-if="generateEventVisible"></ViewGenerateEventData>
</template> </template>
...@@ -21,6 +21,7 @@ import IconQrcode from '@/components/icon/IconQrcode.vue' ...@@ -21,6 +21,7 @@ import IconQrcode from '@/components/icon/IconQrcode.vue'
import IconMiniProgram from '@/components/icon/IconMiniProgram.vue' import IconMiniProgram from '@/components/icon/IconMiniProgram.vue'
import IconCard from '@/components/icon/IconCard.vue' import IconCard from '@/components/icon/IconCard.vue'
import IconEvent from '@/components/icon/IconEvent.vue' import IconEvent from '@/components/icon/IconEvent.vue'
import IconMarket from '@/components/icon/IconMarket.vue'
interface State { interface State {
studentMenus: IMenuItem[] studentMenus: IMenuItem[]
...@@ -41,6 +42,11 @@ const studentMenus: IMenuItem[] = [ ...@@ -41,6 +42,11 @@ const studentMenus: IMenuItem[] = [
} }
] ]
}, },
{
name: '营销策划',
path: '/market/my',
icon: markRaw(IconMarket)
},
{ {
name: '用户画像', name: '用户画像',
path: '/user', path: '/user',
...@@ -164,6 +170,11 @@ const adminMenus: IMenuItem[] = [ ...@@ -164,6 +170,11 @@ const adminMenus: IMenuItem[] = [
} }
] ]
}, },
{
name: '营销策划',
path: '/market/review',
icon: markRaw(IconMarket)
},
{ {
name: '用户画像', name: '用户画像',
path: '/user', path: '/user',
......
...@@ -83,10 +83,10 @@ export const numberOperatorList: OperatorType[] = [ ...@@ -83,10 +83,10 @@ export const numberOperatorList: OperatorType[] = [
export const dateOperatorList: OperatorType[] = [ export const dateOperatorList: OperatorType[] = [
{ label: '绝对时间之前', value: 'before' }, { label: '绝对时间之前', value: 'before' },
{ label: '绝对时间之后', value: 'after' }, { label: '绝对时间之后', value: 'after' },
{ label: '绝对时间区间', value: 'range' }, { label: '绝对时间区间', value: 'range' }
{ label: '相对过去天数', value: 'relative_past_day' }, // { label: '相对过去天数', value: 'relative_past_day' },
{ label: '相对当前天数', value: 'relative_current_day' }, // { label: '相对当前天数', value: 'relative_current_day' },
{ label: '在...天内', value: 'in_day' } // { label: '在...天内', value: 'in_day' }
] ]
export const happenInfoList = [ export const happenInfoList = [
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论