Files
zyt/admin/src/views/first_visit/conversion/index.vue
T
2026-08-20 17:47:14 +08:00

703 lines
32 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.search_label || item.name"
:value="item.code"
>
<span class="channel-option" :class="{ 'is-group': item.kind === 'group' }">
<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 visibleMetricCards" :key="metric.key" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
<small>{{ metric.hint }}</small>
</article>
</section>
<section v-if="showRankings" class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}订单量占比排名</h2>
<p>{{ rankingSubject }}归属统计排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
<strong><span>{{ formatNumber(item.value) }} </span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}金额占比排名</h2>
<p>{{ rankingSubject }}归属统计仅含未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></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="220" 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="72" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
<el-table-column label="接诊金额" min-width="104" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column>
<el-table-column label="开口率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column>
<el-table-column label="挂号率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
</el-table-column>
<el-table-column label="预约率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="100" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
<el-table-column v-if="canViewFinance" label="ROI" min-width="72" 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
kind?: 'channel' | 'group'
search_label?: string
}
const FINANCE_METRIC_KEYS = new Set(['account_cost', 'roi'])
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: '', ranking_kind: 'hidden',
can_view_finance: false
},
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 canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
const visibleMetricCards = computed(() =>
canViewFinance.value
? metricCards
: metricCards.filter((card) => !FINANCE_METRIC_KEYS.has(card.key))
)
const mediaChannelGroups = computed(() => {
const groups: Array<{
group_name: string
customer_count: number
channels: MediaChannelOption[]
}> = []
const indexByName = new Map<string, number>()
for (const channel of dashboard.filters.media_channels) {
if (channel.kind === 'group') continue
const groupName = String(channel.group_name || '').trim()
let groupIndex = indexByName.get(groupName)
if (groupIndex === undefined) {
groupIndex = groups.length
indexByName.set(groupName, groupIndex)
groups.push({ group_name: groupName, customer_count: 0, channels: [] })
}
const group = groups[groupIndex]
const searchLabel = groupName !== '' && !channel.name.includes(groupName)
? `${channel.name} ${groupName}`
: ''
group.channels.push(searchLabel === '' ? channel : { ...channel, search_label: searchLabel })
group.customer_count = Math.max(group.customer_count, Number(channel.customer_count || 0))
}
for (const group of groups) {
if (group.group_name === '') continue
const hasSameNameLeaf = group.channels.some((item) => item.name === group.group_name)
if (group.channels.length < 2 && hasSameNameLeaf) continue
group.channels.unshift({
code: `group:${group.group_name}`,
name: `${group.group_name}(全部)`,
group_name: group.group_name,
customer_count: group.customer_count,
kind: 'group'
})
}
return groups
})
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
))
const showRankings = computed(() => rankingKind.value !== 'hidden')
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 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 formatShare(value: any, total: number) {
if (total <= 0) return '0.0%'
return `${(Number(value || 0) / total * 100).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, total: number) {
const numericValue = Number(value || 0)
if (total <= 0 || numericValue <= 0) return '0%'
return `${Math.max(4, Math.min(100, numericValue / total * 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;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 640px;
padding: 16px;
overflow-x: hidden;
box-sizing: border-box;
color: #172033;
background: #f4f6f8;
}
.page-heading,
.filter-strip,
.panel,
.metric-card {
min-width: 0;
box-sizing: border-box;
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: 220px; }
.channel-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
span:last-child {
color: #98a2b3;
font-size: 12px;
}
&.is-group span:first-child {
font-weight: 650;
color: #172033;
}
}
.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 { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
.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; max-height: 340px; overflow-y: auto; padding-right: 4px; }
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
.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; overflow: hidden; }
.detail-table {
width: 100%;
max-width: 100%;
min-width: 0;
:deep(.el-table__inner-wrapper),
:deep(.el-scrollbar) { max-width: 100%; }
: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: 100px minmax(70px, 1fr) 96px; }
.target-summary { grid-template-columns: 1fr; }
}
</style>