Files
zyt/admin/src/views/first_visit/conversion/index.vue
T
2026-08-05 11:08:02 +08:00

476 lines
21 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="loadDashboard" />
</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>
<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>部门层级汇总父级包含其下级数据</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="230" fixed="left">
<template #default="{ row }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length }">
{{ row.name }}
</strong>
</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="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.interview_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'
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: '', open_count_source: ''
},
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
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 })
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '本季度', value: 'quarter' },
{ label: '本年', value: 'year' }
]
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)
return parts.join(' · ')
})
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 }
]
}))
async function loadDashboard() {
loading.value = true
try {
const result: any = await firstVisitConversionOverview(query)
Object.assign(dashboard, emptyDashboard(), result || {})
} catch (error: any) {
ElMessage.error(error?.message || '综合数据加载失败')
} finally {
loading.value = false
}
}
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; }
.employee-select { width: 190px; }
.dept-select { width: 220px; }
.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; }
}
.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 { width: 100%; }
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
.target-summary { grid-template-columns: 1fr; }
}
</style>