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

chore: 学生端新增营销策划

上级 d91af3e5
差异被折叠。
......@@ -21,6 +21,7 @@
"@tinymce/tinymce-vue": "^5.0.1",
"@vue-flow/controls": "^1.0.4",
"@vue-flow/core": "^1.17.4",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.9.0",
"axios": "^1.6.8",
"blueimp-md5": "^2.19.0",
......@@ -28,9 +29,13 @@
"echarts": "^5.5.0",
"echarts-wordcloud": "^2.1.0",
"element-plus": "^2.6.3",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.1",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.7",
"pinia": "^2.1.7",
"scroll-into-view-if-needed": "^3.1.0",
"vue": "^3.4.26",
"vue-echarts": "^6.6.9",
"vue-router": "^4.3.2",
......
......@@ -2,5 +2,49 @@ import httpRequest from '@/utils/axios'
// 获取实验信息
export function getExperiment() {
return httpRequest.get('/api/lab/v1/experiment')
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/experiment')
}
// 获取营销策划完成记录
export function getRecords() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/step-complete-records')
}
// 获取连接列表
export function getConnections() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/connections')
}
// 获取除了固定属性之外的其他用户属性
export function getMemberAttrs() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/member-attrs')
}
// 获取事件列表
export function getEvents() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/events')
}
// 获取当前学员步骤
export function getSteps() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/steps')
}
// 保存步骤
export function updateStep(data: { type: string; detail: string }) {
return httpRequest.post('/api/lab/v1/experiment/marketing-planning/save-steps', data)
}
// 验证当前步骤是否已经评分
export function checkStep(data: { type: number }) {
return httpRequest.post('/api/lab/v1/experiment/marketing-planning/check-step', data)
}
// 获取用户属性数据分析
export function getMemberAttrAnalysis(params: { attr_id: string; attr_type: string }) {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/member-attr-analysis', { params })
}
// 获取用户事件数据分析
export function getMemberEventAnalysis(params: { event_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/member-event-analysis', { params })
}
差异被折叠。
<script setup>
import { ElMessage } from 'element-plus'
import { Plus, Minus } from '@element-plus/icons-vue'
import { useObjectiveStore } from '../stores/objective'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(1)
const objectiveStore = useObjectiveStore()
const { addProblem, removeProblem, addObjective, removeObjective } = objectiveStore
const emit = defineEmits(['submit', 'next'])
async function handleValidate() {
const { problems, objectives } = objectiveStore
const problemLength = problems.filter(item => !item.content).length
const objectiveLength = objectives.filter(item => !item.content).length
if (problemLength || objectiveLength) {
ElMessage.error('请填写完整')
return Promise.reject()
}
}
function genFormData() {
const { problems, objectives } = objectiveStore
return { type: 1, detail: { step1: { problems, objectives } } }
}
async function handleSubmit() {
await handleValidate()
emit('submit', genFormData())
}
async function handleNext() {
await handleValidate()
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div class="step-wrapper">
<h2 class="h2-title">营销背景及营销目标</h2>
<div class="market-step1">
<div class="market-step1-box">
<h4>当前业务面临问题/挑战</h4>
<ul>
<li v-for="(item, index) in objectiveStore.problems" :key="item.id">
<p>问题/挑战:</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
:disabled="isCheck"></el-input>
<el-button
type="primary"
:icon="Plus"
@click="addProblem({ content: '' })"
:disabled="isCheck"
v-if="index === objectiveStore.problems.length - 1"></el-button>
<el-button type="primary" :icon="Minus" @click="removeProblem(item.id)" :disabled="isCheck" v-else></el-button>
</li>
</ul>
</div>
<div class="market-step1-box">
<h4>业务部门营销目标</h4>
<ul>
<li v-for="(item, index) in objectiveStore.objectives" :key="item.id">
<p>营销目标:</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
:disabled="isCheck"></el-input>
<el-button
type="primary"
:icon="Plus"
@click="addObjective({ content: '' })"
:disabled="isCheck"
v-if="index === objectiveStore.objectives.length - 1"></el-button>
<el-button type="primary" :icon="Minus" @click="removeObjective(item.id)" :disabled="isCheck" v-else></el-button>
</li>
</ul>
</div>
</div>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.market-step1 {
display: flex;
gap: 20px;
}
.market-step1-box {
flex: 1;
padding: 20px;
border-radius: 10px;
background-color: #eef2f6;
h4 {
text-align: center;
}
ul {
width: 100%;
flex: 1;
}
li {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: center;
column-gap: 20px;
white-space: nowrap;
}
}
</style>
<script setup>
import { ElMessage } from 'element-plus'
import { Select } from '@element-plus/icons-vue'
import Icon from '@/components/ConnectionIcon.vue'
import { useConnectionStore } from '../stores/connection'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(2)
const connectionStore = useConnectionStore()
const emit = defineEmits(['submit', 'next'])
function handleClick(item) {
if (isCheck.value) return
item.active = !item.active
}
async function handleValidate() {
const listLength = connectionStore.currentConnections.filter(item => item.active && !item.content).length
if (listLength) {
ElMessage.error('请填写完整')
return Promise.reject()
}
}
function genFormData() {
const { connections, activeConnections } = connectionStore
return { type: 2, detail: { step2: { connections, activeConnections } } }
}
async function handleSubmit() {
await handleValidate()
emit('submit', genFormData())
}
async function handleNext() {
await handleValidate()
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div class="step-wrapper">
<div class="h2-title">
<h2>营销渠道选择</h2>
<el-button type="primary"><router-link :to="`/user?experiment_id=${$route.query.experiment_id}`" target="_blank">维护用户数据</router-link></el-button>
</div>
<div class="connect-list">
<div class="connect-list-item" v-for="item in connectionStore.currentConnections" :key="item.id" :class="{ 'is-active': item.active }">
<div class="connect-box connect-box__icon" @click="handleClick(item)">
<el-icon v-show="item.active"><Select /></el-icon>
<Icon w="40" h="40" :multiColor="true" class="svg" :name="item.type == 15 ? 'mall' : item.type"></Icon>
<p>{{ item.type_name }}</p>
</div>
<div class="connect-box connect-box__total">
<p>用户数据量:{{ item.member_count }}</p>
<p>用户事件数据量:{{ item.event_count }}</p>
</div>
<div v-show="item.active">
<p>*选择原因</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="100"
:autosize="{ minRows: 3, maxRows: 3 }"
:disabled="isCheck"></el-input>
</div>
</div>
</div>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.connect-list {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.connect-list-item {
width: 300px;
line-height: 1.4;
&.is-active {
color: var(--main-color);
.connect-box__total {
color: #fff;
background-color: var(--main-color);
}
}
}
.connect-box {
padding: 10px;
}
.connect-box__icon {
position: relative;
height: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
.el-icon {
position: absolute;
left: 10px;
top: 10px;
}
}
.connect-box__total {
margin: 5px 0;
text-align: center;
color: var(--main-color);
}
.connect-box {
border: 1px dashed #ccc;
}
</style>
<script setup>
import { Plus, Minus } from '@element-plus/icons-vue'
import AppUpload from '@/components/base/AppUpload.vue'
import { useMemberAttrs } from '../composables/useData'
import { useMemberStore } from '../stores/member'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(3)
const memberStore = useMemberStore()
const { memberAttrs } = useMemberAttrs()
const emit = defineEmits(['submit', 'next'])
const rules = reactive({
sex: [{ required: true, message: '请输入', trigger: 'blur' }],
source: [{ required: true, message: '请输入', trigger: 'blur' }]
})
function handleAdd() {
memberStore.addAttr({ attr_id: '', attr_content: '', attr_file: '' })
}
function handleRemove(item) {
memberStore.removeAttr(item.id)
}
const formRef = ref(null)
async function handleValidate() {
return formRef.value.validate()
}
function genFormData() {
return { type: 3, detail: { step3: { ...memberStore.member } } }
}
async function handleSubmit() {
await handleValidate()
emit('submit', genFormData())
}
async function handleNext() {
await handleValidate()
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div>
<div class="h2-title">
<h2>用户分析</h2>
<div>
<el-button type="primary"><router-link :to="`/user?experiment_id=${$route.query.experiment_id}`" target="_blank">用户个人画像</router-link></el-button>
<el-button type="primary">
<router-link :to="`/analyze/user?experiment_id=${$route.query.experiment_id}`" target="_blank">用户整体画像</router-link>
</el-button>
</div>
</div>
<el-form label-width="150" label-suffix=":" :model="memberStore.member" :rules="rules" ref="formRef" :disabled="isCheck">
<el-form-item label="用户性别分析" prop="sex">
<div style="width: 100%">
<el-input type="textarea" v-model="memberStore.member.sex" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<AppUpload v-model="memberStore.member.sex_file" style="margin-top: 20px"></AppUpload>
</div>
</el-form-item>
<el-form-item label="用户数据来源分析" prop="source">
<div style="width: 100%">
<el-input type="textarea" v-model="memberStore.member.source" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<AppUpload v-model="memberStore.member.source_file" style="margin-top: 20px"></AppUpload>
</div>
</el-form-item>
<el-form-item label="用户属性分析">
<ul>
<li v-for="(item, index) in memberStore.member.attrs" :key="index">
<div style="flex: 1">
<el-select-v2 v-model="item.attr_id" :options="memberAttrs" :props="{ label: 'name', value: 'id' }"></el-select-v2>
<el-input
type="textarea"
v-model="item.attr_content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
style="margin: 10px 0">
</el-input>
<AppUpload v-model="item.attr_file"></AppUpload>
</div>
<el-button type="primary" :icon="Minus" @click="handleRemove(item)"></el-button>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd"></el-button>
</el-form-item>
</el-form>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.el-card {
background-color: #eef2f6;
}
h4 {
text-align: center;
}
ul {
width: 100%;
}
li {
border-bottom: 1px solid #eef2f6;
padding-bottom: 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
column-gap: 20px;
}
</style>
<script setup lang="ts">
import { Plus, Minus } from '@element-plus/icons-vue'
import FormDialog from './Step4Form.vue'
import { useLabelStore } from '../stores/label'
import type { TypeState, LabelState } from '../stores/label'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(4)
const labelStore = useLabelStore()
const emit = defineEmits(['submit', 'next'])
const dialogVisible = ref(false)
const currentRow = ref()
// 添加
function handleAdd(item: TypeState) {
currentRow.value = { type_id: item.id, type_name: item.name }
dialogVisible.value = true
}
// 查看
function handleView(item: LabelState) {
currentRow.value = item
dialogVisible.value = true
}
// 删除
function handleRemove(item: LabelState) {
labelStore.removeLabel(item.id)
}
function genFormData() {
const { types, labels, treeLabels } = labelStore
return { type: 4, detail: { step4: { types, labels, treeLabels } } }
}
async function handleSubmit() {
emit('submit', genFormData())
}
async function handleNext() {
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div>
<h2 class="h2-title">标签体系设计</h2>
<div class="market-label">
<div class="market-label-box" v-for="item in labelStore.treeLabels" :key="item.name">
<h4>{{ item.name }}</h4>
<ul>
<li v-for="child in item.children" :key="child.id">
<p @click="handleView(child)">{{ child.name }}</p>
<!-- <el-button type="primary" text size="small" @click="handleView(child)">查看</el-button> -->
<el-button :icon="Minus" size="small" @click="handleRemove(child)" :disabled="isCheck"></el-button>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd(item)" :disabled="isCheck"></el-button>
</div>
</div>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
<FormDialog v-model="dialogVisible" :data="currentRow" v-if="dialogVisible" />
</div>
</template>
<style lang="scss" scoped>
.market-label {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 20px;
}
.market-label-box {
padding: 20px;
border-radius: 20px;
background-color: #eef2f6;
min-height: 400px;
display: flex;
flex-direction: column;
align-items: center;
ul {
width: 100%;
flex: 1;
}
li {
margin: 20px 0;
display: flex;
align-items: center;
justify-content: center;
column-gap: 10px;
cursor: pointer;
p {
flex: 1;
line-height: 40px;
text-align: center;
background-color: #fff;
border-radius: 10px;
border: 1px solid #bbb;
}
}
}
</style>
<script setup>
import { nanoid } from 'nanoid'
import Step4FormAttr from './Step4FormAttr.vue'
import Step4FormEvent from './Step4FormEvent.vue'
import { useMemberAttrs, useEvents } from '../composables/useData'
import { useLabelStore } from '../stores/label'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(4)
const labelStore = useLabelStore()
const { addLabel, updateLabel } = labelStore
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['data'])
const form = reactive({
id: nanoid(4),
type_id: '',
type_name: '',
name: '',
data_type: '用户属性',
attr_id: '',
attr_name: '',
attr_type: '',
event_id: '',
event_name: '',
desc: ''
})
watchEffect(() => {
Object.assign(form, props.data)
})
const formRef = ref(null)
const rules = reactive({
name: [{ required: true, message: '请输入', trigger: 'blur' }],
desc: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const { memberAttrs } = useMemberAttrs()
const handleAttrChange = value => {
const found = memberAttrs.value.find(item => item.id === value)
form.attr_name = found.name
form.attr_type = found.type
form.attr_type_name = found.type_name
}
const { events } = useEvents()
const handleEventChange = value => {
const found = events.value.find(item => item.id === value)
form.event_name = found.name
}
const handleSubmit = async () => {
await formRef.value.validate()
props.data.id ? updateLabel(toRaw(form)) : addLabel(toRaw(form))
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog title="设计标签" width="800" @closed="$emit('update:modelValue', false)">
<el-form label-suffix=":" label-width="160" :model="form" :rules="rules" ref="formRef" :disabled="isCheck">
<el-form-item label="标签所属目录">
<el-select-v2 v-model="form.type_name" :options="labelStore.types" :props="{ label: 'name', value: 'name' }" disabled></el-select-v2>
</el-form-item>
<el-form-item label="标签名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="关联数据类型" prop="data_type">
<el-radio-group v-model="form.data_type">
<el-radio label="用户属性" value="用户属性"></el-radio>
<el-radio label="用户行为事件" value="用户行为事件"></el-radio>
<el-radio label="其他" value="其他"></el-radio>
</el-radio-group>
</el-form-item>
<!-- 用户属性 -->
<template v-if="form.data_type == '用户属性'">
<el-form-item label="用户属性字段">
<el-select-v2 v-model="form.attr_id" :options="memberAttrs" :props="{ label: 'name', value: 'id' }" @change="handleAttrChange"></el-select-v2>
</el-form-item>
<el-form-item label="字段类型">{{ form.attr_type_name }}</el-form-item>
<Step4FormAttr :attrId="form.attr_id" :attrType="form.attr_type"></Step4FormAttr>
</template>
<!-- 用户行为事件 -->
<template v-if="form.data_type == '用户行为事件'">
<el-form-item label="用户行为事件">
<el-select-v2 v-model="form.event_id" :options="events" :props="{ label: 'name', value: 'id' }" @change="handleEventChange"></el-select-v2>
</el-form-item>
<Step4FormEvent :eventId="form.event_id"></Step4FormEvent>
</template>
<el-form-item label="标签设置规则及说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit" :disabled="isCheck">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
<script setup>
import { getMemberAttrAnalysis } from '../api'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, PieChart, TitleComponent, TooltipComponent, LegendComponent])
const props = defineProps(['attrId', 'attrType'])
const detail = ref({
str_analysis: {
items: []
},
num_analysis: {
avg: '0',
min: '0',
max: '0',
first_quarter: '0',
median: '0',
three_quarters: '0'
}
})
async function fetchInfo() {
const res = await getMemberAttrAnalysis({ attr_id: props.attrId, attr_type: props.attrType })
detail.value = res.data
}
watch(() => props.attrId, fetchInfo)
const options = computed(() => {
return {
color: ['#af1c40', '#c17933', '#8f0034', '#d45548', '#ab3259', '#dec34c', '#8b8920', '#a25a6d'],
tooltip: { trigger: 'item', formatter: '{b}: {c}<br />{d}%' },
series: [
{
type: 'pie',
label: { formatter: '{b}\n{d}%' },
itemStyle: { borderRadius: 6 },
radius: [0, '70%'],
data: detail.value.str_analysis.items.map(item => {
return { name: item.group_name, value: item.total }
})
}
]
}
})
</script>
<template>
<div>
<el-form-item label="字段值分布">
<template v-if="attrType == 2">
<div>
<el-form-item label-width="auto" label="平均值">{{ detail.num_analysis.avg }}</el-form-item>
<el-form-item label-width="auto" label="最大值">{{ detail.num_analysis.max }}</el-form-item>
<el-form-item label-width="auto" label="最小值">{{ detail.num_analysis.min }}</el-form-item>
</div>
<div style="margin-left: 100px">
<el-form-item label-width="auto" label="1/4位数">{{ detail.num_analysis.first_quarter }}</el-form-item>
<el-form-item label-width="auto" label="中位数">{{ detail.num_analysis.median }}</el-form-item>
<el-form-item label-width="auto" label="3/4位数">{{ detail.num_analysis.three_quarters }}</el-form-item>
</div>
</template>
<template v-else>
<v-chart class="chart" :option="options" autoresize ref="chart" style="height: 200px" v-if="detail.str_analysis.items.length" />
<el-empty description="暂无数据" v-else />
</template>
</el-form-item>
</div>
</template>
<script setup>
import { getMemberEventAnalysis } from '../api'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const props = defineProps(['eventId'])
const detail = ref({
event_total: '0',
member_total: '0',
member_avg_total: '0',
start_time: '',
end_time: '',
items: []
})
async function fetchInfo() {
const res = await getMemberEventAnalysis({ event_id: props.eventId })
detail.value = res.data
}
watch(() => props.eventId, fetchInfo)
const options = computed(() => {
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '15%', containLabel: true },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
boundaryGap: ['20%', '20%'],
data: detail.value.items.map(item => item.group_name)
},
yAxis: { type: 'value' },
series: [
{
data: detail.value.items.map(item => item.total),
type: 'line',
smooth: true
}
]
}
})
</script>
<template>
<div>
<el-form-item label="事件数据总量">
{{ detail.event_total }}
<el-form-item label="事件人数">{{ detail.member_total }}</el-form-item>
<el-form-item label="人均事件数量">{{ detail.member_avg_total }}</el-form-item>
</el-form-item>
<el-form-item label="事件发生开始时间">
{{ detail.start_time }}
<el-form-item label="事件发生结束时间">{{ detail.end_time }} </el-form-item>
</el-form-item>
<el-form-item label="事件走势图">
<v-chart class="chart" :option="options" autoresize ref="chart" style="height: 200px" v-if="detail.items.length" />
<el-empty description="暂无数据" v-else />
</el-form-item>
</div>
</template>
<script setup>
import { Plus, CircleClose } from '@element-plus/icons-vue'
import FormDialog from './Step5Form.vue'
import { useGroupStore } from '../stores/group'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(5)
const groupStore = useGroupStore()
const emit = defineEmits(['submit', 'next'])
const dialogVisible = ref(false)
const currentRow = ref()
// 添加
function handleAdd(item) {
currentRow.value = item
dialogVisible.value = true
}
// 查看
function handleView(item) {
currentRow.value = item
dialogVisible.value = true
}
// 删除
function handleRemove(item) {
groupStore.removeGroup(item.id)
}
function genFormData() {
const { groups, staticGroups, dynamicGroups } = groupStore
return { type: 5, detail: { step5: { groups, staticGroups, dynamicGroups } } }
}
async function handleSubmit() {
emit('submit', genFormData())
}
async function handleNext() {
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div>
<div class="h2-title">
<h2>用户精准分群设计</h2>
<div>
<el-button type="primary"><router-link :to="`/group?experiment_id=${$route.query.experiment_id}`" target="_blank">维护用户群组</router-link></el-button>
</div>
</div>
<div class="market-group">
<div class="market-group-box">
<h4>静态用户群组</h4>
<ul>
<li v-for="(item, index) in groupStore.staticGroups" :key="index" @click="handleView(item)">
<p>{{ item.name }}</p>
<el-icon @click.stop="handleRemove(item)" class="remove" v-if="!isCheck"><CircleClose /></el-icon>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd({ type: 1, type_name: '静态群组' })" :disabled="isCheck"></el-button>
</div>
<div class="market-group-box">
<h4>动态用户群组</h4>
<ul>
<li v-for="(item, index) in groupStore.dynamicGroups" :key="index" @click="handleView(item)">
<p>{{ item.name }}</p>
<el-icon @click.stop="handleRemove(item)" class="remove" v-if="!isCheck"><CircleClose /></el-icon>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd({ type: 2, type_name: '动态群组' })" :disabled="isCheck"></el-button>
</div>
</div>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
<FormDialog v-model="dialogVisible" :data="currentRow" v-if="dialogVisible" />
</div>
</template>
<style lang="scss" scoped>
.market-group-box {
margin: 20px 0;
padding: 20px;
border-radius: 20px;
background-color: #eef2f6;
min-height: 200px;
ul {
margin: 40px 0;
display: flex;
flex-wrap: wrap;
gap: 20px;
}
li {
position: relative;
width: 128px;
height: 128px;
background-color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid #ccc;
.remove {
display: none;
position: absolute;
color: var(--main-color);
font-size: 30px;
top: 5px;
right: 5px;
}
&:hover {
.remove {
display: block;
}
}
}
}
</style>
<script setup>
import { useGroupStore } from '../stores/group'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(5)
const groupStore = useGroupStore()
const { addGroup, updateGroup } = groupStore
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['data'])
const form = reactive({
type: 1,
name: '',
join_rule: '',
remove_rule: '',
reason: ''
})
watchEffect(() => {
Object.assign(form, props.data)
})
const formRef = ref(null)
const rules = reactive({
name: [{ required: true, message: '请输入', trigger: 'blur' }],
join_rule: [{ required: true, message: '请输入', trigger: 'blur' }],
remove_rule: [{ required: true, message: '请输入', trigger: 'blur' }],
reason: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const handleSubmit = async () => {
await formRef.value.validate()
props.data.id ? updateGroup(toRaw(form)) : addGroup(toRaw(form))
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog title="用户群组设计" width="600" @closed="$emit('update:modelValue', false)">
<el-form label-suffix=":" label-width="120" :model="form" :rules="rules" ref="formRef" :disabled="isCheck">
<el-form-item label="用户群组类型">
<el-radio-group v-model="form.type" disabled>
<el-radio label="静态群组" :value="1"></el-radio>
<el-radio label="动态群组" :value="2"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="群组名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="群组加入规则" prop="join_rule">
<el-input type="textarea" v-model="form.join_rule" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
<el-form-item label="群组移除规则" prop="remove_rule">
<el-input type="textarea" v-model="form.remove_rule" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
<el-form-item label="设计群组原因" prop="reason">
<el-input type="textarea" v-model="form.reason" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit" :disabled="isCheck">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
<script setup>
import Flow from './flow/Flow.vue'
import { useTripStore } from '../stores/trip'
import { useObjectiveStore } from '../stores/objective'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(6)
const tripStore = useTripStore()
const objectiveStore = useObjectiveStore()
const emit = defineEmits(['submit', 'next'])
function genFormData() {
const { nodes, edges } = tripStore
return { type: 6, detail: { step6: { nodes, edges } } }
}
async function handleSubmit() {
emit('submit', genFormData())
}
async function handleNext() {
emit('next', genFormData(), isCheck.value)
}
const problemDialogVisible = ref(false)
const objectiveDialogVisible = ref(false)
</script>
<template>
<div class="market-trip">
<div class="h2-title">
<h2>自动化营销旅程设计</h2>
<div>
<el-button type="primary">
<router-link :to="`/trip/my?experiment_id=${$route.query.experiment_id}`" target="_blank">维护自动化营销旅程</router-link>
</el-button>
</div>
</div>
<el-row justify="center">
<el-space :size="100">
<el-button type="primary" size="large" @click="problemDialogVisible = true">当前面临的问题与挑战</el-button>
<el-button type="primary" size="large" @click="objectiveDialogVisible = true">业务部门的营销目标</el-button>
</el-space>
</el-row>
<Flow v-model:nodes="tripStore.nodes" v-model:edges="tripStore.edges" style="height: 60vh"></Flow>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
<el-dialog title="当前面临的问题与挑战" v-model="problemDialogVisible" width="600">
<ul>
<li v-for="item in objectiveStore.problems" :key="item.id">{{ item.content }}</li>
</ul>
</el-dialog>
<el-dialog title="业务部门的营销目标" v-model="objectiveDialogVisible" width="600">
<ul>
<li v-for="item in objectiveStore.objectives" :key="item.id">{{ item.content }}</li>
</ul>
</el-dialog>
</div>
</template>
<script setup>
import FormDialog from './Step7Form.vue'
import { useMaterialStore } from '../stores/material'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(7)
const materialStore = useMaterialStore()
const listOptions = computed(() => {
return {
data: materialStore.materials,
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '一级流程节点', prop: 'node1' },
{ label: '二级流程节点', prop: 'node2' },
{ label: '营销物料类型', prop: 'type' },
{ label: '物料风格', prop: 'style' },
{ label: '物料侧重点', prop: 'desc' },
{ label: '物料更新频率', prop: 'update_rule' },
{ label: '操作', slots: 'table-x', width: 140 }
]
}
})
const emit = defineEmits(['submit', 'next'])
const dialogVisible = ref(false)
const currentRow = ref()
// 添加
function handleAdd() {
currentRow.value = null
dialogVisible.value = true
}
// 查看
function handleView(item) {
currentRow.value = item
dialogVisible.value = true
}
// 删除
function handleRemove(item) {
materialStore.removeMaterial(item.id)
}
function genFormData() {
const { materials } = materialStore
return { type: 7, detail: { step7: { materials } } }
}
async function handleSubmit() {
emit('submit', genFormData())
}
async function handleNext() {
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div>
<div class="h2-title">
<h2>营销物料设计</h2>
<div>
<el-button type="primary" @click="handleAdd" :disabled="isCheck">新建</el-button>
</div>
</div>
<AppList v-bind="listOptions">
<template #table-x="{ row }">
<el-button text type="primary" @click="handleView(row)" :disabled="isCheck">编辑</el-button>
<el-button text type="danger" @click="handleRemove(row)" :disabled="isCheck">删除</el-button>
</template>
</AppList>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
<FormDialog v-model="dialogVisible" :data="currentRow" v-if="dialogVisible" />
</div>
</template>
<script setup>
import { useMaterialStore } from '../stores/material'
import { useTripStore } from '../stores/trip'
import { useMapStore } from '@/stores/map'
const materialTypeList = useMapStore().getMapValuesByKey('experiment_marketing_material_type')
const tripStore = useTripStore()
const materialStore = useMaterialStore()
const { addMaterial, updateMaterial } = materialStore
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['data'])
const form = reactive({
node1: '',
node2: '',
type: '',
style: '',
desc: '',
update_rule: '低'
})
watchEffect(() => {
Object.assign(form, props.data)
})
const formRef = ref(null)
const rules = reactive({
node1: [{ required: true, message: '请选择', trigger: 'blur' }],
node2: [{ required: true, message: '请选择', trigger: 'blur' }],
type: [{ required: true, message: '请选择', trigger: 'blur' }],
style: [{ required: true, message: '请选择', trigger: 'blur' }],
desc: [{ required: true, message: '请输入', trigger: 'blur' }],
update_rule: [{ required: true, message: '请选择', trigger: 'blur' }]
})
const handleSubmit = async () => {
await formRef.value.validate()
props.data?.id ? updateMaterial(toRaw(form)) : addMaterial(toRaw(form))
emit('update:modelValue', false)
}
const styleList = ['专业权威', '详细深入', '时尚年轻', '种草分享', '网红推荐', '生动有趣']
</script>
<template>
<el-dialog title="自动化营销旅程设计-营销物料" width="600" @closed="$emit('update:modelValue', false)">
<el-form label-suffix=":" label-width="140" :model="form" :rules="rules" ref="formRef">
<el-form-item label="一级流程节点" prop="node1">
<el-select-v2 v-model="form.node1" :options="tripStore.node1List" :props="{ label: 'label', value: 'label' }"></el-select-v2>
</el-form-item>
<el-form-item label="二级流程节点" prop="node2">
<el-select-v2 v-model="form.node2" :options="tripStore.node2List" :props="{ label: 'label', value: 'label' }"></el-select-v2>
</el-form-item>
<el-form-item label="营销物料类型" prop="type">
<el-select-v2 v-model="form.type" :options="materialTypeList" :props="{ label: 'label', value: 'label' }"></el-select-v2>
</el-form-item>
<el-form-item label="营销物料风格" prop="style">
<el-radio-group v-model="form.style">
<el-radio v-for="item in styleList" :label="item" :value="item" :key="item"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="营销物料侧重点" prop="desc">
<el-input v-model="form.desc"></el-input>
</el-form-item>
<el-form-item label="营销物料更新频率" prop="update_rule">
<el-radio-group v-model="form.update_rule">
<el-radio label="低" value="低"></el-radio>
<el-radio label="中" value="中"></el-radio>
<el-radio label="高" value="高"></el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
<script setup>
import Report from './Report.vue'
import { useUserStore } from '@/stores/user'
import { useExperiment } from '../composables/useData'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(8)
const { experiment } = useExperiment()
const userStore = useUserStore()
const emit = defineEmits(['submit'])
const reportRef = ref(null)
// 导出图片
function handleExport() {
// reportRef.value.generateImage()
reportRef.value.generatePdf()
}
async function genFormData() {
const report = await reportRef.value.generateImage()
return { type: 8, detail: { step8: { report } } }
}
async function handleSubmit() {
emit('submit', await genFormData())
}
</script>
<template>
<div>
<div class="h2-title">
<h2>营销策划报告</h2>
<el-button type="primary" @click="handleExport">导出</el-button>
</div>
<Report
:experimentName="experiment.name"
:teacherName="experiment.teacher_name"
:studentName="userStore.user.name || userStore.user.username"
ref="reportRef" />
<div class="market-step-footer">
<el-button type="primary" @click="handleSubmit" :disabled="isCheck">保存</el-button>
</div>
</div>
</template>
<script setup>
import { StepEdge, EdgeLabelRenderer, getSmoothStepPath, useVueFlow } from '@vue-flow/core'
import { Plus, Close } from '@element-plus/icons-vue'
import { nanoid } from 'nanoid'
const props = defineProps({
id: { type: String, required: true },
sourceX: { type: Number, required: true },
sourceY: { type: Number, required: true },
targetX: { type: Number, required: true },
targetY: { type: Number, required: true },
sourcePosition: { type: String, required: true },
targetPosition: { type: String, required: true },
data: { type: Object, required: false },
markerEnd: { type: String, required: false },
style: { type: Object, required: false },
selected: { type: Boolean, required: false }
})
const path = computed(() => getSmoothStepPath(props))
const { addNodes, removeEdges } = useVueFlow()
function onAdd() {
const position = { x: 0, y: 0 }
const newNode = {
id: nanoid(4),
type: 'custom',
label: `旅程节点`,
data: { label: '旅程节点' },
position
}
addNodes([newNode])
}
</script>
<script>
export default {
inheritAttrs: false
}
</script>
<template>
<StepEdge :id="id" :style="style" :path="path[0]" :marker-end="markerEnd" :interactionWidth="30"></StepEdge>
<EdgeLabelRenderer>
<div
:style="{
pointerEvents: 'all',
position: 'absolute',
transform: `translate(-50%, -50%) translate(${path[1]}px,${path[2]}px)`
}">
<el-button-group>
<el-button :icon="Plus" circle @click="onAdd"></el-button>
<el-button :icon="Close" circle @click="removeEdges([id])"></el-button>
</el-button-group>
</div>
</EdgeLabelRenderer>
</template>
<script setup>
import { VueFlow, useVueFlow, MarkerType, ConnectionLineType } from '@vue-flow/core'
import NodeStart from './NodeStart.vue'
import NodeEnd from './NodeEnd.vue'
import NodeCustom from './NodeCustom.vue'
import EdgeCustom from './EdgeCustom.vue'
import { nanoid } from 'nanoid'
defineProps({
process: { type: Number, default: 1 }
})
const id = nanoid()
const { onConnect, addEdges } = useVueFlow(id)
onConnect(params => {
addEdges([
{
...params,
type: 'custom',
animated: true,
markerEnd: MarkerType.ArrowClosed
}
])
})
</script>
<template>
<VueFlow
:id="id"
fit-view-on-init
:zoom-on-scroll="false"
:prevent-scrolling="false"
:connection-radius="30"
snap-to-grid
:snap-grid="[180, 180]"
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed, type: ConnectionLineType.Straight }">
<template #node-start="props">
<NodeStart :process="process" v-bind="props" />
</template>
<template #node-end="props">
<NodeEnd :process="process" v-bind="props" />
</template>
<template #node-custom="props">
<NodeCustom :process="process" v-bind="props" />
</template>
<template #edge-custom="props">
<EdgeCustom :process="process" v-bind="props" />
</template>
</VueFlow>
</template>
<style>
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
</style>
<script setup>
import { Position, Handle, useVueFlow } from '@vue-flow/core'
import { CircleCloseFilled } from '@element-plus/icons-vue'
import NodeCustomForm from './NodeCustomForm.vue'
const Flow = defineAsyncComponent(() => import('./Flow.vue'))
const props = defineProps(['label', 'data', 'process', 'selected', 'id'])
const dialogVisible = ref(false)
const flowDialogVisible = ref(false)
const nodes = ref([])
const edges = ref([])
watch(
() => props.data,
() => {
nodes.value = props.data.nodes || [
{
id: 'start',
type: 'start',
label: '子流程入口',
data: { label: '子流程入口' },
position: { x: 0, y: 0 }
},
{
id: '1',
type: 'custom',
label: '二级旅程节点',
data: { label: '二级旅程节点' },
position: { x: 360, y: 0 }
},
{
id: 'end',
type: 'end',
label: '子流程出口',
data: { label: '子流程出口' },
position: { x: 720, y: 0 }
}
]
edges.value = props.data.edges || [
{
id: 'start->1',
type: 'custom',
source: 'start',
target: '1',
animated: true,
markerEnd: 'arrowclosed'
},
{
id: '1->end',
type: 'custom',
source: '1',
target: 'end',
animated: true,
markerEnd: 'arrowclosed'
}
]
},
{ immediate: true }
)
const { removeNodes } = useVueFlow()
const isCompleted = computed(() => {
return !!props.data.label
})
const handleSubmit = async () => {
Object.assign(props.data, { nodes: nodes.value, edges: edges.value })
flowDialogVisible.value = false
}
</script>
<template>
<div class="flow-node flow-node-custom" :class="{ 'is-completed': isCompleted }">
<el-icon class="flow-node-custom__remove" @click="removeNodes([id])" v-if="selected"><CircleCloseFilled /></el-icon>
<Handle type="target" :position="Position.Left" />
<div class="flow-node-custom__inner">
<el-button type="primary" size="small" @click="dialogVisible = true">编辑</el-button>
<el-button type="primary" size="small" @click="flowDialogVisible = true" v-if="process != 2">子流程</el-button>
<div class="flow-node__label">{{ data.label || label }}</div>
</div>
<Handle type="source" :position="Position.Right" />
<NodeCustomForm :data="data" :process="process" v-model="dialogVisible" v-if="dialogVisible"></NodeCustomForm>
<el-dialog title="自动化营销旅程设计-二级流程" append-to-body width="1000" v-model="flowDialogVisible">
<Flow v-model:nodes="nodes" v-model:edges="edges" :process="2" style="height: 500px"></Flow>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="flowDialogVisible = false">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</div>
</template>
<style lang="scss">
@import './style.css';
.flow-node-custom {
&.is-completed {
color: #fff;
background-color: rgba(104, 187, 196, 0.52);
}
}
.flow-node-custom__remove {
position: absolute;
right: 0;
top: 0;
}
</style>
<script setup>
import { useMapStore } from '@/stores/map'
const materialTypeList = useMapStore().getMapValuesByKey('experiment_marketing_material_type')
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['data', 'process'])
const title = computed(() => {
const subTitle = props.process == 2 ? '二级流程节点' : '一级流程节点'
return `自动化营销旅程设计-${subTitle}`
})
const formRef = ref(null)
const form = reactive({
label: '',
desc: '',
use_material: '是',
material_type: ''
})
onMounted(() => {
Object.assign(form, props.data)
})
const rules = reactive({
label: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const handleSubmit = async () => {
await formRef.value?.validate()
Object.assign(props.data, { ...form })
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog :title="title" append-to-body width="600" @closed="$emit('update:modelValue', false)">
<el-form label-suffix=":" label-width="140" :model="form" :rules="rules" ref="formRef">
<el-form-item label="节点名称" prop="label">
<el-input v-model="form.label"></el-input>
</el-form-item>
<el-form-item label="流程节点说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
<template v-if="process == 2">
<el-form-item label="是否用到营销物料" prop="use_material">
<el-radio-group v-model="form.use_material">
<el-radio label="是" value="是"></el-radio>
<el-radio label="否" value="否"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="营销物料类型" prop="material_type">
<el-select-v2 v-model="form.type" :options="materialTypeList" :props="{ label: 'label', value: 'label' }" clearable></el-select-v2>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
<script setup>
import { Position, Handle } from '@vue-flow/core'
defineProps(['label', 'process'])
</script>
<template>
<div class="flow-node flow-node-end">
<div class="flow-node__label">{{ label }}</div>
<Handle type="target" :position="Position.Left" />
<p class="flow-node-tips" v-if="process == 1">自动化旅程的结束不需要维护</p>
</div>
</template>
<style>
@import './style.css';
</style>
<script setup>
import { Position, Handle } from '@vue-flow/core'
const props = defineProps(['label', 'data', 'process'])
const dialogVisible = ref(false)
const formRef = ref(null)
const form = reactive({
time: '一次性触发',
condition: '无条件触发',
desc: ''
})
watch(dialogVisible, value => {
if (value) {
Object.assign(form, props.data)
} else {
formRef.value?.resetFields()
}
})
const rules = reactive({
time: [{ required: true, message: '请选择', trigger: 'blur' }],
condition: [{ required: true, message: '请选择', trigger: 'blur' }],
desc: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const timeList = ['一次性触发', '周期性触发']
const conditionList = ['无条件触发', '固定条件触发', '动态条件触发']
const handleSubmit = async () => {
await formRef.value?.validate()
Object.assign(props.data, { ...form })
dialogVisible.value = false
}
</script>
<template>
<div class="flow-node flow-node-start" @click="dialogVisible = true">
<div class="flow-node__label">{{ label }}</div>
<Handle type="source" :position="Position.Right" />
<p class="flow-node-tips" v-if="process == 1">点击节点维护自动化营销旅程的触发条件</p>
<el-dialog v-model="dialogVisible" title="自动化营销旅程设计-旅程触发" append-to-body width="600">
<el-form label-suffix=":" label-width="140" :model="form" :rules="rules" ref="formRef">
<el-form-item label="旅程触发时机" prop="time">
<el-radio-group v-model="form.time">
<el-radio v-for="item in timeList" :key="item" :label="item" :value="item"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="旅程触发条件" prop="condition">
<el-radio-group v-model="form.condition">
<el-radio v-for="item in conditionList" :key="item" :label="item" :value="item"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="旅程触发条件说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="dialogVisible = false">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</div>
</template>
<style>
@import './style.css';
</style>
.flow-node {
position: relative;
width: 180px;
height: 160px;
border: 1px solid #bbb;
display: flex;
align-items: center;
justify-content: center;
}
.flow-node-start {
border-radius: 30px;
background-color: #bdf8b4;
}
.flow-node-end {
border-radius: 30px;
background-color: rgba(255, 2, 2, 0.32);
}
.flow-node-custom__inner {
text-align: center;
}
.flow-node__label {
padding: 10px 0;
text-align: center;
}
.is-computed {
color: #fff;
background-color: rgba(104, 187, 196, 0.52);
}
.flow-node-tips {
position: absolute;
left: 50%;
bottom: -30px;
transform: translateX(-50%);
font-size: 12px;
text-align: center;
color: #a6a6a6;
white-space: nowrap;
}
import { getExperiment, getConnections, getMemberAttrs, getEvents, checkStep } from '../api'
import { getNameByValue } from '@/utils/dictionary'
import { useMapStore } from '@/stores/map'
const connectionTypeList = useMapStore().getMapValuesByKey('experiment_connection_type')
const attrTypeList = useMapStore().getMapValuesByKey('experiment_attribute_type')
// 实验信息
export interface Experiment {
id: number
name: string
length: string
course_name: string
teacher_name: string
}
const experiment = ref<Partial<Experiment>>({})
export function useExperiment() {
async function fetchInfo() {
const res = await getExperiment()
const detail = res.data.detail
const course = detail.courses?.map((item: any) => item.name) || []
const teacher = detail.teachers?.map((item: any) => item.name) || []
experiment.value = { ...detail, course_name: course.join('、'), teacher_name: teacher.join('、') }
}
onMounted(() => {
fetchInfo()
})
return { experiment }
}
// 链接
export interface Connection {
id: string
type: string
type_name: string
member_count: string
event_count: string
}
const connections = ref<Connection[]>([])
export function useConnection() {
async function fetchInfo() {
const res = await getConnections()
connections.value = res.data.items.map((item: any) => {
return { ...item, type_name: getNameByValue(item.type, connectionTypeList) }
})
}
onMounted(() => {
fetchInfo()
})
return { connections }
}
// 用户属性
export interface MemberAttr {
id: string
name: string
english_name: string
type: string
format: string
}
const memberAttrs = ref<MemberAttr[]>([])
export function useMemberAttrs() {
async function fetchInfo() {
const res = await getMemberAttrs()
memberAttrs.value = res.data.items.map((item: any) => {
return { ...item, type_name: getNameByValue(item.type, attrTypeList) }
})
}
onMounted(() => {
fetchInfo()
})
return { memberAttrs }
}
// 事件
export interface EventAttr {
id: string
name: string
english_name: string
}
const events = ref<EventAttr[]>([])
export function useEvents() {
async function fetchInfo() {
const res = await getEvents()
events.value = res.data.items
}
onMounted(() => {
fetchInfo()
})
return { events }
}
export function useCheckStep(type: number) {
const isCheck = ref(false)
onMounted(async () => {
const res = await checkStep({ type })
isCheck.value = res.data.is_check
})
return { isCheck }
}
......@@ -5,7 +5,10 @@ const routes: RouteRecordRaw[] = [
{
path: '/market/my',
component: Layout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
children: [
{ path: '', component: () => import('./views/Index.vue') },
{ path: 'update', component: () => import('./views/Update.vue') }
]
}
]
......
import { defineStore } from 'pinia'
import type { Connection } from '../composables/useData'
import { useConnection } from '../composables/useData'
export interface State {
connections: ConnectionState[]
}
export interface ConnectionState {
id: string
content: string
}
export interface CurrentConnection extends Connection {
active: boolean
content: string
}
export const useConnectionStore = defineStore('connection', () => {
const { connections: rawConnections } = useConnection()
const connections = ref<ConnectionState[]>([])
const currentConnections = ref<CurrentConnection[]>([])
const activeConnections = computed(() => {
return currentConnections.value.filter(item => item.active) || []
})
watch(
rawConnections,
() => {
setConnections(connections.value)
},
{ once: true }
)
watch(
currentConnections,
() => {
connections.value = currentConnections.value
.filter(item => item.active)
.map(item => {
return { id: item.id, content: item.content }
})
},
{ immediate: true, deep: true }
)
function setConnections(list: ConnectionState[]) {
connections.value = list
if (rawConnections.value.length === 0) return
currentConnections.value = rawConnections.value.map(item => {
const found = list?.find(({ id }) => id === item.id)
return found ? { ...item, active: true, content: found.content } : { ...item, active: false, content: '' }
})
}
function setData(data: State) {
if (!data?.connections) return
setConnections(data.connections)
}
return { connections, currentConnections, activeConnections, rawConnections, setConnections, setData }
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
groups: GroupState[]
}
export interface GroupState {
id: string
name: string
type: number
}
export const useGroupStore = defineStore('group', {
state: (): State => {
return {
groups: []
}
},
getters: {
staticGroups(state) {
return state.groups.filter(item => item.type == 1)
},
dynamicGroups(state) {
return state.groups.filter(item => item.type == 2)
}
},
actions: {
setData(data: State) {
if (!data?.groups) return
this.setGroups(data.groups)
},
setGroups(list: GroupState[]) {
this.groups = list
},
addGroup(group: Omit<GroupState, 'id'>) {
this.groups.push({ id: nanoid(4), ...group })
},
updateGroup(group: GroupState) {
const index = this.groups.findIndex(item => item.id === group.id)
this.groups[index] = group
},
removeGroup(id: string) {
this.groups = this.groups.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
types: TypeState[]
labels: LabelState[]
}
export interface TypeState {
id: string
name: string
}
export interface LabelState {
id: string
type_name: string
name: string
}
export const useLabelStore = defineStore('label', {
state: (): State => {
return {
types: [
{ id: '1', name: '基础标签' },
{ id: '2', name: '行为标签' },
{ id: '3', name: '业务标签' },
{ id: '4', name: '订单标签' },
{ id: '5', name: '渠道标签' }
],
labels: []
}
},
getters: {
treeLabels: state => {
return state.types.map(item => {
const children = state.labels.filter(child => child.type_name === item.name)
return { ...item, children }
})
}
},
actions: {
setData(data?: State) {
if (!data?.labels) return
this.setLabels(data.labels)
},
setLabels(list: LabelState[]) {
this.labels = list
},
addLabel(label: Omit<LabelState, 'id'>) {
this.labels.push({ id: nanoid(4), ...label })
},
updateLabel(label: LabelState) {
const index = this.labels.findIndex(item => item.id === label.id)
this.labels[index] = label
},
removeLabel(id: string) {
this.labels = this.labels.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import { useObjectiveStore } from './objective'
import { useConnectionStore } from './connection'
import { useMemberStore } from './member'
import { useLabelStore } from './label'
import { useGroupStore } from './group'
import { useTripStore } from './trip'
import { useMaterialStore } from './material'
export const useMarketStore = defineStore('market', () => {
const objectiveStore = useObjectiveStore()
const connectionStore = useConnectionStore()
const memberStore = useMemberStore()
const labelStore = useLabelStore()
const groupStore = useGroupStore()
const tripStore = useTripStore()
const materialStore = useMaterialStore()
function setData(detail: any) {
objectiveStore.setData(detail.step1)
connectionStore.setData(detail.step2)
memberStore.setData(detail.step3)
labelStore.setData(detail.step4)
groupStore.setData(detail.step5)
tripStore.setData(detail.step6)
materialStore.setData(detail.step7)
}
return {
setData,
objectiveStore,
connectionStore,
memberStore,
labelStore,
groupStore,
tripStore,
materialStore
}
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
materials: MaterialState[]
}
export interface MaterialState {
id: string
node1: string
node2: string
type: string
style: string
update_rule: string
desc: string
}
export const useMaterialStore = defineStore('material', {
state: (): State => {
return {
materials: []
}
},
actions: {
setData(data?: State) {
if (!data?.materials) return
this.setMaterials(data.materials)
},
setMaterials(list: MaterialState[]) {
this.materials = list
},
addMaterial(data: Omit<MaterialState, 'id'>) {
this.materials.push({ id: nanoid(4), ...data })
},
updateMaterial(data: MaterialState) {
const index = this.materials.findIndex(item => item.id === data.id)
this.materials[index] = data
},
removeMaterial(id: string) {
this.materials = this.materials.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
member: MemberState
}
export interface MemberState {
sex: string
sex_file: string
source: string
source_file: string
attrs: MemberAttr[]
}
export interface MemberAttr {
id: string
attr_id: string
attr_content: string
attr_file: string
}
export const useMemberStore = defineStore('member', {
state: (): State => {
return {
member: {
sex: '',
sex_file: '',
source: '',
source_file: '',
attrs: []
}
}
},
actions: {
setData(data?: MemberState) {
if (!data) return
this.setMember(data)
},
setMember(data: MemberState) {
this.member = data
},
addAttr(data: Omit<MemberAttr, 'id'>) {
this.member.attrs.push({ id: nanoid(4), ...data })
},
removeAttr(id: string) {
this.member.attrs = this.member.attrs.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
problems: ProblemState[]
objectives: ObjectiveState[]
}
export interface ProblemState {
id: string
content: string
}
export interface ObjectiveState {
id: string
content: string
}
export const useObjectiveStore = defineStore('objective', {
state: (): State => {
return {
problems: [{ id: nanoid(4), content: '' }],
objectives: [{ id: nanoid(4), content: '' }]
}
},
actions: {
setData(data?: State) {
if (!data?.problems) return
this.setProblems(data.problems)
this.setObjectives(data.objectives)
},
setProblems(list: ProblemState[]) {
this.problems = list
},
addProblem(data: Omit<ProblemState, 'id'>) {
this.problems.push({ id: nanoid(4), ...data })
},
updateProblem(data: ProblemState) {
const index = this.problems.findIndex(item => item.id === data.id)
this.problems[index] = data
},
removeProblem(id: string) {
this.problems = this.problems.filter(item => item.id !== id)
},
setObjectives(list: ObjectiveState[]) {
this.objectives = list
},
addObjective(data: Omit<ObjectiveState, 'id'>) {
this.objectives.push({ id: nanoid(4), ...data })
},
updateObjective(data: ObjectiveState) {
const index = this.objectives.findIndex(item => item.id === data.id)
this.objectives[index] = data
},
removeObjective(id: string) {
this.objectives = this.objectives.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import type { Node, Edge } from '@vue-flow/core'
export interface State {
nodes: Node[]
edges: Edge[]
}
export interface ElementState {
id: string
name: string
type: string
}
export const useTripStore = defineStore('trip', {
state: (): State => {
return {
nodes: [
{
id: 'start',
type: 'start',
label: 'Start',
data: { label: 'Start' },
position: { x: 0, y: 0 }
},
{
id: '1',
type: 'custom',
label: '一级旅程节点',
data: { label: '一级旅程节点' },
position: { x: 360, y: 0 }
},
{
id: 'end',
type: 'end',
label: 'End',
data: { label: 'End' },
position: { x: 720, y: 0 }
}
],
edges: [
{
id: 'start->1',
type: 'custom',
source: 'start',
target: '1',
animated: true,
markerEnd: 'arrowclosed'
},
{
id: '1->end',
type: 'custom',
source: '1',
target: 'end',
animated: true,
markerEnd: 'arrowclosed'
}
]
}
},
getters: {
// 一级节点
node1List(state) {
return state.nodes.filter(node => node.type === 'custom')
},
// 二级节点
node2List() {
return this.node1List.reduce((list: Node[], node: Node) => {
const nodes = node.data.nodes?.filter((node: Node) => node.type === 'custom') || []
return list.concat(nodes)
}, [])
}
},
actions: {
setData(data?: State) {
if (!data?.nodes) return
this.setNodes(data.nodes)
this.setEdges(data.edges)
},
setNodes(list: Node[]) {
this.nodes = list
},
setEdges(list: Edge[]) {
this.edges = list
}
}
})
<script setup lang="ts">
<script setup>
import { Select } from '@element-plus/icons-vue'
import { useExperiment } from '../composables/useData'
const { experiment } = useExperiment()
import { getRecords } from '../api'
const typeArr = ['营销背景分析', '营销渠道选择', '用户分析', '用户标签体系设计', '用户精准分群', '自动化营销旅程设计', '营销物料设计', '营销策划报告']
// 列表配置
const listOptions = computed(() => {
return {
data: [{}, {}],
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '步骤名称', prop: 'class_name' },
{ label: '步骤状态', prop: 'name' },
{ label: '开始时间', prop: 'id' },
{ label: '更新时间', prop: 'id' },
{ label: '评分', prop: 'id' },
{ label: '操作', slots: 'table-x', width: 140 }
]
}
})
const listOptions = {
remote: {
httpRequest: getRecords,
callback({ items }) {
return { list: items }
}
},
columns: [
{ label: '序号', type: 'index', width: 60 },
{
label: '步骤名称',
prop: 'type',
computed({ row }) {
return typeArr[row.type - 1]
}
},
{ label: '步骤状态', prop: 'is_complete', slots: 'table-complete' },
{ label: '开始时间', prop: 'start_time' },
{ label: '更新时间', prop: 'update_time' },
{ label: '评分', prop: 'score' },
{ label: '操作', slots: 'table-x', width: 140 }
]
}
const currentRow = ref()
const dialogVisible = ref(false)
// 查看评语
const handleView = row => {
currentRow.value = row
dialogVisible.value = true
}
</script>
<template>
<AppCard>
<el-form label-suffix=":" inline class="info">
<el-form-item label="实验名称">信用卡数字营销</el-form-item>
<el-form-item label="课程名称">数字营销实训课程</el-form-item>
<el-form-item label="指导教师">张三疯</el-form-item>
<el-form-item label="实验学时">16学时</el-form-item>
<el-form-item label="实验名称">{{ experiment.name }}</el-form-item>
<el-form-item label="课程名称">{{ experiment.course_name }}</el-form-item>
<el-form-item label="指导教师">{{ experiment.teacher_name }}</el-form-item>
<el-form-item label="实验学时">{{ experiment.length }}学时</el-form-item>
</el-form>
<el-divider />
<h2 class="h2-title">营销策划</h2>
<AppList v-bind="listOptions">
<template>
<el-button text type="primary">编辑</el-button>
<el-button text type="primary">查看评语</el-button>
<template #table-complete="{ row }">
<el-icon v-if="row.is_complete"><Select color="green" /></el-icon>
</template>
<template #table-x="{ row }">
<el-button text type="primary">
<router-link :to="{ path: '/market/my/update', query: { experiment_id: $route.query.experiment_id, step: row.type } }">编辑</router-link>
</el-button>
<el-button text type="primary" @click="handleView(row)" v-if="row.comment">查看评语</el-button>
</template>
</AppList>
<el-dialog v-model="dialogVisible" title="查看评语" width="600">
<div v-html="currentRow.comment"></div>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space round @click="dialogVisible = false">关闭</el-button>
</el-row>
</template>
</el-dialog>
</AppCard>
</template>
......
<script setup>
import { ElMessage } from 'element-plus'
import { getSteps, updateStep } from '../api'
import { useMarketStore } from '../stores/market'
import { useExperiment } from '../composables/useData'
const { experiment } = useExperiment()
const { setData } = useMarketStore()
const Step1 = defineAsyncComponent(() => import('../components/Step1.vue'))
const Step2 = defineAsyncComponent(() => import('../components/Step2.vue'))
const Step3 = defineAsyncComponent(() => import('../components/Step3.vue'))
const Step4 = defineAsyncComponent(() => import('../components/Step4.vue'))
const Step5 = defineAsyncComponent(() => import('../components/Step5.vue'))
const Step6 = defineAsyncComponent(() => import('../components/Step6.vue'))
const Step7 = defineAsyncComponent(() => import('../components/Step7.vue'))
const Step8 = defineAsyncComponent(() => import('../components/Step8.vue'))
const route = useRoute()
const step = route.query.step ? parseInt(route.query.step) : 1
const activeTab = ref(step)
const detail = reactive({ step1: {}, step2: {}, step3: {}, step4: {}, step5: {}, step6: {}, step7: {}, step8: {} })
async function fetchInfo() {
const res = await getSteps()
try {
const details = res.data.detail.details
Object.assign(detail, JSON.parse(details))
setData(detail)
} catch (error) {
console.log(error)
}
}
onMounted(() => {
fetchInfo()
})
// 提交
async function handleSubmit(data) {
Object.assign(detail, data.detail)
await updateStep({ type: data.type, detail: JSON.stringify(detail) })
ElMessage.success('保存成功')
}
// 下一步
async function handleNext(data, isCheck = false) {
if (!isCheck) await handleSubmit(data)
activeTab.value++
}
</script>
<template>
<AppCard full class="market">
<el-form label-suffix=":" inline class="info">
<el-form-item label="实验名称">{{ experiment.name }}</el-form-item>
<el-form-item label="课程名称">{{ experiment.course_name }}</el-form-item>
<el-form-item label="指导教师">{{ experiment.teacher_name }}</el-form-item>
<el-form-item label="实验学时">{{ experiment.length }}学时</el-form-item>
</el-form>
<el-divider />
<el-tabs v-model="activeTab" stretch class="market-tabs">
<el-tab-pane lazy label="第1步" :name="1">
<Step1 :data="detail.step1" @submit="handleSubmit" @next="handleNext"></Step1>
</el-tab-pane>
<el-tab-pane lazy label="第2步" :name="2">
<Step2 :data="detail.step2" @submit="handleSubmit" @next="handleNext"></Step2>
</el-tab-pane>
<el-tab-pane lazy label="第3步" :name="3">
<Step3 :data="detail.step3" @submit="handleSubmit" @next="handleNext"></Step3>
</el-tab-pane>
<el-tab-pane lazy label="第4步" :name="4">
<Step4 :data="detail.step4" @submit="handleSubmit" @next="handleNext"></Step4>
</el-tab-pane>
<el-tab-pane lazy label="第5步" :name="5">
<Step5 :data="detail.step5" @submit="handleSubmit" @next="handleNext"></Step5>
</el-tab-pane>
<el-tab-pane lazy label="第6步" :name="6">
<Step6 :data="detail.step6" @submit="handleSubmit" @next="handleNext"></Step6>
</el-tab-pane>
<el-tab-pane lazy label="第7步" :name="7">
<Step7 :data="detail.step7" :detail="detail" @submit="handleSubmit" @next="handleNext"></Step7>
</el-tab-pane>
<el-tab-pane lazy label="第8步" :name="8">
<Step8 :data="detail.step8" @submit="handleSubmit" @next="handleNext"></Step8>
</el-tab-pane>
</el-tabs>
</AppCard>
</template>
<style lang="scss">
.market {
.info {
display: flex;
justify-content: space-between;
}
}
.market-tabs {
.el-tabs__header {
margin: 0 auto;
max-width: 1000px;
}
.el-tabs__nav-wrap::after {
display: none;
}
}
.market-step-footer {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 60px 0;
gap: 100px;
.el-button {
width: 100px;
margin: 0;
}
}
</style>
......@@ -42,11 +42,11 @@ const studentMenus: IMenuItem[] = [
}
]
},
// {
// name: '营销策划',
// path: '/market/my',
// icon: markRaw(IconMarket)
// },
{
name: '营销策划',
path: '/market/my',
icon: markRaw(IconMarket)
},
{
name: '用户画像',
path: '/user',
......@@ -170,11 +170,11 @@ const adminMenus: IMenuItem[] = [
}
]
},
// {
// name: '营销策划',
// path: '/market/review',
// icon: markRaw(IconMarket)
// },
{
name: '营销策划',
path: '/market/review',
icon: markRaw(IconMarket)
},
{
name: '用户画像',
path: '/user',
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论