提交 6dfae3f3 authored 作者: lhh's avatar lhh
......@@ -15,6 +15,7 @@
"@vueuse/core": "^10.3.0",
"axios": "^1.5.0",
"blueimp-md5": "^2.19.0",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"echarts-wordcloud": "^2.1.0",
"element-plus": "^2.3.14",
......@@ -2593,9 +2594,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
},
"node_modules/de-indent": {
"version": "1.0.2",
......
......@@ -22,6 +22,7 @@
"@vueuse/core": "^10.3.0",
"axios": "^1.5.0",
"blueimp-md5": "^2.19.0",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"echarts-wordcloud": "^2.1.0",
"element-plus": "^2.3.14",
......
......@@ -106,6 +106,17 @@ export function getMemberEvents() {
}
// 用户属性搜索
export function searchEventAttrs(params?: { search: string; experiment_meta_event_id?: string; experiment_meta_event_attr_id?: string; page?: number; per_page?: number }) {
export function searchEventAttrs(params?: {
search: string
experiment_meta_event_id?: string
experiment_meta_event_attr_id?: string
page?: number
per_page?: number
}) {
return httpRequest.get('/api/lab/v1/experiment/event/search-attributes', { params })
}
// 获取当前实验下可选的人员列表
export function getUserList() {
return httpRequest.get('/api/lab/v1/experiment/analyse/users')
}
<script setup lang="ts">
import { useFullscreen } from '@vueuse/core'
import { FullScreen } from '@element-plus/icons-vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, PieChart, LineChart, PictorialBarChart, FunnelChart, RadarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import 'echarts-wordcloud'
use([
CanvasRenderer,
BarChart,
PieChart,
LineChart,
PictorialBarChart,
FunnelChart,
RadarChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent
])
const props = defineProps<{ title?: string; options?: any; loading?: boolean }>()
const el = ref<HTMLElement | null>(null)
const { isFullscreen, toggle } = useFullscreen(el)
const isEmpty = computed(() => {
if (!props.options) return true
return !Object.keys(props.options)
})
</script>
<template>
<div class="chart-card" ref="el" :class="{ isFullscreen }">
<div class="chart-hd">
<slot name="title">
<h3>{{ props.title }}</h3>
</slot>
<div class="tools">
<slot name="tools"></slot>
<el-tooltip effect="dark" :content="isFullscreen ? '退出全屏' : '全屏'">
<el-icon class="icon-fullscreen" @click="toggle"><FullScreen /></el-icon>
</el-tooltip>
</div>
</div>
<div class="chart-bd" v-loading="loading">
<slot>
<el-empty v-if="isEmpty" />
<v-chart class="chart" :option="options" autoresize ref="chart" v-else />
</slot>
</div>
</div>
</template>
<style lang="scss">
.chart-card {
display: flex;
flex-direction: column;
flex: 1;
background-color: rgba(234, 234, 234, 0.6);
border-radius: 6px;
overflow: hidden;
&.isFullscreen {
.chart {
height: 100%;
}
}
}
.chart-hd {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
padding: 10px 20px;
height: 30px;
color: rgba(0, 0, 0, 0.8);
h3 {
font-weight: bold;
font-size: 16px;
line-height: 30px;
}
.tools {
display: flex;
align-items: center;
}
.icon-fullscreen {
margin-left: 10px;
font-size: 16px;
cursor: pointer;
}
}
.chart-bd {
flex: 1;
background-color: #fff;
margin: 0 20px 20px;
border-radius: 4px;
.chart {
height: 290px;
}
}
</style>
import { getMetaUserAttrList, getMetaEventList, getTagList, getConnectionList } from '@/api/base'
import { getMetaUserAttrList, getMetaEventList, getTagList, getConnectionList, getUserList } from '@/api/base'
// 用户属性类型
export interface AttrType {
......@@ -93,3 +93,32 @@ export function useConnection() {
})
return { fetchConnectionList, connectionList }
}
// 所有成员
export interface UserType {
sso_id: string
name: string
pinyin: number
}
const userList = ref<UserType[]>([])
export function useUser() {
const [me] = userList.value
const userValue = ref(me?.sso_id)
async function fetchUserList() {
const res = await getUserList()
let { me, students, teachers } = res.data.items
me = { ...me, role: 'me' }
students = students.map((item: any) => {
return { ...item, role: 'student' }
})
teachers = teachers.map((item: any) => {
return { ...item, role: 'teacher' }
})
userValue.value = me.sso_id
userList.value = [me, ...teachers, ...students]
}
onMounted(() => {
if (!userList.value?.length) fetchUserList()
})
return { fetchUserList, userList, userValue }
}
import httpRequest from '@/utils/axios'
// 获取实验详情
export function getExperiment() {
return httpRequest.get('/api/lab/v1/experiment/once/experiment')
}
// 获取实验下的所有事件
export function getEventList() {
return httpRequest.get('/api/lab/v1/experiment/analyse/events')
}
// 事件行为统计
export function getEventActionList(data: { soo_id: string; event_ids: Array<string>; start_date: string; end_date: string }) {
return httpRequest.post('/api/lab/v1/experiment/analyse/event-action-statistics', data)
}
// 事件行为数量走势统计
export function getEventActionTrendList(data: { soo_id: string; event_ids: Array<string>; start_date: string; end_date: string }) {
return httpRequest.post('/api/lab/v1/experiment/analyse/event-action-date-statistics', data)
}
// 事件用户人数统计
export function getEventMemberList(data: { soo_id: string; event_ids: Array<string>; start_date: string; end_date: string }) {
return httpRequest.post('/api/lab/v1/experiment/analyse/event-member-statistics', data)
}
// 事件用户人数统计
export function getEventTimeList(data: { soo_id: string; event_ids: Array<string>; start_date: string; end_date: string }) {
return httpRequest.post('/api/lab/v1/experiment/analyse/event-time-statistics', data)
}
import { getEventList } from '../api'
export interface EventType {
id: string
name: string
english_name: string
}
const eventList = ref<EventType[]>([])
export function useEvent() {
const eventValues = ref([])
async function fetchEventList() {
const res = await getEventList()
eventList.value = res.data.items
}
onMounted(() => {
if (!eventList.value?.length) fetchEventList()
})
return { fetchEventList, eventList, eventValues }
}
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/analyze/event',
component: Layout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
}
]
export { routes }
<script setup>
import ChartCard from '@/components/ChartCard.vue'
import { DataLine } from '@element-plus/icons-vue'
import { useUser } from '@/composables/useAllData'
import { useEvent } from '../composables/useEvent'
import * as api from '../api'
const { userValue, userList } = useUser()
const { eventValues, eventList } = useEvent()
const date = ref('')
const info = ref()
async function fetchInfo() {
const res = await api.getExperiment()
info.value = res.data.detail
}
onMounted(fetchInfo)
async function handleStart() {
fetchEventAction()
fetchEventActionTrend()
fetchEventMember()
fetchEventTime()
}
const loading = computed(() => {
return loading1.value || loading2.value || loading3.value || loading4.value
})
const params = computed(() => {
const [startDate, endDate] = date.value || []
return { sso_id: userValue.value, event_ids: eventValues.value, start_date: startDate, end_date: endDate }
})
// 事件行为分布
const loading1 = ref(false)
const eventAction = ref([])
async function fetchEventAction() {
if (!userValue.value) return
loading1.value = true
const res = await api.getEventActionList(params.value)
eventAction.value = res.data.items
loading1.value = false
}
const eventActionOption = computed(() => {
if (!eventAction.value.length) return
const value = eventAction.value.map(item => item.total)
const max = Math.max(...value)
return {
grid: { left: '5%', top: '5%', right: '5%', bottom: '5%', containLabel: true },
tooltip: { trigger: 'axis' },
radar: { indicator: eventAction.value.map(item => ({ name: item.group_name, max })) },
series: [
{
name: '事件',
type: 'radar',
tooltip: { trigger: 'item' },
label: { show: true, position: 'top' },
data: [{ value }]
}
]
}
})
// 事件行为数量走势
const loading2 = ref(false)
const eventActionTrend = ref([])
async function fetchEventActionTrend() {
if (!userValue.value) return
loading2.value = true
const res = await api.getEventActionTrendList(params.value)
eventActionTrend.value = res.data.items
loading2.value = false
}
const eventActionTrendOption = computed(() => {
if (!eventActionTrend.value.length) return
const series = eventActionTrend.value.map(group => {
return {
name: group.event_name,
type: 'line',
smooth: true,
label: { show: true, position: 'top' },
data: group.items.map(item => item.total)
}
})
const [first = {}] = eventActionTrend.value || []
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: { trigger: 'axis' },
legend: {
bottom: '10',
data: eventActionTrend.value.map(item => item.event_name)
},
xAxis: {
type: 'category',
boundaryGap: ['20%', '20%'],
data: first.items.map(item => item.group_name)
},
yAxis: { type: 'value' },
series
}
})
// 事件用户分布
const loading3 = ref(false)
const eventMember = ref([])
async function fetchEventMember() {
if (!userValue.value) return
loading3.value = true
const res = await api.getEventMemberList(params.value)
eventMember.value = res.data.items
loading3.value = false
}
const eventMemberOption = computed(() => {
if (!eventMember.value.length) return
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
// axisLabel: { interval: 0, rotate: 30 },
data: eventMember.value.map(item => item.group_name)
},
yAxis: {
type: 'value'
},
series: [
{
name: '用户',
type: 'bar',
label: { show: true, position: 'top' },
itemStyle: { borderRadius: 2 },
data: eventMember.value.map(item => item.total)
}
]
}
})
// 事件发生时间分析
const loading4 = ref(false)
const eventTime = ref([])
async function fetchEventTime() {
if (!userValue.value) return
loading4.value = true
const res = await api.getEventTimeList(params.value)
eventTime.value = res.data.items
loading4.value = false
}
const eventTimeOption = computed(() => {
if (!eventTime.value.length) return
const series = eventTime.value.map(group => {
return {
name: group.event_name,
type: 'line',
// label: { show: true, position: 'top' },
data: group.items.map(item => item.total)
}
})
const [first = {}] = eventTime.value || []
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: { trigger: 'axis' },
legend: {
bottom: '10',
data: eventTime.value.map(item => item.event_name)
},
xAxis: {
type: 'category',
boundaryGap: ['20%', '20%'],
data: first.items.map(item => item.group_name)
},
yAxis: { type: 'value' },
series
}
})
</script>
<template>
<AppCard title="事件分析">
<el-form inline label-suffix=":" v-if="info">
<el-form-item label="实验名称">{{ info.name }}</el-form-item>
<el-form-item label="请选择学生/老师">
<el-select v-model="userValue" filterable>
<el-option v-for="item in userList" :label="item.name" :value="item.sso_id" :key="item.sso_id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="时间区间">
<el-date-picker type="monthrange" v-model="date" value-format="YYYY-MM"></el-date-picker>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="DataLine" :loading="loading" @click="handleStart">分析</el-button>
</el-form-item>
<el-form-item>
<el-checkbox-group v-model="eventValues">
<el-checkbox v-for="item in eventList" :key="item.id" :label="item.id">{{ item.name }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<el-divider />
<div class="row">
<ChartCard title="事件行为分布" :options="eventActionOption" :loading="loading1"></ChartCard>
<ChartCard title="事件行为数量走势" :options="eventActionTrendOption" :loading="loading2" style="flex: 2.5"></ChartCard>
</div>
<div class="row">
<ChartCard title="事件用户分布" :options="eventMemberOption" :loading="loading3"></ChartCard>
<ChartCard title="事件发生时间分析" :options="eventTimeOption" :loading="loading4" style="flex: 2.5"></ChartCard>
</div>
</AppCard>
</template>
<style lang="scss" scoped>
.total {
font-size: 16px;
font-weight: bold;
color: var(--main-color);
}
.row {
display: flex;
gap: 20px;
& + .row {
margin-top: 20px;
}
}
</style>
import httpRequest from '@/utils/axios'
// 获取实验详情
export function getExperiment() {
return httpRequest.get('/api/lab/v1/experiment/once/experiment')
}
// 获取热门标签
export function getHotTags(params: { sso_id: string; number?: number }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/hot-tags', { params })
}
// 标签人数分析(TOP5)
export function getTagTop(params: { sso_id: string; number?: number }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/tag-top', { params })
}
// 用户标签数分析(TOP10)
export function getMemberTagTop(params: { sso_id: string; number?: number }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/member-tag-top', { params })
}
// 热门群组
export function getHotGroups(params: { sso_id: string; number?: number }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/hot-groups', { params })
}
// 群组人数分析(TOP5)
export function getGroupTop(params: { sso_id: string; number?: number }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/group-top', { params })
}
// 用户群组数分析(TOP10)
export function getMemberGroupTop(params: { sso_id: string; number?: number }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/member-group-top', { params })
}
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/analyze/label',
component: Layout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
}
]
export { routes }
<script setup>
import ChartCard from '@/components/ChartCard.vue'
import { DataLine } from '@element-plus/icons-vue'
import { useUser } from '@/composables/useAllData'
import * as api from '../api'
const { userValue, userList } = useUser()
const info = ref()
async function fetchInfo() {
const res = await api.getExperiment()
info.value = res.data.detail
}
onMounted(fetchInfo)
watch(userValue, () => {
handleStart()
})
async function handleStart() {
fetchLabelHot()
fetchLabelTop()
fetchLabelMemberTop()
fetchGroupHot()
fetchGroupTop()
fetchGroupMemberTop()
}
const loading = computed(() => {
return loading1.value || loading2.value || loading3.value || loading4.value || loading5.value || loading6.value
})
// 热门标签
const loading1 = ref(false)
const labelHot = ref([])
async function fetchLabelHot() {
if (!userValue.value) return
loading1.value = true
const res = await api.getHotTags({ sso_id: userValue.value })
labelHot.value = res.data.items
loading1.value = false
}
const labelHotOption = computed(() => {
if (!labelHot.value.length) return
return {
grid: { left: '10%', top: '10%', right: '10%', bottom: '10%', containLabel: true },
tooltip: {},
series: [
{
type: 'wordCloud',
gridSize: 15,
sizeRange: [12, 50],
rotationRange: [0, 0],
shape: 'circle',
width: '100%',
height: '100%',
drawOutOfBound: false,
textStyle: {
color: function () {
return 'rgb(' + [Math.round(Math.random() * 160), Math.round(Math.random() * 160), Math.round(Math.random() * 160)].join(',') + ')'
}
},
emphasis: {
focus: 'self',
textStyle: {
textShadowBlur: 10,
textShadowColor: '#333'
}
},
data: labelHot.value.map(item => {
return { name: item.group_name, value: item.total }
})
}
]
}
})
// 标签用户分布Top5
const loading2 = ref(false)
const labelTop = ref([])
async function fetchLabelTop() {
if (!userValue.value) return
loading2.value = true
const res = await api.getTagTop({ sso_id: userValue.value })
labelTop.value = res.data.items
loading2.value = false
}
const labelTopOption = computed(() => {
if (!labelTop.value.length) return
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
axisLabel: { interval: 0 },
data: labelTop.value.map(item => item.group_name)
},
yAxis: {
type: 'value'
},
series: [
{
name: '用户',
type: 'bar',
label: { show: true, position: 'top' },
data: labelTop.value.map(item => item.total)
}
]
}
})
// 用户标签Top10
const loading3 = ref(false)
const labelMemberTop = ref([])
async function fetchLabelMemberTop() {
if (!userValue.value) return
loading3.value = true
const res = await api.getMemberTagTop({ sso_id: userValue.value })
labelMemberTop.value = res.data.items
loading3.value = false
}
const labelMemberTopOption = computed(() => {
if (!labelMemberTop.value.length) return
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'axis'
},
xAxis: { type: 'value' },
yAxis: {
type: 'category',
inverse: true,
data: labelMemberTop.value.map(item => item.group_name)
},
series: [
{
name: '标签',
type: 'bar',
label: { show: true, position: 'right' },
data: labelMemberTop.value.map(item => item.total)
}
]
}
})
// 热门群组
const loading4 = ref(false)
const groupHot = ref([])
async function fetchGroupHot() {
if (!userValue.value) return
loading4.value = true
const res = await api.getHotGroups({ sso_id: userValue.value })
groupHot.value = res.data.items
loading4.value = false
}
const groupHotOption = computed(() => {
if (!groupHot.value.length) return
return {
grid: { left: '10%', top: '10%', right: '10%', bottom: '10%', containLabel: true },
tooltip: {},
series: [
{
type: 'wordCloud',
gridSize: 15,
sizeRange: [12, 50],
rotationRange: [0, 0],
shape: 'circle',
width: '100%',
height: '100%',
drawOutOfBound: false,
textStyle: {
color: function () {
return 'rgb(' + [Math.round(Math.random() * 160), Math.round(Math.random() * 160), Math.round(Math.random() * 160)].join(',') + ')'
}
},
emphasis: {
focus: 'self',
textStyle: {
textShadowBlur: 10,
textShadowColor: '#333'
}
},
data: groupHot.value.map(item => {
return { name: item.group_name, value: item.total }
})
}
]
}
})
// 群组用户分布Top5
const loading5 = ref(false)
const groupTop = ref([])
async function fetchGroupTop() {
if (!userValue.value) return
loading5.value = true
const res = await api.getGroupTop({ sso_id: userValue.value })
groupTop.value = res.data.items
loading5.value = false
}
const groupTopOption = computed(() => {
if (!groupTop.value.length) return
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
// axisLabel: { interval: 0, rotate: 30 },
data: groupTop.value.map(item => item.group_name)
},
yAxis: {
type: 'value'
},
series: [
{
name: '用户',
type: 'bar',
label: { show: true, position: 'top' },
itemStyle: { borderRadius: 2 },
data: groupTop.value.map(item => item.total)
}
]
}
})
// 用户群组Top10
const loading6 = ref(false)
const groupMemberTop = ref([])
async function fetchGroupMemberTop() {
if (!userValue.value) return
loading6.value = true
const res = await api.getMemberGroupTop({ sso_id: userValue.value })
groupMemberTop.value = res.data.items
loading6.value = false
}
const groupMemberTopOption = computed(() => {
if (!groupMemberTop.value.length) return
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'axis'
},
xAxis: { type: 'value' },
yAxis: {
type: 'category',
inverse: true,
data: groupMemberTop.value.map(item => item.group_name)
},
series: [
{
name: '群组',
type: 'bar',
label: { show: true, position: 'right' },
data: groupMemberTop.value.map(item => item.total)
}
]
}
})
</script>
<template>
<AppCard title="标签群组分析">
<el-form inline label-suffix=":">
<el-form-item label="实验名称">{{ info?.name }}</el-form-item>
<el-form-item label="请选择学生/老师">
<el-select v-model="userValue" filterable>
<el-option v-for="item in userList" :label="item.name" :value="item.sso_id" :key="item.sso_id"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="DataLine" :loading="loading" @click="handleStart">分析</el-button>
</el-form-item>
</el-form>
<el-divider />
<div class="row">
<ChartCard title="热门标签" :options="labelHotOption" :loading="loading1"></ChartCard>
<ChartCard title="标签用户分布Top5" :options="labelTopOption" :loading="loading2"></ChartCard>
<ChartCard title="用户标签Top10" :options="labelMemberTopOption" :loading="loading3"></ChartCard>
</div>
<div class="row">
<ChartCard title="热门群组" :options="groupHotOption" :loading="loading4"></ChartCard>
<ChartCard title="群组用户分布Top5" :options="groupTopOption" :loading="loading5"></ChartCard>
<ChartCard title="用户群组Top10" :options="groupMemberTopOption" :loading="loading6"></ChartCard>
</div>
</AppCard>
</template>
<style lang="scss" scoped>
.total {
font-size: 16px;
font-weight: bold;
color: var(--main-color);
}
.row {
display: flex;
gap: 20px;
& + .row {
margin-top: 20px;
}
}
</style>
import httpRequest from '@/utils/axios'
// 获取实验详情
export function getExperiment() {
return httpRequest.get('/api/lab/v1/experiment/once/experiment')
}
// 获取实验下的所有事件
export function getEventList() {
return httpRequest.get('/api/lab/v1/experiment/analyse/events')
}
// 事件行为分布
export function getEventAction(params: { start_date: string; end_date: string; sso_id: string; event_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/event-marketing-statistics', { params })
}
// 实验营销事件漏斗分析
export function getEventMarketing(params: { start_date: string; end_date: string; sso_id: string; event_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/event-funnel-statistics', { params })
}
// 获取实验营销事件漏斗配置
export function getEventFunnel() {
return httpRequest.get('/api/lab/v1/experiment/analyse/event-funnels')
}
// 保存实验营销事件漏斗配置
export function saveEventFunnel(data: { rules: Array<{ id: string; event_id: string; sort: number; name: string }> }) {
return httpRequest.post('/api/lab/v1/experiment/analyse/save-event-funnels', data)
}
// 事件行为分布
export function getMemberList(params: { start_date: string; end_date: string; sso_id: string; event_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/member-retention-statistics', { params })
}
<script setup>
import { SemiSelect } from '@element-plus/icons-vue'
import { useEvent } from '../composables/useEvent'
import { getEventFunnel, saveEventFunnel } from '../api'
const { eventList } = useEvent()
const rules = ref([])
async function fetchConfig() {
const res = await getEventFunnel()
rules.value = res.data.items
}
onMounted(() => {
fetchConfig()
})
function handleAdd() {
rules.value.push({ id: '0', event_id: '', sort: 0, name: '' })
}
function handleRemove(index) {
rules.value.splice(index, 1)
}
async function handelSubmit() {
const paramsRules = rules.value.map((item, index) => {
return { ...item, sort: index + 1 }
})
await saveEventFunnel({ rules: paramsRules })
}
</script>
<template>
<el-dialog title="创建营销漏斗分析" width="600">
<el-button type="primary" @click="handleAdd">添加漏斗步数</el-button>
<div class="rule-item" v-for="(item, index) in rules" :key="index">
<el-button type="primary">{{ index + 1 }}</el-button>
<el-input v-model="item.name" :placeholder="'漏斗第' + (index + 1) + '步名字'"></el-input>
<el-select placeholder="请选择事件" v-model="item.event_id">
<el-option v-for="item in eventList" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
<el-button :icon="SemiSelect" @click="handleRemove(index)"></el-button>
</div>
<template #footer>
<el-button type="primary" @click="handelSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.rule-item {
margin: 10px 0;
gap: 10px;
display: flex;
}
</style>
import { getEventList } from '../api'
export interface EventType {
id: string
name: string
english_name: string
}
const eventList = ref<EventType[]>([])
export function useEvent() {
const eventValue = ref('')
async function fetchEventList() {
const res = await getEventList()
eventList.value = res.data.items
}
onMounted(() => {
if (!eventList.value?.length) fetchEventList()
})
return { fetchEventList, eventList, eventValue }
}
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/analyze/marketing',
component: Layout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
}
]
export { routes }
<script setup>
import ChartCard from '@/components/ChartCard.vue'
import { DataLine } from '@element-plus/icons-vue'
import { useUser } from '@/composables/useAllData'
import { useEvent } from '../composables/useEvent'
import * as api from '../api'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
const MarketingDialog = defineAsyncComponent(() => import('../components/MarketingDialog.vue'))
const { userValue, userList } = useUser()
const info = ref()
async function fetchInfo() {
const res = await api.getExperiment()
info.value = res.data.detail
}
onMounted(fetchInfo)
async function handleStart() {
fetchEventAction()
fetchMarketing()
fetchUser()
}
const loading = computed(() => {
return loading1.value || loading2.value || loading3.value
})
const { eventValue, eventList } = useEvent()
// 事件行为分析
const loading1 = ref(false)
const eventAction = ref()
const eventActionDate = ref([])
async function fetchEventAction() {
const [startDate, endDate] = eventActionDate.value
if (!userValue.value || !eventValue.value || !startDate || !endDate) return
loading1.value = true
const res = await api.getEventAction({ sso_id: userValue.value, start_date: startDate, end_date: endDate, event_id: eventValue.value })
eventAction.value = res.data
loading1.value = false
}
const eventActionOption = computed(() => {
if (!eventAction.value) return
return {
grid: { left: '80', top: '40', right: '40', bottom: '60' },
tooltip: { trigger: 'axis' },
legend: {
bottom: '10',
data: ['行为数量', '用户数量']
},
xAxis: {
type: 'category',
boundaryGap: ['20%', '20%'],
data: eventAction.value.event_action_items.map(item => {
return item.group_name + '月'
})
},
yAxis: { type: 'value' },
series: [
{
name: '行为数量',
type: 'line',
smooth: true,
label: { show: true, position: 'top' },
data: eventAction.value.event_action_items.map(item => item.total)
},
{
name: '用户数量',
type: 'line',
smooth: true,
label: { show: true, position: 'top' },
data: eventAction.value.event_member_items.map(item => item.total)
}
]
}
})
// 营销漏斗分析
const dialogVisible = ref(false)
const loading2 = ref(false)
const marketing = ref([])
const marketingDate = ref([])
async function fetchMarketing() {
const [startDate, endDate] = marketingDate.value
if (!userValue.value || !startDate || !endDate) return
loading2.value = true
const res = await api.getEventMarketing({ sso_id: userValue.value, start_date: startDate, end_date: endDate })
marketing.value = res.data.items
loading2.value = false
}
const marketingOption = computed(() => {
return marketing.value.map((item, index, items) => {
// 总转换率
let rate = 0
if (index === 0) {
rate = ((items[items.length - 1]?.total / item.total) * 100).toFixed(2)
} else {
rate = ((item.total / (items[index - 1]?.total || 0)) * 100).toFixed(2)
}
if (isNaN(rate)) rate = 0
return { ...item, rate }
})
})
// 用户留存分析
const loading3 = ref(false)
const user = ref([])
const userStartDate = ref('')
const userEndDate = computed(() => {
return dayjs(userStartDate.value).add(7, 'day').format('YYYY-MM-DD')
})
async function fetchUser() {
if (!userValue.value || !userStartDate.value || !userEndDate.value) return
loading3.value = true
const res = await api.getMemberList({ sso_id: userValue.value, start_date: userStartDate.value, end_date: userEndDate.value })
user.value = res.data.items
loading3.value = false
}
function format(date) {
return dayjs(date).format('MM-DD (dd)')
}
</script>
<template>
<AppCard title="营销分析">
<el-form inline label-suffix=":">
<el-form-item label="实验名称">{{ info?.name }}</el-form-item>
<el-form-item label="请选择学生/老师">
<el-select v-model="userValue" filterable>
<el-option v-for="item in userList" :label="item.name" :value="item.sso_id" :key="item.sso_id"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="DataLine" :loading="loading" @click="handleStart">分析</el-button>
</el-form-item>
</el-form>
<el-divider />
<el-row :gutter="20">
<el-col :span="16">
<ChartCard title="事件行为分析" :options="eventActionOption" :loading="loading1">
<template #tools>
<el-date-picker
type="monthrange"
v-model="eventActionDate"
value-format="YYYY-MM"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 200px"
@change="fetchEventAction"></el-date-picker>
<p style="white-space: nowrap; margin: 0 5px 0 10px">请选择事件</p>
<el-select v-model="eventValue" placeholder="请选择事件" style="width: 140px" @change="fetchEventAction">
<el-option v-for="item in eventList" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</template>
</ChartCard>
<ChartCard title="用户留存分析" style="margin-top: 20px">
<template #tools>
<el-date-picker
v-model="userStartDate"
value-format="YYYY-MM-DD"
start-placeholder="开始时间"
end-placeholder="结束时间"
@change="fetchUser"></el-date-picker>
</template>
<el-table :data="user" border height="290" :headerCellStyle="{ color: '#fff', backgroundColor: '#68bbc4' }">
<el-table-column prop="date" label="总体" align="center">
<template #default="{ row }">
<p>{{ format(row.date) }}</p>
</template>
</el-table-column>
<el-table-column prop="total" label="总人数" align="center">
<template #default="{ row }">
<p>{{ row.total }}</p>
</template>
</el-table-column>
<el-table-column label="当日" align="center">
<template #default="{ row }">
<p>{{ row.day0 }}</p>
<p>{{ row.day0_rate }}%</p>
</template>
</el-table-column>
<template v-for="index in 7" :key="index">
<el-table-column :label="index === 1 ? '次日' : '第' + index + '日'" align="center">
<template #default="{ row }">
<p>{{ row['day' + index] }}</p>
<p>{{ row['day' + index + '_rate'] }}%</p>
</template>
</el-table-column>
</template>
</el-table>
</ChartCard>
</el-col>
<el-col :span="8">
<ChartCard title="营销漏斗分析" style="height: 100%">
<template #tools>
<el-date-picker
type="monthrange"
v-model="marketingDate"
value-format="YYYY-MM"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 200px"
@change="fetchMarketing"></el-date-picker>
<el-button text @click="dialogVisible = true">编辑</el-button>
</template>
<div class="card">
<template v-if="marketingOption.length">
<div class="item" v-for="(item, index) in marketingOption" :key="index">
<!-- <div class="item-left">{{ item.group_name }}</div> -->
<div class="item-center">
<p v-if="index === 0">总转换率 {{ item.rate }}%</p>
<p v-else>{{ item.rate }}%</p>
<div class="circle">{{ item.group_name }}</div>
</div>
<div class="item-right">{{ item.total }}</div>
</div>
</template>
<el-empty v-else />
</div>
</ChartCard>
</el-col>
</el-row>
<MarketingDialog v-model="dialogVisible" v-if="dialogVisible"></MarketingDialog>
</AppCard>
</template>
<style lang="scss" scoped>
.row {
display: flex;
gap: 20px;
& + .row {
margin-top: 20px;
}
}
.card {
margin: 0 20px 20px;
background-color: #fff;
padding: 20px;
min-height: 410px;
border-radius: 4px;
.item {
min-height: 40px;
display: flex;
align-items: flex-end;
border-bottom: 1px solid #ebeef5;
.item-left {
padding: 0 10px;
max-width: 100px;
font-size: 14px;
color: #999;
text-align: center;
}
.item-center {
position: relative;
margin: 40px 0 0;
flex: 1;
height: 40px;
.circle {
width: 100%;
height: 100%;
font-size: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
background-color: #1b41a0;
}
p {
position: absolute;
color: #000;
top: -30px;
left: 50%;
transform: translateX(-50%);
}
}
&:nth-child(1) {
.circle {
clip-path: polygon(0 0, 100% 0%, 95% 100%, 5% 100%);
}
}
&:nth-child(2) {
.circle {
clip-path: polygon(5% 0, 95% 0%, 90% 100%, 10% 100%);
}
}
&:nth-child(3) {
.circle {
clip-path: polygon(10% 0, 90% 0%, 85% 100%, 15% 100%);
}
}
&:nth-child(4) {
.circle {
clip-path: polygon(15% 0, 85% 0%, 80% 100%, 20% 100%);
}
}
&:nth-child(5) {
.circle {
clip-path: polygon(20% 0, 80% 0%, 75% 100%, 25% 100%);
}
}
.item-right {
font-size: 14px;
color: #999;
text-align: right;
}
}
}
</style>
......@@ -5,51 +5,27 @@ export function getExperiment() {
return httpRequest.get('/api/lab/v1/experiment/once/experiment')
}
// 获取成员性别
export function getMemberGender() {
return httpRequest.get('/api/lab/v1/experiment/once/member-gender-statistics')
// 获取用户性别
export function getMemberGender(params: { sso_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/gender', { params })
}
// 获取成员年龄
export function getMemberAge() {
return httpRequest.get('/api/lab/v1/experiment/once/member-age-statistics')
// 获取用户来源
export function getMemberConnections(params: { sso_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/connections', { params })
}
// 获取成员信用卡等级
export function getCardLevel() {
return httpRequest.get('/api/lab/v1/experiment/once/member-credit-card-level-statistics')
// 获取用户状态
export function getMemberStatus(params: { sso_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/member-status', { params })
}
// 获取成员月收入
export function getMemberMonthly() {
return httpRequest.get('/api/lab/v1/experiment/once/member-monthly-income-statistics')
// 获取用户元数据属性
export function getMemberAttrs(params: { sso_id: string; member_meta_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/member-attrs', { params })
}
// 获取标签
export function getLabel() {
return httpRequest.get('/api/lab/v1/experiment/once/tag-member-statistics')
}
// 获取群组
export function getGroup() {
return httpRequest.get('/api/lab/v1/experiment/once/group-member-statistics')
}
// 获取事件
export function getEvent() {
return httpRequest.get('/api/lab/v1/experiment/once/event-member-statistics')
}
// 获取信用卡开卡
export function getCardOpen() {
return httpRequest.get('/api/lab/v1/experiment/once/credit-card-open-statistics')
}
// 获取信用卡积分
export function getCardIntegral() {
return httpRequest.get('/api/lab/v1/experiment/once/credit-card-integral-statistics')
}
// 获取信用卡分期还款
export function getCardInstallment() {
return httpRequest.get('/api/lab/v1/experiment/once/credit-card-installment-statistics')
// 获取用户元数据属性
export function getMemberMetaAttrs() {
return httpRequest.get('/api/lab/v1/experiment/analyse/member-meta-attrs')
}
<script setup>
import ChartCard from '@/components/ChartCard.vue'
import { getMemberAttrs } from '../api'
import { useMetaAttr } from '../composables/useMetaAttr'
const props = defineProps({ ssoId: String })
const { metaAttrList, metaAttrValue } = useMetaAttr()
const loading = ref(false)
const attrs = ref([])
async function fetchAttrs() {
if (!props.ssoId || !metaAttrValue.value) return
loading.value = true
const res = await getMemberAttrs({ sso_id: props.ssoId, member_meta_id: metaAttrValue.value })
attrs.value = res.data.items.map(item => {
return { name: item.group_name, value: item.total }
})
loading.value = false
}
watchEffect(() => {
fetchAttrs()
})
const chartTypeList = [
{ value: 1, label: '柱状图' },
{ value: 2, label: '线状图' },
{ value: 3, label: '面积图' },
{ value: 4, label: '饼状图' },
{ value: 5, label: '环形图' }
]
const chartType = ref(1)
const options = computed(() => {
if (!attrs.value.length) return
if (chartType.value === 1) {
// 柱状图
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: attrs.value.map(item => item.name)
},
yAxis: { type: 'value' },
series: [
{
name: '用户',
type: 'bar',
label: { show: true, position: 'top' },
data: attrs.value.map(item => item.value)
}
]
}
} else if (chartType.value === 2) {
// 线状图
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: attrs.value.map(item => item.name)
},
yAxis: { type: 'value' },
series: [
{
name: '用户',
type: 'line',
label: { show: true, position: 'top' },
data: attrs.value.map(item => item.value)
}
]
}
} else if (chartType.value === 3) {
// 面积图
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
}
},
xAxis: {
type: 'category',
boundaryGap: false,
data: attrs.value.map(item => item.name)
},
yAxis: { type: 'value' },
series: [
{
name: '用户',
type: 'line',
areaStyle: {},
label: { show: true, position: 'top' },
data: attrs.value.map(item => item.value)
}
]
}
} else if (chartType.value === 4) {
return {
tooltip: { trigger: 'item', formatter: '{b}: {c}<br />{d}%' },
series: [
{
type: 'pie',
label: { formatter: '{b}\n{d}%' },
itemStyle: { borderRadius: 6 },
radius: [0, '70%'],
data: attrs.value
}
]
}
} else {
return {
tooltip: { trigger: 'item', formatter: '{b}: {c}<br />{d}%' },
series: [
{
type: 'pie',
label: { formatter: '{b}\n{d}%' },
itemStyle: { borderRadius: 6 },
radius: ['40%', '70%'],
data: attrs.value
}
]
}
}
})
</script>
<template>
<ChartCard :options="options" :loading="loading">
<template #title>
<el-row justify="space-between" style="flex: 1">
<el-select v-model="metaAttrValue" filterable>
<el-option v-for="item in metaAttrList" :label="item.name" :value="item.id" :key="item.id"></el-option>
</el-select>
<el-select v-model="chartType" style="width: 86px">
<el-option v-for="item in chartTypeList" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-row>
</template>
</ChartCard>
</template>
import { getMemberMetaAttrs } from '../api'
// 所有成员
export interface UserType {
id: string
name: string
english_name: string
type: string
format: string
}
const metaAttrList = ref<UserType[]>([])
export function useMetaAttr() {
const metaAttrValue = ref('')
async function fetchMetaAttrList() {
const res = await getMemberMetaAttrs()
metaAttrList.value = res.data.items
}
onMounted(() => {
if (!metaAttrList.value?.length) fetchMetaAttrList()
})
return { fetchMetaAttrList, metaAttrList, metaAttrValue }
}
<script setup>
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, PieChart, LineChart, PictorialBarChart, FunnelChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import ChartCard from '@/components/ChartCard.vue'
import UserChart from '../components/UserChart.vue'
import { DataLine } from '@element-plus/icons-vue'
import { useUser } from '@/composables/useAllData'
import { useMapStore } from '@/stores/map'
import { getNameByValue } from '@/utils/dictionary'
import * as api from '../api'
use([CanvasRenderer, BarChart, PieChart, LineChart, PictorialBarChart, FunnelChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const connectionTypeList = useMapStore().getMapValuesByKey('experiment_connection_type')
const statusList = useMapStore().getMapValuesByKey('system_status')
const { userValue, userList } = useUser()
const info = ref()
async function fetchInfo() {
const res = await api.getExperiment()
......@@ -15,27 +18,38 @@ async function fetchInfo() {
}
onMounted(fetchInfo)
watch(userValue, () => {
handleStart()
})
async function handleStart() {
fetchGender()
fetchConnections()
fetchStatus()
}
const loading = computed(() => {
return loading1.value || loading2.value || loading3.value
})
// 用户性别分布
const gender = reactive({ 1: 0, 2: 0 })
const loading1 = ref(false)
const gender = ref([])
const userTotal = ref(0)
async function fetchGender() {
const res = await api.getMemberGender()
if (!userValue.value) return
loading1.value = true
const res = await api.getMemberGender({ sso_id: userValue.value })
gender.value = res.data.items
userTotal.value = res.data.total
Object.assign(
gender,
res.data.items.reduce((result, item) => {
return { ...result, [item.group_name]: parseInt(item.total) }
}, {})
)
loading1.value = false
}
onMounted(fetchGender)
const man =
'path://M21.696 10.368c-0.032-1.888-1.344-3.136-3.2-3.136h-5.12c-1.792 0.032-3.104 1.344-3.104 3.136v6.4c0 0.704 0.48 1.216 1.12 1.248 0.736 0 1.248-0.48 1.248-1.248v-5.6c0-0.16 0.096-0.32 0.16-0.48 0.064 0.16 0.16 0.32 0.16 0.48v17.568c0.032 0.544 0.384 1.024 0.896 1.184 0.992 0.32 1.856-0.32 1.888-1.344v-8.736c0-0.192-0.096-0.512 0.256-0.512 0.32 0 0.224 0.32 0.224 0.512 0 2.88 0 5.728 0.032 8.608 0 0.32 0.064 0.672 0.224 0.928 0.288 0.544 0.96 0.736 1.6 0.544 0.576-0.16 0.96-0.64 0.96-1.312v-17.408c0-0.16 0.096-0.32 0.128-0.48 0.064 0.16 0.16 0.32 0.16 0.48v5.472c0 0.416 0.096 0.8 0.48 1.088 0.384 0.256 0.8 0.32 1.216 0.096 0.512-0.224 0.672-0.672 0.672-1.184 0.015-0.938 0.024-2.044 0.024-3.152s-0.009-2.214-0.026-3.319l0.002 0.167zM15.968 6.912c1.408 0 2.464-1.056 2.464-2.464 0-1.344-1.088-2.432-2.432-2.432s-2.496 1.12-2.464 2.464c0 1.344 1.088 2.4 2.432 2.432z'
const woman =
'path://M22.784 16.512c-0.032-0.256-0.096-0.512-0.16-0.768-0.384-2.048-0.832-4.096-1.216-6.144-0.224-1.248-1.44-2.336-2.688-2.336-0.8-0.015-1.743-0.024-2.688-0.024s-1.888 0.009-2.829 0.026l0.141-0.002c-0.16 0-0.352 0-0.512 0.032-1.312 0.384-2.048 1.248-2.304 2.624-0.384 2.176-0.864 4.32-1.28 6.496-0.16 0.736 0.224 1.376 0.864 1.504 0.672 0.128 1.248-0.256 1.408-0.992l1.216-5.92c0.032-0.128 0.096-0.224 0.128-0.352l0.128 0.064c0 0.128 0.032 0.256 0 0.384-0.16 0.8-0.32 1.632-0.48 2.432-0.544 2.624-1.056 5.248-1.6 7.84-0.096 0.48 0.032 0.608 0.512 0.608h1.568v6.56c0 0.896 0.576 1.44 1.44 1.44 0.64 0 1.312-0.576 1.312-1.44-0.032-2.048-0.032-4.096-0.032-6.144v-0.384h0.544v6.592c0 0.736 0.448 1.28 1.12 1.376 0.992 0.128 1.632-0.448 1.632-1.472v-6.528h1.536c0.576 0 0.672-0.096 0.576-0.704l-1.728-8.448c-0.128-0.64-0.256-1.248-0.384-1.888 0-0.064 0.064-0.16 0.096-0.256 0.064 0.064 0.096 0.128 0.16 0.192 0 0.032 0 0.096 0.032 0.16 0.384 1.984 0.8 3.968 1.216 5.952 0.128 0.672 0.736 1.088 1.344 0.96 0.672-0.128 1.056-0.768 0.928-1.44zM16.032 6.912c1.312-0.032 2.4-1.12 2.368-2.464 0-0.010 0-0.022 0-0.033 0-1.332-1.071-2.413-2.399-2.431l-0.002-0c-1.344 0-2.4 1.088-2.4 2.464 0 1.344 1.088 2.464 2.432 2.464z'
const genderOption = computed(() => {
if (!gender.value.length) return
const manIcon =
'path://M21.696 10.368c-0.032-1.888-1.344-3.136-3.2-3.136h-5.12c-1.792 0.032-3.104 1.344-3.104 3.136v6.4c0 0.704 0.48 1.216 1.12 1.248 0.736 0 1.248-0.48 1.248-1.248v-5.6c0-0.16 0.096-0.32 0.16-0.48 0.064 0.16 0.16 0.32 0.16 0.48v17.568c0.032 0.544 0.384 1.024 0.896 1.184 0.992 0.32 1.856-0.32 1.888-1.344v-8.736c0-0.192-0.096-0.512 0.256-0.512 0.32 0 0.224 0.32 0.224 0.512 0 2.88 0 5.728 0.032 8.608 0 0.32 0.064 0.672 0.224 0.928 0.288 0.544 0.96 0.736 1.6 0.544 0.576-0.16 0.96-0.64 0.96-1.312v-17.408c0-0.16 0.096-0.32 0.128-0.48 0.064 0.16 0.16 0.32 0.16 0.48v5.472c0 0.416 0.096 0.8 0.48 1.088 0.384 0.256 0.8 0.32 1.216 0.096 0.512-0.224 0.672-0.672 0.672-1.184 0.015-0.938 0.024-2.044 0.024-3.152s-0.009-2.214-0.026-3.319l0.002 0.167zM15.968 6.912c1.408 0 2.464-1.056 2.464-2.464 0-1.344-1.088-2.432-2.432-2.432s-2.496 1.12-2.464 2.464c0 1.344 1.088 2.4 2.432 2.432z'
const womanIcon =
'path://M22.784 16.512c-0.032-0.256-0.096-0.512-0.16-0.768-0.384-2.048-0.832-4.096-1.216-6.144-0.224-1.248-1.44-2.336-2.688-2.336-0.8-0.015-1.743-0.024-2.688-0.024s-1.888 0.009-2.829 0.026l0.141-0.002c-0.16 0-0.352 0-0.512 0.032-1.312 0.384-2.048 1.248-2.304 2.624-0.384 2.176-0.864 4.32-1.28 6.496-0.16 0.736 0.224 1.376 0.864 1.504 0.672 0.128 1.248-0.256 1.408-0.992l1.216-5.92c0.032-0.128 0.096-0.224 0.128-0.352l0.128 0.064c0 0.128 0.032 0.256 0 0.384-0.16 0.8-0.32 1.632-0.48 2.432-0.544 2.624-1.056 5.248-1.6 7.84-0.096 0.48 0.032 0.608 0.512 0.608h1.568v6.56c0 0.896 0.576 1.44 1.44 1.44 0.64 0 1.312-0.576 1.312-1.44-0.032-2.048-0.032-4.096-0.032-6.144v-0.384h0.544v6.592c0 0.736 0.448 1.28 1.12 1.376 0.992 0.128 1.632-0.448 1.632-1.472v-6.528h1.536c0.576 0 0.672-0.096 0.576-0.704l-1.728-8.448c-0.128-0.64-0.256-1.248-0.384-1.888 0-0.064 0.064-0.16 0.096-0.256 0.064 0.064 0.096 0.128 0.16 0.192 0 0.032 0 0.096 0.032 0.16 0.384 1.984 0.8 3.968 1.216 5.952 0.128 0.672 0.736 1.088 1.344 0.96 0.672-0.128 1.056-0.768 0.928-1.44zM16.032 6.912c1.312-0.032 2.4-1.12 2.368-2.464 0-0.010 0-0.022 0-0.033 0-1.332-1.071-2.413-2.399-2.431l-0.002-0c-1.344 0-2.4 1.088-2.4 2.464 0 1.344 1.088 2.464 2.432 2.464z'
const [man, woman] = gender.value
return {
grid: { left: '60', right: '60' },
tooltip: {
......@@ -48,8 +62,8 @@ const genderOption = computed(() => {
axisTick: { show: false },
axisLabel: {
formatter: function (value, index) {
const total = gender[1] + gender[2]
return value + '\n' + (index === 0 ? ((gender[1] / total) * 100).toFixed(1) : ((gender[2] / total) * 100).toFixed(1)) + '%'
const total = parseInt(man.total) + parseInt(woman.total)
return value + '\n' + (index === 0 ? ((man.total / total) * 100).toFixed(1) : ((woman.total / total) * 100).toFixed(1)) + '%'
}
}
},
......@@ -67,66 +81,37 @@ const genderOption = computed(() => {
symbolOffset: [20, 0],
symbolMargin: 10,
data: [
{ value: gender[1], symbol: man, itemStyle: { color: '#767aca' } },
{ value: gender[2], symbol: woman, itemStyle: { color: '#d26080' } }
{ value: man.total, symbol: manIcon, itemStyle: { color: '#767aca' } },
{ value: woman.total, symbol: womanIcon, itemStyle: { color: '#d26080' } }
]
}
]
}
})
// 用户年龄分布
const age = ref([])
async function fetchAge() {
const res = await api.getMemberAge()
age.value = res.data.items.map(item => {
return { name: item.group_name, value: item.total }
// 用户数据来源分布
const loading2 = ref(false)
const connection = ref([])
async function fetchConnections() {
if (!userValue.value) return
loading2.value = true
const res = await api.getMemberConnections({ sso_id: userValue.value })
connection.value = res.data.items.map(item => {
return { ...item, group_name: getNameByValue(item.group_name, connectionTypeList) }
})
loading2.value = false
}
onMounted(fetchAge)
const ageOption = computed(() => {
const connectionOption = computed(() => {
if (!connection.value.length) return
return {
tooltip: {
trigger: 'item',
formatter: '{b}: {c}<br />{d}%'
},
series: [
{
type: 'pie',
label: { formatter: '{b}\n{d}%' },
itemStyle: {
borderRadius: 6
},
radius: [0, '45%'],
data: age.value
}
]
}
})
// 信用卡级别分布
const cardLevel = ref([])
async function fetchCardLevel() {
const res = await api.getCardLevel()
cardLevel.value = res.data.items
}
onMounted(fetchCardLevel)
const levelOption = computed(() => {
return {
grid: {
left: '20%',
right: '10%'
// bottom: '10%'
},
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
axisLabel: { interval: 0, rotate: 30 },
data: cardLevel.value.map(item => item.group_name)
axisLabel: { interval: 0 },
data: connection.value.map(item => item.group_name)
},
yAxis: {
type: 'value'
......@@ -135,29 +120,29 @@ const levelOption = computed(() => {
{
name: '数据',
type: 'bar',
label: {
show: true,
position: 'top'
},
itemStyle: { borderRadius: 2 },
data: cardLevel.value.map(item => item.total)
label: { show: true, position: 'top' },
data: connection.value.map(item => item.total)
}
]
}
})
// 月收入分布
const monthly = ref([])
async function fetchMonthly() {
const res = await api.getMemberMonthly()
monthly.value = res.data.items.map(item => {
return { name: item.group_name, value: item.total }
// 用户数据来源分布
const loading3 = ref(false)
const status = ref([])
async function fetchStatus() {
if (!userValue.value) return
loading3.value = true
const res = await api.getMemberStatus({ sso_id: userValue.value })
status.value = res.data.items.map(item => {
return { name: getNameByValue(item.group_name, statusList), value: item.total }
})
loading3.value = false
}
onMounted(fetchMonthly)
const monthlyOption = computed(() => {
const statusOption = computed(() => {
if (!status.value.length) return
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'item',
formatter: '{b}: {c}<br />{d}%'
......@@ -165,404 +150,57 @@ const monthlyOption = computed(() => {
series: [
{
type: 'pie',
radius: ['30%', '45%'],
itemStyle: {
borderRadius: 6
},
label: {
formatter: '{b}\n{d}%'
},
data: monthly.value
label: { formatter: '{b}\n{d}%' },
itemStyle: { borderRadius: 6 },
radius: [0, '70%'],
data: status.value
}
]
}
})
// 事件分析
const event = ref([])
async function fetchEvent() {
const res = await api.getEvent()
event.value = res.data.items
}
onMounted(fetchEvent)
const eventOption = computed(() => {
const series = event.value.map(item => {
const [first] = item.items
return {
name: first?.name,
type: 'line',
data: item.items.map(item => {
return { name: new Date(item.group_name), value: [item.group_name, item.total] }
})
}
})
return {
grid: {
left: '5%',
right: '5%',
bottom: '10%'
},
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'time',
splitLine: {
show: false
}
},
yAxis: {
type: 'value',
splitLine: {
show: false
}
},
series
}
})
// 标签用户数量分布
const label = ref([])
const labelTotal = ref(0)
async function fetchLabel() {
const res = await api.getLabel()
label.value = res.data.items
labelTotal.value = res.data.total
}
onMounted(fetchLabel)
const labelOption = computed(() => {
return {
border: true,
height: 280,
headerCellStyle: { color: '#fff', backgroundColor: '#68bbc4' },
columns: [
{ type: 'index', label: '序号', width: 60 },
{ label: '项目', prop: 'group_name' },
{ label: '人数', prop: 'total' },
{
label: '占比',
computed({ row }) {
return ((parseInt(row.total) / labelTotal.value) * 100).toFixed(2) + '%'
}
}
],
data: label.value
}
})
// 群组用户数量分布
const group = ref([])
const groupTotal = ref(0)
async function fetchGroup() {
const res = await api.getGroup()
group.value = res.data.items
groupTotal.value = res.data.total
}
onMounted(fetchGroup)
const groupOption = computed(() => {
return {
border: true,
height: 280,
headerCellStyle: { color: '#fff', backgroundColor: '#68bbc4' },
columns: [
{ type: 'index', label: '序号', width: 60 },
{ label: '项目', prop: 'group_name' },
{ label: '人数', prop: 'total' },
{
label: '占比',
computed({ row }) {
return ((parseInt(row.total) / groupTotal.value) * 100).toFixed(2) + '%'
}
}
],
data: group.value
}
})
// 信用卡开卡
const cardOpen = ref([])
async function fetchCardOpen() {
const res = await api.getCardOpen()
cardOpen.value = res.data.items.map((item, index, items) => {
// 总转换率
let rate = 0
if (index === 0) {
rate = ((items[items.length - 1]?.total / item.total) * 100).toFixed(2)
} else {
rate = ((item.total / (items[index - 1]?.total || 0)) * 100).toFixed(2)
}
return { ...item, rate }
})
}
onMounted(fetchCardOpen)
// 信用卡积分
const cardIntegral = ref([])
async function fetchCardIntegral() {
const res = await api.getCardIntegral()
cardIntegral.value = res.data.items.map((item, index, items) => {
// 总转换率
let rate = 0
if (index === 0) {
rate = ((items[items.length - 1]?.total / item.total) * 100).toFixed(2)
} else {
rate = ((item.total / (items[index - 1]?.total || 0)) * 100).toFixed(2)
}
return { ...item, rate }
})
}
onMounted(fetchCardIntegral)
// 分期还款
const cardInstallment = ref([])
async function fetchCardInstallment() {
const res = await api.getCardInstallment()
cardInstallment.value = res.data.items.map((item, index, items) => {
// 总转换率
let rate = 0
if (index === 0) {
rate = ((items[items.length - 1]?.total / item.total) * 100).toFixed(2)
} else {
rate = ((item.total / (items[index - 1]?.total || 0)) * 100).toFixed(2)
}
return { ...item, rate }
})
}
onMounted(fetchCardInstallment)
</script>
<template>
<AppCard title="报表分析">
<el-form inline label-suffix=":" v-if="info">
<el-form-item label="实验名称">{{ info.name }}</el-form-item>
<el-form-item label="操作人">{{ info.operator.real_name || info.operator.nickname || info.operator.username }}</el-form-item>
<AppCard title="用户分析">
<el-form inline label-suffix=":">
<el-form-item label="实验名称">{{ info?.name }}</el-form-item>
<el-form-item label="请选择学生/老师">
<el-select v-model="userValue" filterable>
<el-option v-for="item in userList" :label="item.name" :value="item.sso_id" :key="item.sso_id"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="DataLine" :loading="loading" @click="handleStart">分析</el-button>
</el-form-item>
<el-divider style="margin: 10px 0" />
<el-form-item label="用户总数">
<b class="total">{{ userTotal }}</b>
</el-form-item>
</el-form>
<div class="group">
<h2>用户分析</h2>
<div class="row">
<div class="box">
<h3>用户性别分布</h3>
<div class="box-inner">
<v-chart class="chart" :option="genderOption" ref="chart" style="height: 280px" v-if="userTotal" />
<el-empty v-else />
</div>
</div>
<div class="box">
<h3>用户年龄分布</h3>
<div class="box-inner">
<v-chart class="chart" :option="ageOption" ref="chart" style="height: 280px" v-if="age.length" />
<el-empty v-else />
</div>
</div>
<div class="box">
<h3>信用卡级别分布</h3>
<div class="box-inner">
<v-chart class="chart" :option="levelOption" ref="chart" style="height: 280px" v-if="cardLevel.length" />
<el-empty v-else />
</div>
</div>
<div class="box">
<h3>月收入分布</h3>
<div class="box-inner">
<v-chart class="chart" :option="monthlyOption" ref="chart" style="height: 280px" v-if="monthly.length" />
<el-empty v-else />
</div>
</div>
</div>
</div>
<div class="group">
<h2>标签及群组分析</h2>
<div class="row">
<div class="box">
<h3>标签用户数量分布</h3>
<div style="margin: 0 20px">
<AppList v-bind="labelOption"></AppList>
</div>
</div>
<div class="box">
<h3>群组用户数量分布</h3>
<div style="margin: 0 20px">
<AppList v-bind="groupOption"></AppList>
</div>
</div>
</div>
<div class="row">
<ChartCard title="用户性别分布" :options="genderOption" :loading="loading1"></ChartCard>
<ChartCard title="用户数据来源分布" :options="connectionOption" :loading="loading2"></ChartCard>
<ChartCard title="用户状态分布" :options="statusOption" :loading="loading3"></ChartCard>
</div>
<div class="group">
<h2>营销分析</h2>
<div class="row">
<div class="box">
<h3>事件分析</h3>
<div class="box-inner">
<v-chart class="chart" :option="eventOption" ref="chart" style="height: 380px" v-if="event.length" />
<el-empty v-else />
</div>
</div>
</div>
<div class="row">
<div class="box">
<h3>信用卡开卡</h3>
<div class="card">
<template v-if="cardOpen.length">
<div class="item" v-for="(item, index) in cardOpen" :key="index">
<!-- <div class="item-left">{{ item.group_name }}</div> -->
<div class="item-center">
<p v-if="index === 0">总转换率 {{ item.rate }}%</p>
<p v-else>{{ item.rate }}%</p>
<div class="circle">{{ item.group_name }}</div>
</div>
<div class="item-right">{{ item.total }}</div>
</div>
</template>
<el-empty v-else />
</div>
</div>
<div class="box">
<h3>积分翻倍及消费满减</h3>
<div class="card">
<template v-if="cardIntegral.length">
<div class="item" v-for="(item, index) in cardIntegral" :key="index">
<!-- <div class="item-left">{{ item.group_name }}</div> -->
<div class="item-center">
<p v-if="index === 0">总转换率 {{ item.rate }}%</p>
<p v-else>{{ item.rate }}%</p>
<div class="circle">{{ item.group_name }}</div>
</div>
<div class="item-right">{{ item.total }}</div>
</div>
</template>
<el-empty v-else />
</div>
</div>
<div class="box">
<h3>分期还款</h3>
<div class="card">
<template v-if="cardInstallment.length">
<div class="item" v-for="(item, index) in cardInstallment" :key="index">
<!-- <div class="item-left">{{ item.group_name }}</div> -->
<div class="item-center">
<p v-if="index === 0">总转换率 {{ item.rate }}%</p>
<p v-else>{{ item.rate }}%</p>
<div class="circle">{{ item.group_name }}</div>
</div>
<div class="item-right">{{ item.total }}</div>
</div>
</template>
<el-empty v-else />
</div>
</div>
</div>
<div class="row">
<UserChart :ssoId="userValue" />
<UserChart :ssoId="userValue" />
<UserChart :ssoId="userValue" />
</div>
</AppCard>
</template>
<style lang="scss">
.group {
margin-bottom: 20px;
h2 {
font-size: 18px;
line-height: 32px;
margin-bottom: 20px;
border-bottom: 1px solid var(--main-color);
}
h3 {
padding: 10px;
font-size: 16px;
color: rgba(0, 0, 0, 0.8);
line-height: 30px;
text-align: center;
}
.row {
display: flex;
gap: 20px;
& + .row {
margin-top: 20px;
}
}
.box {
flex: 1;
background-color: rgba(234, 234, 234, 0.6);
border-radius: 6px;
overflow: hidden;
}
.box-inner {
background-color: #fff;
margin: 0 20px 20px;
border-radius: 4px;
}
<style lang="scss" scoped>
.total {
font-size: 16px;
font-weight: bold;
color: var(--main-color);
}
.card {
margin: 0 20px 20px;
background-color: #fff;
padding: 20px;
min-height: 410px;
border-radius: 4px;
.item {
min-height: 40px;
display: flex;
align-items: flex-end;
border-bottom: 1px solid #ebeef5;
.item-left {
padding: 0 10px;
max-width: 100px;
font-size: 14px;
color: #999;
text-align: center;
}
.item-center {
position: relative;
margin: 40px 0 0;
flex: 1;
height: 40px;
.circle {
width: 100%;
height: 100%;
font-size: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
background-color: #1b41a0;
}
p {
position: absolute;
color: #000;
top: -30px;
left: 50%;
transform: translateX(-50%);
}
}
&:nth-child(1) {
.circle {
clip-path: polygon(0 0, 100% 0%, 95% 100%, 5% 100%);
}
}
&:nth-child(2) {
.circle {
clip-path: polygon(5% 0, 95% 0%, 90% 100%, 10% 100%);
}
}
&:nth-child(3) {
.circle {
clip-path: polygon(10% 0, 90% 0%, 85% 100%, 15% 100%);
}
}
&:nth-child(4) {
.circle {
clip-path: polygon(15% 0, 85% 0%, 80% 100%, 20% 100%);
}
}
&:nth-child(5) {
.circle {
clip-path: polygon(20% 0, 80% 0%, 75% 100%, 25% 100%);
}
}
.item-right {
font-size: 14px;
color: #999;
text-align: right;
}
.row {
display: flex;
gap: 20px;
& + .row {
margin-top: 20px;
}
}
</style>
......@@ -65,7 +65,9 @@ const listOptions = computed(() => {
label: '状态',
prop: 'status_name',
computed: (row: any) => {
return row.row.status === '0' ? `<span style="color: rgb(170, 2, 49)">${row.row.status_name}</span>` : `<span style="color: #00ac27">${row.row.status_name}</span>`
return row.row.status === '0'
? `<span style="color: rgb(170, 2, 49)">${row.row.status_name}</span>`
: `<span style="color: #00ac27">${row.row.status_name}</span>`
}
},
{ label: '更新人', prop: 'updated_operator_name' },
......@@ -203,38 +205,41 @@ const downloadMember = function (isAll?: boolean) {
<AppCard>
<AppList v-bind="listOptions" ref="appList" @selection-change="handleSelectionChange">
<template #header-buttons>
<el-space>
<el-button type="primary" :icon="Plus" @click="handleAdd" v-permission="'v1-experiment-member-create'">新建</el-button>
<el-dropdown v-permission="'v1-experiment-member-download'">
<el-button type="primary" :icon="Download">导出</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="downloadMember(true)">全部用户数据</el-dropdown-item>
<el-dropdown-item :disabled="!multipleSelection.length" @click="downloadMember(false)">勾选用户数据</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown v-permission="['v1-experiment-member-member-upload', 'v1-experiment-member-event-upload']">
<el-button type="primary" :icon="Upload">导入</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="userVisible = true">用户数据</el-dropdown-item>
<el-dropdown-item @click="eventsVisible = true">用户事件数据</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" @click="progressVisible = true" v-permission="'v1-experiment-member-tasks'">数据导入进度</el-button>
<!-- <el-button type="danger" plain :icon="Delete" :disabled="!multipleSelection.length" @click="handleRemoves()" v-permission="'v1-experiment-member-delete'">删除</el-button> -->
<el-dropdown v-permission="'v1-experiment-member-delete'">
<el-button type="danger" :icon="Delete">删除</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleRemoves(true)">删除全部用户</el-dropdown-item>
<el-dropdown-item :disabled="!multipleSelection.length" @click="handleRemoves(false)">删除勾选用户</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-space>
<el-row justify="space-between">
<el-space>
<el-button type="primary" :icon="Plus" @click="handleAdd" v-permission="'v1-experiment-member-create'">新建</el-button>
<el-dropdown v-permission="'v1-experiment-member-download'">
<el-button type="primary" :icon="Download">导出</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="downloadMember(true)">全部用户数据</el-dropdown-item>
<el-dropdown-item :disabled="!multipleSelection.length" @click="downloadMember(false)">勾选用户数据</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown v-permission="['v1-experiment-member-member-upload', 'v1-experiment-member-event-upload']">
<el-button type="primary" :icon="Upload">导入</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="userVisible = true">用户数据</el-dropdown-item>
<el-dropdown-item @click="eventsVisible = true">用户事件数据</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" @click="progressVisible = true" v-permission="'v1-experiment-member-tasks'">数据导入进度</el-button>
<!-- <el-button type="danger" plain :icon="Delete" :disabled="!multipleSelection.length" @click="handleRemoves()" v-permission="'v1-experiment-member-delete'">删除</el-button> -->
<el-dropdown v-permission="'v1-experiment-member-delete'">
<el-button type="danger" :icon="Delete">删除</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleRemoves(true)">删除全部用户</el-dropdown-item>
<el-dropdown-item :disabled="!multipleSelection.length" @click="handleRemoves(false)">删除勾选用户</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-space>
<router-link to="/analyze/user"><el-button type="primary">用户分析</el-button></router-link>
</el-row>
</template>
<template #table-x="{ row }">
<el-button type="primary" plain @click="handleImage(row)">画像</el-button>
......
......@@ -25,7 +25,7 @@ router.beforeEach(async (to, from, next) => {
path: to.path,
query: {
...to.query,
experiment_id: from.query.experiment_id || '7025368348925886464',
experiment_id: from.query.experiment_id || '7165149417073278976',
student_id: from.query.student_id,
force_tgc: from.query.force_tgc
}
......
......@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'
// import router from '@/router'
const httpRequest = axios.create({
timeout: 60000,
// timeout: 60000,
withCredentials: true,
headers: {
// 'Content-Type': 'application/x-www-form-urlencoded'
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论