更新
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user