Merge branch 'kpi-statis'
This commit is contained in:
@@ -39,3 +39,23 @@ export function refundLog(params?: any) {
|
||||
export function refundStat(params?: any) {
|
||||
return request.get({ url: '/finance.refund/stat', params })
|
||||
}
|
||||
|
||||
// 账户消耗列表
|
||||
export function accountCostLists(params?: any) {
|
||||
return request.get({ url: '/finance.account_cost/lists', params })
|
||||
}
|
||||
|
||||
// 账户消耗新增
|
||||
export function accountCostAdd(params?: any) {
|
||||
return request.post({ url: '/finance.account_cost/add', params })
|
||||
}
|
||||
|
||||
// 账户消耗编辑
|
||||
export function accountCostEdit(params?: any) {
|
||||
return request.post({ url: '/finance.account_cost/edit', params })
|
||||
}
|
||||
|
||||
// 账户消耗详情
|
||||
export function accountCostDetail(params?: any) {
|
||||
return request.get({ url: '/finance.account_cost/detail', params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getConversionStatsOverview(params: any) {
|
||||
return request.get({ url: '/stats.conversion/overview', params })
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="edit-popup">
|
||||
<popup
|
||||
ref="popupRef"
|
||||
:title="popupTitle"
|
||||
:async="true"
|
||||
width="560px"
|
||||
@confirm="handleSubmit"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" label-width="100px" :rules="formRules">
|
||||
<el-form-item label="日期" prop="cost_date">
|
||||
<el-date-picker
|
||||
v-model="formData.cost_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择日期"
|
||||
:disabled="mode === 'edit'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="账户消耗" prop="amount">
|
||||
<el-input-number
|
||||
v-model="formData.amount"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 6 }"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||
const mode = ref<'add' | 'edit'>('add')
|
||||
|
||||
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
cost_date: '',
|
||||
amount: 0,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
cost_date: [
|
||||
{
|
||||
required: true,
|
||||
message: '请选择日期',
|
||||
trigger: ['change'],
|
||||
},
|
||||
],
|
||||
amount: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入账户消耗金额',
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.id = ''
|
||||
formData.cost_date = ''
|
||||
formData.amount = 0
|
||||
formData.remark = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
if (mode.value === 'edit') {
|
||||
await accountCostEdit(formData)
|
||||
} else {
|
||||
await accountCostAdd(formData)
|
||||
}
|
||||
popupRef.value?.close()
|
||||
emit('success')
|
||||
}
|
||||
|
||||
const open = (type: 'add' | 'edit' = 'add') => {
|
||||
mode.value = type
|
||||
resetForm()
|
||||
popupRef.value?.open()
|
||||
}
|
||||
|
||||
const setFormData = (data: Record<string, any>) => {
|
||||
formData.id = data.id ?? ''
|
||||
formData.cost_date = data.cost_date ?? ''
|
||||
formData.amount = Number(data.amount ?? 0)
|
||||
formData.remark = data.remark ?? ''
|
||||
}
|
||||
|
||||
const getDetail = async (row: Record<string, any>) => {
|
||||
const data = await accountCostDetail({ id: row.id })
|
||||
setFormData(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
setFormData,
|
||||
getDetail,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="account-cost-list">
|
||||
<el-card class="!border-none mb-4" shadow="never">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/2 md:w-1/3">
|
||||
<div class="leading-10">当前筛选天数</div>
|
||||
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="w-1/2 md:w-1/3">
|
||||
<div class="leading-10">累计账户消耗 (元)</div>
|
||||
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
||||
<el-form-item label="日期范围">
|
||||
<daterange-picker
|
||||
v-model:startTime="queryParams.start_date"
|
||||
v-model:endTime="queryParams.end_date"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[280px]" label="备注/维护人">
|
||||
<el-input
|
||||
v-model="queryParams.remark"
|
||||
placeholder="备注 / 创建人 / 修改人"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
<el-button v-perms="['finance.account_cost/add']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||
<el-table-column label="账户消耗" min-width="120">
|
||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column label="创建人" prop="creator_name" min-width="100" />
|
||||
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
|
||||
<el-table-column label="更新时间" prop="update_time" min-width="180" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['finance.account_cost/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="accountCostList">
|
||||
import { accountCostLists } from '@/api/finance'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
|
||||
import EditPopup from './edit.vue'
|
||||
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
const showEdit = ref(false)
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: accountCostLists,
|
||||
params: queryParams,
|
||||
})
|
||||
|
||||
const handleAdd = async () => {
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('add')
|
||||
}
|
||||
|
||||
const handleEdit = async (row: any) => {
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('edit')
|
||||
editRef.value?.getDetail(row)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
queryParams.remark = ''
|
||||
resetPage()
|
||||
}
|
||||
|
||||
getLists()
|
||||
</script>
|
||||
@@ -0,0 +1,655 @@
|
||||
<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: 'cash_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: '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,
|
||||
type: 'scroll',
|
||||
pageIconColor: '#409eff',
|
||||
pageTextStyle: {
|
||||
color: '#909399',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
type: 'pie',
|
||||
radius: ['42%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
avoidLabelOverlap: true,
|
||||
minAngle: 3,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}',
|
||||
overflow: 'truncate',
|
||||
width: 90,
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
data: overview.charts.amount_share
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const fanPieOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
type: 'scroll',
|
||||
pageIconColor: '#409eff',
|
||||
pageTextStyle: {
|
||||
color: '#909399',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '加粉数',
|
||||
type: 'pie',
|
||||
radius: ['42%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
avoidLabelOverlap: true,
|
||||
minAngle: 3,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}',
|
||||
overflow: 'truncate',
|
||||
width: 90,
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
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>
|
||||
Reference in New Issue
Block a user