This commit is contained in:
2026-04-21 15:56:04 +08:00
parent b03c079099
commit e2aaee324e
7 changed files with 1747 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function getConversionStatsOverview(params: any) {
return request.get({ url: '/stats.conversion/overview', params })
}
+10 -2
View File
@@ -27,9 +27,10 @@ export function filterAsyncRoutes(routes: any[], firstRoute = true) {
// 创建一条路由记录
export function createRouteRecord(route: any, firstRoute: boolean): RouteRecordRaw {
const normalizedPath = normalizeMenuPath(route.paths)
//@ts-ignore
const routeRecord: RouteRecordRaw = {
path: isExternal(route.paths) ? route.paths : firstRoute ? `/${route.paths}` : route.paths,
path: isExternal(route.paths) ? route.paths : firstRoute ? `/${normalizedPath}` : normalizedPath,
name: Symbol(route.paths),
meta: {
hidden: !route.is_show,
@@ -59,8 +60,9 @@ export function createRouteRecord(route: any, firstRoute: boolean): RouteRecordR
// 动态加载组件
export function loadRouteView(component: string) {
try {
const normalizedComponent = normalizeMenuPath(component)
const key = Object.keys(modules).find((key) => {
return key.includes(`/${component}.vue`)
return key.includes(`/${normalizedComponent}.vue`)
})
if (key) {
return modules[key]
@@ -72,6 +74,12 @@ export function loadRouteView(component: string) {
}
}
function normalizeMenuPath(path?: string) {
return String(path || '')
.replace(/^\/+/, '')
.replace(/\/+$/, '')
}
// 找到第一个有效的路由
export function findFirstValidRoute(routes: RouteRecordRaw[]): string | undefined {
for (const route of routes) {
+617
View File
@@ -0,0 +1,617 @@
<template>
<div class="conversion-stats-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<el-form-item label="统计维度">
<el-segmented
v-model="queryParams.dimension"
:options="dimensionOptions"
@change="handleDimensionChange"
/>
</el-form-item>
<el-form-item :label="entityLabel">
<el-tree-select
v-if="queryParams.dimension === 'dept'"
v-model="currentEntityId"
:data="deptTreeOptions"
:placeholder="`全部${entityLabel}`"
clearable
filterable
node-key="id"
check-strictly
:default-expand-all="true"
:props="deptTreeProps"
style="width: 220px"
@change="handleEntityChange"
/>
<el-select
v-else
v-model="currentEntityId"
:placeholder="`全部${entityLabel}`"
clearable
filterable
style="width: 220px"
@change="handleEntityChange"
>
<el-option
v-for="item in currentEntityOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">最近30天</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="queryParams.time_type === 'custom'">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
@change="handleCustomDateChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="stats-kpi-grid">
<div
v-for="card in summaryCards"
:key="card.key"
class="stats-kpi-card"
>
<div class="stats-kpi-label">{{ card.label }}</div>
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
{{ renderMetric(card.key, overview.summary, card.type, card.placeholder) }}
</div>
</div>
</div>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="14">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>转化排行</span>
<span class="card-hint">{{ dateRangeText }}</span>
</div>
</template>
<v-charts
v-if="rankingChartHasData"
class="stats-chart"
:option="rankingChartOption"
autoresize
/>
<el-empty v-else description="暂无图表数据" />
</el-card>
</el-col>
<el-col :xs="24" :lg="10">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>诊单金额占比</span>
<span class="card-hint">TOP 10</span>
</div>
</template>
<v-charts
v-if="amountPieHasData"
class="stats-chart"
:option="amountPieOption"
autoresize
/>
<el-empty v-else description="暂无金额数据" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="10">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>加粉占比</span>
<span class="card-hint">TOP 10</span>
</div>
</template>
<v-charts
v-if="fanPieHasData"
class="stats-chart"
:option="fanPieOption"
autoresize
/>
<el-empty v-else description="暂无加粉数据" />
</el-card>
</el-col>
<el-col :xs="24" :lg="14">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>明细列表</span>
<span class="card-hint">{{ entityLabel }}维度</span>
</div>
</template>
<el-table
:data="pager.lists"
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
:default-expand-all="false"
size="large"
style="width: 100%"
max-height="560"
>
<el-table-column
:label="entityLabel"
prop="name"
fixed="left"
min-width="280"
class-name="dept-name-column"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="dept-name-cell" :title="row.name">
{{ row.name }}
</span>
</template>
</el-table-column>
<el-table-column
v-for="column in tableColumns"
:key="column.key"
:label="column.label"
:min-width="column.minWidth || 110"
:width="column.width"
:align="column.align || 'center'"
>
<template #default="{ row }">
{{ renderMetric(column.key, row, column.type, column.placeholder) }}
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts" name="conversionStatsPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import vCharts from 'vue-echarts'
import { getConversionStatsOverview } from '@/api/stats'
import { usePaging } from '@/hooks/usePaging'
type StatsDimension = 'dept' | 'assistant' | 'doctor'
interface OptionItem {
id: number
name: string
}
interface MetricCard {
key: string
label: string
type: 'count' | 'money' | 'percent' | 'ratio'
placeholder?: boolean
}
interface MetricColumn extends MetricCard {
minWidth?: number
width?: number
align?: 'left' | 'center' | 'right'
}
const dateRange = ref<string[]>([])
const deptTreeOptions = ref<any[]>([])
const deptOptions = ref<OptionItem[]>([])
const assistantOptions = ref<OptionItem[]>([])
const doctorOptions = ref<OptionItem[]>([])
const filtersLoaded = ref(false)
const overview = reactive<Record<string, any>>({
dimension: 'dept',
date_range: [],
summary: {},
charts: {
ranking: {
names: [],
amounts: [],
order_counts: [],
fan_counts: [],
rois: []
},
amount_share: [],
fan_share: []
}
})
const queryParams = reactive({
dimension: 'dept' as StatsDimension,
dept_id: undefined as number | undefined,
assistant_id: undefined as number | undefined,
doctor_id: undefined as number | undefined,
time_type: 'today',
start_date: '',
end_date: ''
})
const fetchOverviewList = async (params: Record<string, any>) => {
params.include_filters = filtersLoaded.value ? 0 : 1
if (queryParams.time_type === 'custom') {
params.start_date = dateRange.value[0] || ''
params.end_date = dateRange.value[1] || ''
}
const res = await getConversionStatsOverview(params)
Object.assign(overview, res?.extend || {})
applyFilterOptions(res?.extend?.filters)
return res
}
const { pager, getLists } = usePaging({
fetchFun: fetchOverviewList,
params: queryParams,
firstLoading: true
})
const dimensionOptions = [
{ label: '部门', value: 'dept' },
{ label: '医助', value: 'assistant' },
{ label: '医生', value: 'doctor' }
]
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children'
}
const treeProps = {
children: 'children'
}
const entityLabel = computed(() => {
if (queryParams.dimension === 'assistant') return '医助'
if (queryParams.dimension === 'doctor') return '医生'
return '部门'
})
const currentEntityOptions = computed(() => {
if (queryParams.dimension === 'assistant') return assistantOptions.value
if (queryParams.dimension === 'doctor') return doctorOptions.value
return deptOptions.value
})
const currentEntityId = computed({
get: () => {
if (queryParams.dimension === 'assistant') return queryParams.assistant_id
if (queryParams.dimension === 'doctor') return queryParams.doctor_id
return queryParams.dept_id
},
set: (value: number | undefined) => {
if (queryParams.dimension === 'assistant') {
queryParams.assistant_id = value
return
}
if (queryParams.dimension === 'doctor') {
queryParams.doctor_id = value
return
}
queryParams.dept_id = value
}
})
const summaryCards: MetricCard[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
{ key: 'interview_count', label: '面诊', type: 'count' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money' },
{ key: 'account_cost', label: '账户消耗', type: 'money' },
{ key: 'roi', label: 'ROI', type: 'ratio' }
]
const tableColumns: MetricColumn[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
{ key: 'interview_count', label: '面诊', type: 'count' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', minWidth: 120 },
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 120 },
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', placeholder: true, minWidth: 120 },
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 130 },
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', placeholder: true, minWidth: 120 },
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 110 },
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 120 },
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 110 },
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
]
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
})
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
const rankingChartData = computed(() => {
const ranking = overview.charts?.ranking || {}
const names = Array.isArray(ranking.names) ? ranking.names : []
const normalize = (values: unknown[]) => names.map((_, index) => Number(values?.[index] ?? 0))
return {
names,
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
}
})
const rankingChartOption = computed(() => ({
tooltip: { trigger: 'axis' },
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
grid: { left: 48, right: 24, top: 48, bottom: 48 },
xAxis: {
type: 'category',
data: rankingChartData.value.names,
axisLabel: {
interval: 0,
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
}
},
yAxis: [
{ type: 'value', name: '金额 / 数量' }
],
series: [
{
name: '诊单金额',
type: 'bar',
barMaxWidth: 36,
data: rankingChartData.value.amounts,
itemStyle: { color: '#4a78ff' }
},
{
name: '接诊诊单',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.orderCounts,
itemStyle: { color: '#15b8a6' }
},
{
name: '加粉数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.fanCounts,
itemStyle: { color: '#ff9f43' }
}
]
}))
const amountPieOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
name: '诊单金额',
type: 'pie',
radius: ['42%', '72%'],
data: overview.charts.amount_share
}
]
}))
const fanPieOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
name: '加粉数',
type: 'pie',
radius: ['42%', '72%'],
data: overview.charts.fan_share
}
]
}))
const flattenDepts = (items: any[]): OptionItem[] => {
let result: OptionItem[] = []
items.forEach((item) => {
result.push({ id: Number(item.id), name: item.name })
if (Array.isArray(item.children) && item.children.length > 0) {
result = result.concat(flattenDepts(item.children))
}
})
return result
}
const applyFilterOptions = (filters?: Record<string, any>) => {
if (!filters) return
filtersLoaded.value = true
const departments = Array.isArray(filters.departments) ? filters.departments : []
const assistants = Array.isArray(filters.assistants) ? filters.assistants : []
const doctors = Array.isArray(filters.doctors) ? filters.doctors : []
deptTreeOptions.value = departments
deptOptions.value = flattenDepts(departments)
assistantOptions.value = assistants.map((item: any) => ({
id: Number(item.id),
name: item.name,
}))
doctorOptions.value = doctors.map((item: any) => ({
id: Number(item.id),
name: item.name,
}))
}
const fetchOverview = async () => {
try {
await getLists()
} catch (error: any) {
console.error('获取统计概览失败:', error)
ElMessage.error(error?.msg || '获取统计数据失败')
}
}
const handleDimensionChange = () => {
queryParams.dept_id = undefined
queryParams.assistant_id = undefined
queryParams.doctor_id = undefined
pager.page = 1
fetchOverview()
}
const handleEntityChange = () => {
pager.page = 1
fetchOverview()
}
const handleTimeTypeChange = () => {
if (queryParams.time_type !== 'custom') {
dateRange.value = []
pager.page = 1
fetchOverview()
}
}
const handleCustomDateChange = () => {
if (dateRange.value.length === 2) {
pager.page = 1
fetchOverview()
}
}
const handleQuery = () => {
pager.page = 1
fetchOverview()
}
const handleReset = () => {
queryParams.dimension = 'dept'
queryParams.dept_id = undefined
queryParams.assistant_id = undefined
queryParams.doctor_id = undefined
queryParams.time_type = 'today'
dateRange.value = []
pager.page = 1
pager.size = 15
fetchOverview()
}
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
if (placeholder) return '—'
const value = source?.[key] ?? 0
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
if (type === 'ratio') return Number(value || 0).toFixed(2)
return String(value ?? 0)
}
onMounted(async () => {
await fetchOverview()
})
</script>
<style scoped lang="scss">
.conversion-stats-page {
padding: 20px;
}
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stats-kpi-card {
background: linear-gradient(145deg, #ffffff, #f5f8ff);
border: 1px solid #ebf1ff;
border-radius: 14px;
padding: 18px 16px;
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
}
.stats-kpi-label {
color: #6b7280;
font-size: 13px;
}
.stats-kpi-value {
margin-top: 12px;
font-size: 28px;
line-height: 1.1;
font-weight: 700;
color: #1f2937;
}
.stats-kpi-value.is-money {
font-size: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.card-hint {
color: #909399;
font-size: 12px;
}
.dept-name-cell {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
:deep(.dept-name-column .cell) {
white-space: nowrap;
}
.stats-chart {
height: 360px;
}
</style>