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

chore: 优化营销策划

上级 c6b8711b
......@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@chuangkit/chuangkit-design": "^2.0.5",
"@dagrejs/dagre": "^1.1.3",
"@element-plus/icons-vue": "^2.3.1",
"@fortaine/fetch-event-source": "^3.0.6",
"@tinymce/tinymce-vue": "^5.0.1",
......@@ -598,6 +599,24 @@
"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": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz",
......
......@@ -16,6 +16,7 @@
},
"dependencies": {
"@chuangkit/chuangkit-design": "^2.0.5",
"@dagrejs/dagre": "^1.1.3",
"@element-plus/icons-vue": "^2.3.1",
"@fortaine/fetch-event-source": "^3.0.6",
"@tinymce/tinymce-vue": "^5.0.1",
......
......@@ -91,3 +91,8 @@ textarea:focus {
.info tr:last-child td {
padding-bottom: 0 !important;
}
.el-button a {
margin: -8px -15px;
padding: 8px 15px;
}
......@@ -156,14 +156,15 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => {
overflow: hidden;
}
.avatar-uploader {
width: 178px;
height: 178px;
width: 180px;
height: 180px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
box-sizing: border-box;
.el-image {
width: 100%;
height: 100%;
......@@ -179,4 +180,7 @@ const handlePreview: UploadProps['onPreview'] = uploadFile => {
height: 100%;
text-align: center;
}
.el-upload__tip:empty {
display: none;
}
</style>
......@@ -66,26 +66,36 @@ async function generateImage() {
async function generatePdf() {
// const blob = await toBlob(reportRef.value, { width: 1000 })
// saveAs(blob, '营销策划报告.png')
const canvas = await toCanvas(reportRef.value)
const canvas = await toCanvas(reportRef.value, { width: 1000 })
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)
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)
for (let i = 0; i < totalPages; i++) {
if (i > 0) {
pdf.addPage()
}
const position = -i * pdfHeight
pdf.addImage(imgData, 'PNG', 0, position, 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')
}
......@@ -150,7 +160,7 @@ defineExpose({ generateImage, generatePdf })
<li>指导教师:{{ teacherName }}</li>
</ul>
</div>
<section id="step1" class="section" :class="{ hide: step !== 1 }" v-element-visibility="state => onElementVisibility(state, 1)">
<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">
......@@ -161,7 +171,7 @@ defineExpose({ generateImage, generatePdf })
<p>营销目标{{ index + 1 }}{{ item.content }}</p>
</template>
</section>
<section id="step2" class="section" :class="{ hide: step !== 2 }" v-element-visibility="state => onElementVisibility(state, 2)">
<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">
......@@ -170,7 +180,7 @@ defineExpose({ generateImage, generatePdf })
<p>选择该渠道的原因为:{{ item.content }}</p>
</template>
</section>
<section id="step3" class="section" :class="{ hide: step !== 3 }" v-element-visibility="state => onElementVisibility(state, 3)">
<section id="step3" class="section" v-element-visibility="state => onElementVisibility(state, 3)">
<h2>三、用户分析</h2>
<h3>(一)用户性别分析</h3>
<p>{{ memberStore.member.sex }}</p>
......@@ -183,7 +193,7 @@ defineExpose({ generateImage, generatePdf })
<img :src="memberStore.member.source_file" />
</div>
</section>
<section id="step4" class="section" :class="{ hide: step !== 4 }" v-element-visibility="state => onElementVisibility(state, 4)">
<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>
......@@ -195,7 +205,7 @@ defineExpose({ generateImage, generatePdf })
</template>
</template>
</section>
<section id="step5" class="section" :class="{ hide: step !== 5 }" v-element-visibility="state => onElementVisibility(state, 5)">
<section id="step5" class="section" v-element-visibility="state => onElementVisibility(state, 5)">
<h2>五、用户精准分群设计</h2>
<h3>(一)静态群组</h3>
<p>本项目设计如下静态群组:</p>
......@@ -214,18 +224,18 @@ defineExpose({ generateImage, generatePdf })
<p>设计该群组的原因是:{{ item.reason }}</p>
</template>
</section>
<section id="step6" class="section" :class="{ hide: step !== 6 }" v-element-visibility="state => onElementVisibility(state, 6)">
<section id="step6" class="section" v-element-visibility="state => onElementVisibility(state, 6)">
<h2>六、自动化营销旅程设计</h2>
<h3>(一)一级流程</h3>
<p>本项目设计一级流程图如下。</p>
<Flow
id="report-flow-1"
:nodes="tripStore.nodes"
:edges="tripStore.edges"
:zoom-on-scroll="false"
:prevent-scrolling="false"
:nodes-draggable="false"
:nodes-connectable="false"
style="height: 200px"></Flow>
disabled
style="height: 200px; margin: 20px 0"></Flow>
<p>相关节点设计说明如下:</p>
<template v-for="(item, index) in tripStore.nodes" :key="item.id">
<h4>{{ index + 1 }}{{ item.data.label || item.label }}节点</h4>
......@@ -248,9 +258,9 @@ defineExpose({ generateImage, generatePdf })
:edges="item.data.edges"
:zoom-on-scroll="false"
:prevent-scrolling="false"
:nodes-draggable="false"
:nodes-connectable="false"
style="height: 200px"></Flow>
disabled
style="height: 200px; margin: 20px 0"
v-if="item.data.nodes?.length"></Flow>
<p>该二级流程图节点说明如下:</p>
<template v-for="(item, index) in item.data.nodes" :key="item.id">
<h4>{{ index + 1 }}{{ item.data.label || item.label }}节点</h4>
......@@ -267,7 +277,7 @@ defineExpose({ generateImage, generatePdf })
</template>
</template>
</section>
<section id="step7" class="section" :class="{ hide: step !== 7 }" v-element-visibility="state => onElementVisibility(state, 7)">
<section id="step7" class="section" v-element-visibility="state => onElementVisibility(state, 7)">
<h2>七、营销物料设计</h2>
<p>本项目设计如下营销物料。</p>
<AppList v-bind="listOptions" style="margin: 10px"></AppList>
......@@ -346,6 +356,7 @@ defineExpose({ generateImage, generatePdf })
}
img {
max-width: 90%;
margin: 20px 0;
}
.market-report-step {
position: fixed;
......@@ -364,7 +375,8 @@ defineExpose({ generateImage, generatePdf })
cursor: pointer;
background-color: #fff;
&.is-active {
background-color: rgb(189, 248, 180);
color: #fff;
background-color: var(--main-color);
border: none;
}
}
......
......@@ -46,13 +46,7 @@ async function handleNext() {
<ul>
<li v-for="(item, index) in objectiveStore.problems" :key="item.id">
<p>问题/挑战:</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
:disabled="isCheck"></el-input>
<el-input type="textarea" v-model="item.content" show-word-limit maxlength="200" :rows="4" :disabled="isCheck"></el-input>
<el-button
type="primary"
:icon="Plus"
......@@ -68,13 +62,7 @@ async function handleNext() {
<ul>
<li v-for="(item, index) in objectiveStore.objectives" :key="item.id">
<p>营销目标:</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
:disabled="isCheck"></el-input>
<el-input type="textarea" v-model="item.content" show-word-limit maxlength="200" :rows="4" :disabled="isCheck"></el-input>
<el-button
type="primary"
:icon="Plus"
......
......@@ -59,13 +59,7 @@ async function handleNext() {
</div>
<div v-show="item.active">
<p>*选择原因</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="100"
:autosize="{ minRows: 3, maxRows: 3 }"
:disabled="isCheck"></el-input>
<el-input type="textarea" v-model="item.content" show-word-limit maxlength="100" :rows="3" :disabled="isCheck"></el-input>
</div>
</div>
</div>
......@@ -92,6 +86,9 @@ async function handleNext() {
background-color: var(--main-color);
}
}
p {
line-height: 30px;
}
}
.connect-box {
padding: 10px;
......
......@@ -57,33 +57,38 @@ async function handleNext() {
</div>
<el-form label-width="150" label-suffix=":" :model="memberStore.member" :rules="rules" ref="formRef" :disabled="isCheck">
<el-form-item label="用户性别分析" prop="sex">
<div style="width: 100%">
<el-input type="textarea" v-model="memberStore.member.sex" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<AppUpload v-model="memberStore.member.sex_file" style="margin-top: 20px"></AppUpload>
<div 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 style="width: 100%">
<el-input type="textarea" v-model="memberStore.member.source" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<AppUpload v-model="memberStore.member.source_file" style="margin-top: 20px"></AppUpload>
<div 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 style="flex: 1">
<el-select-v2 v-model="item.attr_id" :options="memberAttrs" :props="{ label: 'name', value: 'id' }"></el-select-v2>
<el-input
type="textarea"
v-model="item.attr_content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
style="margin: 10px 0">
</el-input>
<AppUpload v-model="item.attr_file"></AppUpload>
<div 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>
<el-button type="primary" :icon="Minus" @click="handleRemove(item)"></el-button>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd"></el-button>
......@@ -114,4 +119,26 @@ li {
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>
......@@ -51,13 +51,14 @@ async function handleNext() {
<div class="market-label">
<div class="market-label-box" v-for="item in labelStore.treeLabels" :key="item.name">
<h4>{{ item.name }}</h4>
<ul>
<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>
......@@ -83,6 +84,7 @@ async function handleNext() {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
ul {
width: 100%;
flex: 1;
......
......@@ -92,7 +92,7 @@ const handleSubmit = async () => {
</template>
<el-form-item label="标签设置规则及说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="100" :rows="4"></el-input>
</el-form-item>
</el-form>
<template #footer>
......
......@@ -23,9 +23,14 @@ const detail = ref({
three_quarters: '0'
}
})
// 1字符串 2 数字
const type = computed(() => {
return props.attrType == 2 || props.attrType == 3 ? 2 : 1
})
async function fetchInfo() {
const map = { 1: 1, 2: 2, 3: 2 }
const res = await getMemberAttrAnalysis({ attr_id: props.attrId, attr_type: map[props.attrType] })
const res = await getMemberAttrAnalysis({ attr_id: props.attrId, attr_type: type.value })
detail.value = res.data
}
watch(() => props.attrId, fetchInfo)
......@@ -52,7 +57,7 @@ const options = computed(() => {
<template>
<div>
<el-form-item label="字段值分布">
<template v-if="attrType != 1">
<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>
......
......@@ -54,24 +54,30 @@ async function handleNext() {
</div>
<div class="market-group">
<div class="market-group-box">
<h4>静态用户群组</h4>
<ul>
<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-button type="primary" :icon="Plus" @click="handleAdd({ type: 1, type_name: '静态群组' })" :disabled="isCheck"></el-button>
<el-empty desc="暂无数据" v-else></el-empty>
</div>
<div class="market-group-box">
<h4>动态用户群组</h4>
<ul>
<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-button type="primary" :icon="Plus" @click="handleAdd({ type: 2, type_name: '动态群组' })" :disabled="isCheck"></el-button>
<el-empty desc="暂无数据" v-else></el-empty>
</div>
</div>
<div class="market-step-footer">
......@@ -106,6 +112,8 @@ async function handleNext() {
justify-content: center;
cursor: pointer;
border: 1px solid #ccc;
padding: 20px;
line-height: 24px;
.remove {
display: none;
position: absolute;
......@@ -122,4 +130,9 @@ async function handleNext() {
}
}
}
.market-group-box-hd {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
......@@ -47,13 +47,13 @@ const handleSubmit = async () => {
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="群组加入规则" prop="join_rule">
<el-input type="textarea" v-model="form.join_rule" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<el-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" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<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" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<el-input type="textarea" v-model="form.reason" show-word-limit maxlength="100" :rows="4"></el-input>
</el-form-item>
</el-form>
<template #footer>
......
......@@ -42,20 +42,33 @@ const objectiveDialogVisible = ref(false)
<el-button type="primary" size="large" @click="objectiveDialogVisible = true">业务部门的营销目标</el-button>
</el-space>
</el-row>
<Flow v-model:nodes="tripStore.nodes" v-model:edges="tripStore.edges" :nodes-draggable="!isCheck" :nodes-connectable="!isCheck" style="height: 60vh"></Flow>
<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>
<li v-for="item in objectiveStore.problems" :key="item.id">{{ item.content }}</li>
<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>
<li v-for="item in objectiveStore.objectives" :key="item.id">{{ item.content }}</li>
<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>
export default {
inheritAttrs: false
}
</script>
<script setup>
import { StepEdge, EdgeLabelRenderer, getSmoothStepPath, useVueFlow } from '@vue-flow/core'
import { Plus, Close } from '@element-plus/icons-vue'
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({
......@@ -14,30 +20,50 @@ const props = defineProps({
data: { type: Object, required: false },
markerEnd: { type: String, required: false },
style: { type: Object, required: false },
selected: { type: Boolean, required: false }
selected: { type: Boolean, required: false },
disabled: { type: Boolean, required: false }
})
const path = computed(() => getSmoothStepPath(props))
const { addNodes, removeEdges, nodesConnectable } = useVueFlow()
const { addNodes, addEdges, removeEdges } = useVueFlow()
function onAdd() {
const position = { x: 0, y: 0 }
const { edge } = useEdge()
function addNodeBetweenEdges() {
const { source, target, sourceNode, targetNode } = edge
const newNodeId = nanoid(4)
const newNode = {
id: nanoid(4),
id: newNodeId,
type: 'custom',
label: `旅程节点`,
data: { label: '旅程节点' },
position
position: {
x: (sourceNode.position.x + targetNode.position.x) / 2,
y: (sourceNode.position.y + targetNode.position.y) / 2
}
}
addNodes([newNode])
}
</script>
<script>
export default {
inheritAttrs: false
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>
......@@ -50,10 +76,9 @@ export default {
position: 'absolute',
transform: `translate(-50%, -50%) translate(${path[1]}px,${path[2]}px)`
}"
v-if="nodesConnectable">
v-if="!disabled">
<el-button-group>
<el-button :icon="Plus" circle @click="onAdd"></el-button>
<el-button :icon="Close" circle @click="removeEdges([id])"></el-button>
<el-button :icon="Plus" circle @click="addNodeBetweenEdges"></el-button>
</el-button-group>
</div>
</EdgeLabelRenderer>
......
......@@ -5,12 +5,20 @@ import NodeEnd from './NodeEnd.vue'
import NodeCustom from './NodeCustom.vue'
import EdgeCustom from './EdgeCustom.vue'
import { nanoid } from 'nanoid'
defineProps({
process: { type: Number, default: 1 }
import { useLayout } from './useLayout'
const props = defineProps({
id: {
type: String,
default() {
return nanoid()
}
},
process: { type: Number, default: 1 },
disabled: { type: Boolean, default: false }
})
const id = nanoid()
const { onConnect, addEdges } = useVueFlow(id)
const { onConnect, addEdges, fitView, nodes, edges, setNodes, findNode } = useVueFlow(props.id)
onConnect(params => {
addEdges([
{
......@@ -21,27 +29,35 @@ onConnect(params => {
}
])
})
const { layout } = useLayout(findNode)
async function layoutGraph(direction) {
setNodes(layout(nodes.value, edges.value, direction))
nextTick(() => {
fitView()
})
}
</script>
<template>
<VueFlow
:id="id"
fit-view-on-init
:connection-radius="30"
snap-to-grid
:snap-grid="[180, 180]"
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed, type: ConnectionLineType.Straight }">
: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" v-bind="props" />
<NodeStart :process="process" :disabled="disabled" v-bind="props" />
</template>
<template #node-end="props">
<NodeEnd :process="process" v-bind="props" />
<NodeEnd :process="process" :disabled="disabled" v-bind="props" />
</template>
<template #node-custom="props">
<NodeCustom :process="process" v-bind="props" />
<NodeCustom :process="process" :disabled="disabled" v-bind="props" />
</template>
<template #edge-custom="props">
<EdgeCustom :process="process" v-bind="props" />
<EdgeCustom :process="process" :disabled="disabled" v-bind="props" />
</template>
</VueFlow>
</template>
......
<script setup>
import { Position, Handle, useVueFlow } from '@vue-flow/core'
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'])
const props = defineProps(['label', 'data', 'process', 'selected', 'id', 'disabled'])
const dialogVisible = ref(false)
......@@ -62,7 +62,7 @@ watch(
{ immediate: true }
)
const { removeNodes, nodesDraggable, nodesConnectable } = useVueFlow()
const { removeNodes, removeEdges, addEdges, edges: parentEdges } = useVueFlow()
const isCompleted = computed(() => {
return !!props.data.label
......@@ -72,31 +72,47 @@ 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
}
])
}
</script>
<template>
<div class="flow-node flow-node-custom" :class="{ 'is-completed': isCompleted }">
<el-icon class="flow-node-custom__remove" @click="removeNodes([id])" v-if="selected && nodesDraggable"><CircleCloseFilled /></el-icon>
<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="nodesDraggable">编辑</el-button>
<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 :data="data" :process="process" v-model="dialogVisible" v-if="dialogVisible"></NodeCustomForm>
<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"
:nodes-draggable="nodesDraggable"
:nodes-connectable="nodesConnectable"
style="height: 500px"></Flow>
<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="!nodesDraggable">保存</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit" :disabled="disabled">保存</el-button>
</el-row>
</template>
</el-dialog>
......
<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(['data', 'process'])
const props = defineProps(['id', 'data', 'process'])
const title = computed(() => {
const subTitle = props.process == 2 ? '二级流程节点' : '一级流程节点'
......@@ -26,9 +27,12 @@ const rules = reactive({
label: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const { updateNode } = useVueFlow()
const handleSubmit = async () => {
await formRef.value?.validate()
Object.assign(props.data, { ...form })
updateNode(props.id, { label: form.label, data: form })
// Object.assign(props.data, { ...form })
emit('update:modelValue', false)
}
</script>
......@@ -40,7 +44,7 @@ const handleSubmit = async () => {
<el-input v-model="form.label"></el-input>
</el-form-item>
<el-form-item label="流程节点说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<el-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">
......
<script setup>
import { Position, Handle, useVueFlow } from '@vue-flow/core'
import { Position, Handle } from '@vue-flow/core'
defineProps(['label', 'process'])
const { nodesDraggable } = useVueFlow()
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 && nodesDraggable">自动化旅程的结束不需要维护</p>
<p class="flow-node-tips" v-if="process == 1 && !disabled">自动化旅程的结束不需要维护</p>
</div>
</template>
......
<script setup>
import { Position, Handle, useVueFlow } from '@vue-flow/core'
import { Position, Handle } from '@vue-flow/core'
const props = defineProps(['label', 'data', 'process'])
const { nodesDraggable } = useVueFlow()
const props = defineProps(['label', 'data', 'process', 'disabled'])
const dialogVisible = ref(false)
......@@ -38,7 +36,7 @@ const handleSubmit = async () => {
}
const handleClick = () => {
if (!nodesDraggable.value) return
if (props.disabled) return
dialogVisible.value = true
}
</script>
......@@ -47,7 +45,7 @@ const handleClick = () => {
<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 && nodesDraggable">点击节点维护自动化营销旅程的触发条件</p>
<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">
......@@ -62,7 +60,7 @@ const handleClick = () => {
</el-radio-group>
</el-form-item>
<el-form-item label="旅程触发条件说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="200" :rows="3"></el-input>
</el-form-item>
</el-form>
<template #footer>
......
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: 160 })
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 || 182, height: graphNode.dimensions.height || 162 })
}
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 }
}
......@@ -39,7 +39,7 @@ export const useMemberStore = defineStore('member', {
this.setMember(data)
},
setMember(data: MemberState) {
this.member = data
this.member = Object.assign(this.member, data)
},
addAttr(data: Omit<MemberAttr, 'id'>) {
this.member.attrs.push({ id: nanoid(4), ...data })
......
......@@ -61,28 +61,28 @@ async function handleNext(data, isCheck = false) {
<el-divider />
<el-tabs v-model="activeTab" stretch class="market-tabs">
<el-tab-pane lazy label="第1步" :name="1">
<Step1 :data="detail.step1" @submit="handleSubmit" @next="handleNext"></Step1>
<Step1 @submit="handleSubmit" @next="handleNext"></Step1>
</el-tab-pane>
<el-tab-pane lazy label="第2步" :name="2">
<Step2 :data="detail.step2" @submit="handleSubmit" @next="handleNext"></Step2>
<Step2 @submit="handleSubmit" @next="handleNext"></Step2>
</el-tab-pane>
<el-tab-pane lazy label="第3步" :name="3">
<Step3 :data="detail.step3" @submit="handleSubmit" @next="handleNext"></Step3>
<Step3 @submit="handleSubmit" @next="handleNext"></Step3>
</el-tab-pane>
<el-tab-pane lazy label="第4步" :name="4">
<Step4 :data="detail.step4" @submit="handleSubmit" @next="handleNext"></Step4>
<Step4 @submit="handleSubmit" @next="handleNext"></Step4>
</el-tab-pane>
<el-tab-pane lazy label="第5步" :name="5">
<Step5 :data="detail.step5" @submit="handleSubmit" @next="handleNext"></Step5>
<Step5 @submit="handleSubmit" @next="handleNext"></Step5>
</el-tab-pane>
<el-tab-pane lazy label="第6步" :name="6">
<Step6 :data="detail.step6" @submit="handleSubmit" @next="handleNext"></Step6>
<Step6 @submit="handleSubmit" @next="handleNext"></Step6>
</el-tab-pane>
<el-tab-pane lazy label="第7步" :name="7">
<Step7 :data="detail.step7" :detail="detail" @submit="handleSubmit" @next="handleNext"></Step7>
<Step7 @submit="handleSubmit" @next="handleNext"></Step7>
</el-tab-pane>
<el-tab-pane lazy label="第8步" :name="8">
<Step8 :data="detail.step8" @submit="handleSubmit" @next="handleNext"></Step8>
<Step8 @submit="handleSubmit" @next="handleNext"></Step8>
</el-tab-pane>
</el-tabs>
</AppCard>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论