Files
zyt/admin/src/views/first_visit/conversion/index.vue
T
2026-08-12 11:03:28 +08:00

639 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="conversion-page" v-loading="loading" element-loading-text="正在汇总权限范围内的数据">
<header class="page-heading">
<div>
<h1>综合数据转化</h1>
<p>{{ scopeDescription }}</p>
</div>
<div class="heading-meta">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
<span v-if="dashboard.meta.generated_at">更新于 {{ dashboard.meta.generated_at }}</span>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
</div>
</header>
<section class="filter-strip">
<div class="filter-item filter-item--time">
<span class="filter-label">时间范围</span>
<el-segmented v-model="query.time_type" :options="timeOptions" @change="handleTimeTypeChange" />
<el-date-picker
v-if="query.time_type === 'custom'"
v-model="customDateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
class="date-range-picker"
:clearable="false"
@change="handleCustomDateChange"
/>
</div>
<div class="filter-item">
<span class="filter-label">搜索员工</span>
<el-select
v-model="query.assistant_id"
clearable
filterable
placeholder="全部员工"
class="employee-select"
@change="loadDashboard"
>
<el-option
v-for="item in dashboard.filters.assistants"
:key="item.id"
:label="item.name"
:value="Number(item.id)"
/>
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">部门</span>
<el-tree-select
v-model="query.dept_id"
:data="dashboard.filters.departments"
:props="deptTreeProps"
node-key="id"
clearable
filterable
check-strictly
default-expand-all
placeholder="全部可见部门"
class="dept-select"
@change="handleDeptChange"
/>
</div>
<div class="filter-item">
<span class="filter-label">渠道</span>
<el-select
v-model="query.media_channel_code"
clearable
filterable
placeholder="全部渠道"
class="channel-select"
@change="loadDashboard"
>
<el-option-group
v-for="group in mediaChannelGroups"
:key="group.group_name || '_'"
:label="`${group.group_name || '(未分组)'} · ${group.customer_count}`"
>
<el-option
v-for="item in group.channels"
:key="item.code"
:label="item.name"
:value="item.code"
>
<span class="channel-option">
<span>{{ item.name }}</span>
<span>{{ item.customer_count }} </span>
</span>
</el-option>
</el-option-group>
</el-select>
</div>
<span class="range-text">{{ dashboard.meta.start_date }} {{ dashboard.meta.end_date }}</span>
</section>
<section class="metric-grid" aria-label="综合转化指标">
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
<small>{{ metric.hint }}</small>
</article>
</section>
<section class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门订单量占比</h2>
<p>按订单创建人归属排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="item in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门金额占比</h2>
<p>仅统计未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="item in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
</article>
</section>
<section class="panel detail-panel">
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=区间有效加粉按员工+客户去重须会话同意剔除已删客户);挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/预约预约接诊率=接诊诊单/面诊面诊接诊率=面诊/挂号接诊率=接诊诊单/加粉</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
<el-table
:data="dashboard.rows"
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
class="detail-table"
>
<el-table-column prop="name" label="部门 / 人员" min-width="280" fixed="left">
<template #default="{ row }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
{{ row.name }}
</strong>
<el-tag
v-if="!row.type && row.leader_name"
size="small"
type="warning"
effect="plain"
class="role-tag"
>
组长 · {{ row.leader_name }}
</el-tag>
<el-tag v-if="row.type === 'member' && row.role" size="small" type="info" effect="plain" class="role-tag">
{{ row.role }}{{ row.is_leader ? ' · 组长' : '' }}
</el-tag>
<el-tag v-else-if="row.type === 'unbound'" size="small" type="warning" effect="plain" class="role-tag">
未绑定账号
</el-tag>
</template>
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
<el-table-column label="接诊金额" min-width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column>
<el-table-column label="开口率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column>
<el-table-column label="挂号率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="预约接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
<el-table-column label="ROI" min-width="86" align="right">
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
</el-table-column>
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
</el-table>
</section>
<section class="panel target-panel">
<div class="panel-heading">
<div>
<h2>一诊诊金目标追踪</h2>
<p>{{ dashboard.target.year }} · 按当前角色部门及员工筛选范围计算</p>
</div>
<span>{{ dashboard.target.department_count }} 个目标部门</span>
</div>
<div class="target-layout">
<div class="target-progress-list">
<div class="target-summary">
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
</div>
<div class="progress-block">
<div class="progress-copy">
<span>年度范围目标</span>
<b>{{ nullablePercent(dashboard.target.completion_rate) }}</b>
</div>
<el-progress
:percentage="progressValue(dashboard.target.completion_rate)"
:show-text="false"
:stroke-width="12"
color="#0f9185"
/>
<small>已完成 {{ formatMoney(dashboard.target.actual_amount) }} / 目标 {{ formatMoney(dashboard.target.target_amount) }}</small>
</div>
<div class="progress-block progress-block--month">
<div class="progress-copy">
<span>本月范围目标</span>
<b>{{ nullablePercent(dashboard.target.current_month_rate) }}</b>
</div>
<el-progress
:percentage="progressValue(dashboard.target.current_month_rate)"
:show-text="false"
:stroke-width="12"
color="#2f78df"
/>
<small>已完成 {{ formatMoney(dashboard.target.current_month_actual) }} / 目标 {{ formatMoney(dashboard.target.current_month_target) }}</small>
</div>
<div v-if="Number(dashboard.target.target_amount) <= 0" class="target-empty-note">
当前可见部门尚未维护本年度月度目标实际诊单金额仍会正常统计
</div>
</div>
<div class="trend-wrap">
<div class="trend-title">
<span>年度累计趋势</span>
<div><i class="legend-line is-actual" />实际完成 <i class="legend-line is-target" />目标值</div>
</div>
<v-charts class="target-chart" :option="targetChartOption" autoresize />
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts" name="firstVisitConversionPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Lock, Refresh } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
type MetricType = 'count' | 'money' | 'ratio'
type MediaChannelOption = {
code: string
name: string
tag_id?: string
group_name?: string
customer_count?: number
}
const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
},
filters: {
departments: [] as any[],
assistants: [] as Array<{ id: number; name: string }>,
media_channels: [] as MediaChannelOption[]
},
summary: {} as Record<string, any>,
rankings: { orders: [] as any[], amounts: [] as any[] },
rows: [] as any[],
target: {
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0, completion_rate: null as number | null,
current_month_target: 0, current_month_actual: 0, current_month_rate: null as number | null,
department_count: 0, months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
}
})
const dashboard = reactive(emptyDashboard())
const loading = ref(false)
const query = reactive<FirstVisitConversionParams>({
time_type: 'today',
dept_id: undefined,
assistant_id: undefined,
media_channel_code: ''
})
const customDateRange = ref<string[]>([])
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '昨天', value: 'yesterday' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '本季度', value: 'quarter' },
{ label: '本年', value: 'year' },
{ label: '自定义', value: 'custom' }
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
{ key: 'avg_unit_price', label: '平均客单价', type: 'money', hint: '诊单金额 / 接诊诊单' },
{ key: 'account_cost', label: '现金成本', type: 'money', hint: '当前范围内实际投放成本' },
{ key: 'roi', label: 'ROI', type: 'ratio', hint: '诊单金额 / 投放成本' }
]
const scopeDescription = computed(() => {
const parts = [`${dashboard.meta.time_label || '当前区间'}数据`, dashboard.meta.scope_label || '当前权限范围']
if (dashboard.meta.selected_dept_name) parts.push(dashboard.meta.selected_dept_name)
if (dashboard.meta.selected_assistant_name) parts.push(dashboard.meta.selected_assistant_name)
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
return parts.join(' · ')
})
const mediaChannelGroups = computed(() => {
const groups = new Map<string, {
group_name: string
customer_count: number
channels: MediaChannelOption[]
}>()
for (const channel of dashboard.filters.media_channels) {
const groupName = channel.group_name || ''
if (!groups.has(groupName)) {
groups.set(groupName, { group_name: groupName, customer_count: 0, channels: [] })
}
const group = groups.get(groupName)!
group.channels.push(channel)
group.customer_count = Math.max(group.customer_count, Number(channel.customer_count || 0))
}
return Array.from(groups.values())
})
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
const targetChartOption = computed(() => ({
animationDuration: 450,
color: ['#0f9185', '#2f78df'],
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
grid: { left: 62, right: 24, top: 28, bottom: 36 },
xAxis: { type: 'category', boundaryGap: false, data: dashboard.target.months, axisLine: { lineStyle: { color: '#d9e1e9' } }, axisLabel: { color: '#718096' } },
yAxis: { type: 'value', axisLabel: { color: '#718096', formatter: (value: number) => compactNumber(value) }, splitLine: { lineStyle: { color: '#edf1f5' } } },
series: [
{ name: '实际完成', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 3 }, areaStyle: { color: 'rgba(15,145,133,.08)' }, data: dashboard.target.actual_cumulative },
{ name: '目标值', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 2, type: 'dashed' }, data: dashboard.target.target_cumulative }
]
}))
let latestDashboardRequestId = 0
async function loadDashboard() {
const requestId = ++latestDashboardRequestId
loading.value = true
try {
const params: FirstVisitConversionParams = { ...query }
if (params.time_type === 'custom') {
params.start_date = customDateRange.value[0] || ''
params.end_date = customDateRange.value[1] || ''
if (!params.start_date || !params.end_date) {
ElMessage.warning('请选择自定义日期范围')
return
}
} else {
delete params.start_date
delete params.end_date
}
const result: any = await firstVisitConversionOverview(params)
if (requestId !== latestDashboardRequestId) return
Object.assign(dashboard, emptyDashboard(), result || {})
// 服务端会规范化失效渠道;同步真实生效值,避免筛选框与数据口径不一致。
query.media_channel_code = dashboard.meta.selected_media_channel_code || ''
} catch (error: any) {
if (requestId !== latestDashboardRequestId) return
ElMessage.error(error?.message || '综合数据加载失败')
} finally {
if (requestId === latestDashboardRequestId) loading.value = false
}
}
function handleTimeTypeChange() {
if (query.time_type !== 'custom') {
customDateRange.value = []
loadDashboard()
return
}
if (customDateRange.value.length === 2) {
loadDashboard()
}
}
function handleCustomDateChange() {
if (query.time_type === 'custom' && customDateRange.value.length === 2) {
loadDashboard()
}
}
function handleDeptChange() {
query.assistant_id = undefined
loadDashboard()
}
function formatMetric(key: string, type: MetricType) {
const value = dashboard.summary[key]
if (type === 'money') return formatMoney(value)
if (type === 'ratio') return formatRatio(value)
return formatNumber(value)
}
function formatNumber(value: any) {
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
}
function formatMoney(value: any) {
return ${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPercent(value: any) {
return `${Number(value || 0).toFixed(1)}%`
}
function nullablePercent(value: any) {
return value === null || value === undefined ? '未设置' : formatPercent(value)
}
function formatRatio(value: any) {
return Number(value || 0).toFixed(2)
}
function compactNumber(value: number) {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(0)}万`
return String(Math.round(value))
}
function barWidth(value: any, maximum: number) {
if (maximum <= 0) return '0%'
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%`
}
function progressValue(value: any) {
return Math.max(0, Math.min(100, Number(value || 0)))
}
onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.conversion-page {
display: grid;
gap: 14px;
min-height: 640px;
padding: 16px;
color: #172033;
background: #f4f6f8;
}
.page-heading,
.filter-strip,
.panel,
.metric-card {
border: 1px solid #dfe5ec;
background: #fff;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 15px 18px;
border-radius: 10px;
h1 { margin: 0; font-size: 20px; font-weight: 750; }
p { margin: 5px 0 0; color: #8590a2; font-size: 12px; }
}
.heading-meta {
display: flex;
align-items: center;
gap: 12px;
color: #8994a5;
font-size: 12px;
}
.scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
color: #0d8077;
}
.filter-strip {
display: flex;
align-items: center;
gap: 22px;
padding: 12px 16px;
border-radius: 9px;
}
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-label, .range-text { color: #748094; font-size: 12px; white-space: nowrap; }
.range-text { margin-left: auto; }
.date-range-picker { width: 260px; }
.employee-select { width: 190px; }
.dept-select { width: 220px; }
.channel-select { width: 180px; }
.channel-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
span:last-child {
color: #98a2b3;
font-size: 12px;
}
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
min-height: 100px;
padding: 15px 17px;
border-radius: 10px;
span, small { display: block; color: #748094; font-size: 12px; }
strong { display: block; margin: 9px 0 7px; color: #111b2f; font-size: 25px; line-height: 1; font-variant-numeric: tabular-nums; }
small { color: #a0a9b6; font-size: 11px; }
}
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { padding: 16px; border-radius: 10px; }
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
h2 { margin: 0; font-size: 15px; font-weight: 700; }
p { margin: 4px 0 0; color: #929dac; font-size: 11px; }
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
}
.bar-list { display: grid; gap: 13px; }
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
.bar-track i.is-teal { background: #15998d; }
.bar-track i.is-blue { background: #307bdf; }
.detail-panel { padding-bottom: 10px; }
.detail-table {
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
strong.is-parent { color: #172033; font-weight: 700; }
strong.is-member { color: #314158; font-weight: 600; }
.role-tag { margin-left: 7px; vertical-align: middle; }
}
.target-panel { padding-bottom: 18px; }
.target-layout { display: grid; grid-template-columns: minmax(390px, .82fr) minmax(500px, 1.18fr); gap: 22px; }
.target-progress-list { padding-right: 20px; border-right: 1px solid #e4e9ef; }
.target-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
.target-summary div { display: grid; gap: 5px; }
.target-summary span { color: #8590a2; font-size: 11px; }
.target-summary strong { font-size: 18px; font-variant-numeric: tabular-nums; }
.target-summary strong.is-teal { color: #0f9185; }
.progress-block + .progress-block { margin-top: 18px; }
.progress-copy { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.progress-copy b { color: #263246; font-size: 13px; }
.progress-block small { display: block; margin-top: 7px; color: #8a95a5; font-size: 11px; }
.target-empty-note { margin-top: 16px; padding: 9px 11px; border-radius: 7px; color: #a36b1e; background: #fff7e8; font-size: 11px; }
.trend-title { display: flex; align-items: center; justify-content: space-between; color: #2e3a4d; font-size: 12px; font-weight: 600; }
.trend-title > div { display: flex; align-items: center; gap: 7px; color: #7f8a9b; font-size: 10px; font-weight: 400; }
.legend-line { width: 18px; border-top: 2px solid; }
.legend-line.is-actual { border-color: #0f9185; }
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
.target-chart { width: 100%; height: 260px; }
@media (max-width: 1180px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.range-text { margin-left: 0; }
.target-layout { grid-template-columns: 1fr; }
.target-progress-list { padding-right: 0; padding-bottom: 18px; border-right: 0; border-bottom: 1px solid #e4e9ef; }
}
@media (max-width: 820px) {
.conversion-page { padding: 10px; }
.page-heading, .heading-meta { align-items: flex-start; flex-direction: column; }
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
.target-summary { grid-template-columns: 1fr; }
}
</style>