639 lines
29 KiB
Vue
639 lines
29 KiB
Vue
<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>
|