Files
zyt/admin/src/views/fans/yeji.vue
T
2026-04-30 14:04:12 +08:00

1385 lines
49 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="yeji-page">
<!-- ── 顶部筛选 ────────────────────────────── -->
<el-card class="filter-card" shadow="never">
<div class="filter-surface">
<div class="filter-row filter-row--top">
<div class="filter-field">
<span class="lbl">渠道来源</span>
<el-select
v-model="selectedChannel"
filterable
placeholder="渠道筛选"
class="filter-select filter-select--channel"
:loading="channelOptionsLoading"
@change="loadData"
>
<el-option label="全部" value="" />
<el-option-group
v-for="g in channelGroups"
:key="g.group_name"
:label="g.group_name"
>
<el-option
v-for="ch in g.channels"
:key="ch.channel_code"
:label="ch.channel_name"
:value="ch.channel_code"
>
<div class="channel-opt-row">
<span class="opt-name">{{ ch.channel_name }}</span>
<span class="opt-count">{{
ch.customer_count > 0 ? ch.customer_count : ''
}}</span>
</div>
</el-option>
</el-option-group>
</el-select>
</div>
<div class="filter-field filter-field--wide">
<span class="lbl">展示部门</span>
<el-select
v-model="selectedDeptIds"
multiple
collapse-tags
collapse-tags-tooltip
filterable
placeholder="默认:所有「中心」部门"
class="filter-select filter-select--dept"
:loading="deptOptionsLoading"
>
<el-option
v-for="d in deptOptions"
:key="d.id"
:label="d.path || d.name"
:value="d.id"
/>
</el-select>
</div>
<div class="filter-field filter-field--date-top">
<span class="lbl">日期范围</span>
<el-date-picker
v-model="customRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始"
end-placeholder="结束"
:clearable="true"
:shortcuts="dateShortcuts"
class="filter-date-picker"
/>
</div>
<div class="filter-actions">
<el-button
type="primary"
class="filter-btn filter-btn--query"
round
:loading="loading"
@click="loadData"
>
<el-icon class="filter-btn__icon"><Search /></el-icon>
查询
</el-button>
<el-button
plain
class="filter-btn filter-btn--reset"
round
:disabled="loading"
@click="handleReset"
>
<el-icon class="filter-btn__icon"><RefreshRight /></el-icon>
重置
</el-button>
</div>
</div>
<p class="filter-hint">
未选日期时展示本月 / 本周 / 今日 / 昨日四张汇总表选日期后为单表排行榜默认按今日见下方说明)。
</p>
<div class="filter-quick">
<span class="filter-quick__title">快捷日期</span>
<div class="date-pills" role="group" aria-label="快捷日期">
<button
v-for="sc in dateShortcuts"
:key="sc.text"
type="button"
class="date-pill"
@click="applyDateShortcut(sc)"
>
{{ sc.text }}
</button>
</div>
</div>
</div>
</el-card>
<!-- <el-alert
v-if="channelFilterNote"
:title="channelFilterNote"
type="info"
show-icon
:closable="false"
class="filter-alert"
/> -->
<!-- ── 目标进度卡片 ── -->
<div
v-if="targetProgressCard || targetProgressLoading"
v-loading="targetProgressLoading"
class="tp-wrap"
>
<div v-if="targetProgressCard" class="tp-card">
<div class="tp-card__title">{{ targetProgressCard.title }}</div>
<div class="tp-card__body">
<div
v-for="sec in targetProgressCard.sections"
:key="sec.dept_id"
class="tp-section"
>
<div class="tp-section__head">
<span class="tp-section__name">{{ sec.dept_name }}目标</span>
<span class="tp-section__target">{{ formatMoneyW(sec.target_amount) }}</span>
</div>
<table class="tp-table">
<tbody>
<tr>
<td class="tp-lbl">昨日诊金</td>
<td class="tp-val">{{ formatInt(sec.yesterday_amount) }}</td>
</tr>
<tr v-if="sec.douyin_amount !== null">
<td class="tp-lbl">抖音总诊金</td>
<td class="tp-val">{{ formatInt(sec.douyin_amount) }}</td>
</tr>
<tr v-if="sec.tuifen_amount !== null">
<td class="tp-lbl">推粉总诊金</td>
<td class="tp-val">{{ formatInt(sec.tuifen_amount) }}</td>
</tr>
<tr>
<td class="tp-lbl">总诊金</td>
<td class="tp-val tp-val--total">{{ formatInt(sec.total_amount) }}</td>
</tr>
<tr>
<td class="tp-lbl">目标进度</td>
<td class="tp-val">{{ formatMoney(sec.target_progress) }}</td>
</tr>
<tr>
<td class="tp-lbl">完成进度</td>
<td class="tp-val" :class="achievementClass(sec.completion_rate)">{{ formatAchievement(sec.completion_rate) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ── 业绩表4 /N ────────────────── -->
<div v-loading="loading" class="tables-wrap">
<div v-if="!loading && tables.length === 0" class="empty-tip">
<el-empty description="暂无数据" />
</div>
<div
v-for="(tb, idx) in tables"
:key="idx"
class="yeji-table"
>
<div class="yeji-table__header">
{{ buildTableTitle(tb) }}
</div>
<table class="yeji-table__body">
<thead>
<tr>
<th class="col-dept">部门</th>
<th>
<span>进线数据</span>
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
add_external_contact 事件数按接待企微成员归属部门定渠道时须客户带对应企微标签<br />
<template v-if="tb.channel_name">
<b>名称含二中心的部门</b>在选定渠道下进线计 <b>0</b>
</template>
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
</el-tooltip>
</th>
<th>
<span>合计业绩</span>
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 300px; line-height: 1.7; font-size: 12px">
按订单<b>创建时间</b>统计处方订单列表业绩合计口径一致<br />
<b>不受渠道筛选影响</b>展示部门下全部订单
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
</el-tooltip>
</th>
<th v-if="tb.channel_name" class="col-completed">
<span>{{ buildChannelColTitle(tb, '业绩') }}</span>
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 340px; line-height: 1.7; font-size: 12px">
处方订单列表业绩<b>同口径</b>按订单 <b>create_time</b> 归日<br />
fulfillment_status 4<br />
渠道判定仅用 EXISTS该患者有任一挂号 <b>channels 命中字典 + status=3</b>不限挂号时点)。
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
</el-tooltip>
</th>
<th>接诊诊单</th>
<th v-if="tb.channel_name" class="col-completed">
<span>{{ buildChannelColTitle(tb, '成交单') }}</span>
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
{{ tb.channel_name }}业绩同源统计区间内 <b>非取消业务订单</b> 条数fulfillment_status 4),<br />
按订单 <b>create_time</b> 归日渠道判定同业绩列患者须有对应 channels 的已完成挂号)。
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
</el-tooltip>
</th>
<th>平均单价</th>
<th v-if="tb.channel_name" class="col-completed">
<span>{{ buildChannelColTitle(tb, '客单价') }}</span>
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 280px; line-height: 1.7; font-size: 12px">
{{ tb.channel_name }}业绩 ÷ {{ tb.channel_name }}成交单数均值)。
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
</el-tooltip>
</th>
<th>
<span>ROI</span>
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<template v-if="tb.channel_name">
有渠道时<b>合计行 ROI</b> = <b>{{ tb.channel_name }}业绩合计</b>与上表该渠道业绩列合计一致<b>不含名称含二中心的部门</b>)÷ <b>投放成本合计</b><br />
部门行 ROI = 该部门 <b>{{ tb.channel_name }}业绩</b> ÷ 该部门 <b>投放成本</b>二中心部门该渠道业绩与进线为 0合计业绩列无关
</template>
<template v-else>
<b>合计业绩</b> ÷ <b>投放成本</b>投放成本按各部门<b>进线条数占比</b>从总成本中分摊
</template>
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
</el-tooltip>
</th>
<th>
<span>投放成本</span>
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
区间与渠道与账务表一致后得到<b>总投放成本</b><b>单进线成本</b> = 总成本 ÷ 各展示部门<b>进线合计</b><br />
各部门行投放成本 = 单进线成本 × 该部门进线四舍五入尾差并入进线最多的部门)。
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
</el-tooltip>
</th>
</tr>
</thead>
<tbody>
<tr v-for="r in tb.rows" :key="r.dept_id">
<td class="col-dept">{{ r.dept_name }}</td>
<td>{{ formatInt(r.lead_count) }}</td>
<td>{{ formatMoney(r.performance_amount) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatMoney(r.completed_performance_amount) }}</td>
<td>{{ formatInt(r.consult_count) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatInt(r.channel_consult_count) }}</td>
<td>{{ formatNumber(r.avg_price) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatNumber(r.channel_avg_price) }}</td>
<td :class="roiClass(r.roi)">{{ formatRoi(r.roi) }}</td>
<td>{{ formatMoney(r.cost_amount) }}</td>
</tr>
<tr class="total-row">
<td class="col-dept">合计</td>
<td>{{ formatInt(tb.total.lead_count) }}</td>
<td>{{ formatMoney(tb.total.performance_amount) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatMoney(tb.total.completed_performance_amount) }}</td>
<td>{{ formatInt(tb.total.consult_count) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatInt(tb.total.channel_consult_count) }}</td>
<td>{{ formatNumber(tb.total.avg_price) }}</td>
<td v-if="tb.channel_name" class="col-completed">{{ formatNumber(tb.total.channel_avg_price) }}</td>
<td :class="roiClass(tb.total.roi)">{{ formatRoi(tb.total.roi) }}</td>
<td>{{ formatMoney(tb.total.cost_amount) }}</td>
</tr>
</tbody>
</table>
<div class="yeji-table__foot">
<span>区间{{ tb.start_date }} ~ {{ tb.end_date }}</span>
<span v-if="tb.channel_name"> · 渠道{{ tb.channel_name }}</span>
<span v-if="tb.total.cost_per_lead != null && tb.total.cost_per_lead > 0">
· 单进线成本{{ formatMoney(tb.total.cost_per_lead) }}
</span>
</div>
</div>
</div>
<!-- 医助排行榜选自定义日期时与所选区间一致未选时仅统计今日 -->
<div
v-if="leaderboardBlock || leaderboardsLoading"
class="leaderboards-wrap"
v-loading="leaderboardsLoading"
>
<template v-if="leaderboardBlock">
<p class="leaderboard-hint">
{{ leaderboardBlock.range_note }}
<span class="muted">
统计区间{{ leaderboardBlock.start_date }}{{ leaderboardBlock.end_date }}
<template v-if="!customRange">
未选自定义日期时排行榜仅统计今日与上方多张业绩表区间无关
</template>
</span>
</p>
<div class="tables-wrap">
<template v-for="lb in leaderboardBlock.leaderboards" :key="lb.dept_id">
<div v-if="lb.rows.length > 0" class="yeji-table leaderboard-table">
<div class="yeji-table__header">
{{ lb.dept_name }} · 医助排行榜
<span v-if="leaderboardBlock.channel_name" class="leaderboard-sub">
{{ leaderboardBlock.channel_name }}
</span>
</div>
<table class="yeji-table__body">
<thead>
<tr>
<th class="col-rank">排名</th>
<th class="col-abbr">部门</th>
<th class="col-name">姓名</th>
<th>诊金</th>
<th>接诊率<br /><span class="th-sub">/进线</span></th>
</tr>
</thead>
<tbody>
<tr v-for="r in lb.rows" :key="r.admin_id">
<td class="col-rank">{{ r.rank }}</td>
<td class="col-abbr">{{ r.dept_abbr }}</td>
<td class="col-name">{{ r.name }}</td>
<td>{{ formatMoney(r.fee_amount) }}</td>
<td>{{ formatMoney(r.consult_rate) }}</td>
</tr>
</tbody>
</table>
<div class="yeji-table__foot yeji-table__foot--left">
<span>诊金单位 · 接诊率 = 诊金 ÷ 进线/进线保留 1 位小数有渠道时进线含标签过滤与二中心规则</span>
</div>
</div>
</template>
</div>
<div
v-if="!leaderboardsLoading && leaderboardVisibleTables === 0"
class="empty-tip leaderboard-empty"
>
<el-empty description="当前筛选下暂无医助排行数据" />
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
yejiStatsChannelOptions,
yejiStatsDeptOptions,
yejiStatsLeaderboard,
yejiStatsMulti,
yejiStatsOverview,
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
interface DeptOption {
id: number
name: string
pid: number
path: string
}
interface ChannelOption {
channel_code: string
channel_name: string
source_tag_id: string
source_group_name: string
customer_count: number
}
interface ChannelGroup {
group_name: string
channels: ChannelOption[]
}
interface YejiRow {
dept_id: number
dept_name: string
lead_count: number
consult_count: number
channel_consult_count: number
performance_amount: number
completed_performance_amount: number
avg_price: number | null
channel_avg_price: number | null
cost_amount: number
roi: number | null
}
interface YejiTable {
label: string
start_date: string
end_date: string
channel_code: string
channel_name: string
channel_group: string
channel_filter_note: string
rows: YejiRow[]
total: {
lead_count: number
consult_count: number
channel_consult_count: number
performance_amount: number
completed_performance_amount: number
avg_price: number | null
channel_avg_price: number | null
cost_amount: number
cost_per_lead: number | null
roi: number | null
}
}
interface LeaderboardPack {
start_date: string
end_date: string
channel_code: string
channel_name: string
channel_filter_note: string
range_note: string
leaderboards: Array<{
dept_id: number
dept_name: string
dept_abbr: string
rows: Array<{
rank: number
admin_id: number
name: string
dept_abbr: string
fee_amount: number
consult_rate: number
}>
}>
}
const loading = ref(false)
const deptOptions = ref<DeptOption[]>([])
const deptOptionsLoading = ref(false)
const selectedDeptIds = ref<number[]>([])
const channelGroups = ref<ChannelGroup[]>([])
const channelOptionsLoading = ref(false)
const selectedChannel = ref<string>('')
const customRange = ref<[string, string] | null>(null)
const tables = ref<YejiTable[]>([])
const leaderboardBlock = ref<LeaderboardPack | null>(null)
const leaderboardsLoading = ref(false)
// ── 目标进度卡片 ──────────────────────────────
interface TargetProgressSection {
dept_id: number
dept_name: string
target_amount: number
total_amount: number
yesterday_amount: number
douyin_amount: number | null // 名称含「自媒体1」的子部门合计
tuifen_amount: number | null // 名称含「自媒体3」或「自媒体4」的子部门合计
target_progress: number // 按已过天数均摊的目标进度
completion_rate: number | null // 总诊金 / 目标金额
}
interface TargetProgressCard {
title: string
sections: TargetProgressSection[]
}
const targetProgressCard = ref<TargetProgressCard | null>(null)
const targetProgressLoading = ref(false)
function ymCurrent(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function daysInMonth(ym: string): number {
const [y, m] = ym.split('-').map(Number)
return new Date(y, m, 0).getDate()
}
async function loadTargetProgress() {
targetProgressLoading.value = true
targetProgressCard.value = null
try {
const ym = ymCurrent()
const targetRes: any = await deptPerformanceTargetMonthMatrix({ year_month: ym })
const treeRows: any[] = targetRes?.rows ?? []
if (!treeRows.length) return
// 从目标树平铺,找到 depth>=1 且目标>0 的节点作为卡片分区
function flatWithDepth(nodes: any[], depth = 0): Array<{ node: any; depth: number }> {
const r: Array<{ node: any; depth: number }> = []
for (const n of nodes ?? []) {
r.push({ node: n, depth })
r.push(...flatWithDepth(n.children ?? [], depth + 1))
}
return r
}
const all = flatWithDepth(treeRows)
const cardDepts = all
.filter(x => x.depth >= 1 && Number(x.node.target_amount) > 0)
.map(x => x.node)
if (!cardDepts.length) return
// 用 deptOptions(完整部门列表,含 pid)做层级查找
// 目标树只包含有目标的节点,无法得到 0 目标的子部门(如自媒体1/3/4)
function descIdsFromOptions(deptId: number): number[] {
const result: number[] = []
const queue = [deptId]
while (queue.length) {
const pid = queue.shift()!
for (const d of deptOptions.value) {
if (Number(d.pid) === pid) {
result.push(d.id)
queue.push(d.id)
}
}
}
return result
}
const allIds = new Set<number>()
const specs = cardDepts.map(dept => {
const deptId = Number(dept.dept_id)
const childIds = descIdsFromOptions(deptId)
const totalIds = childIds.length ? childIds : [deptId]
totalIds.forEach(id => allIds.add(id))
return {
dept_id: deptId,
dept_name: String(dept.dept_name),
target_amount: Number(dept.target_amount),
totalIds,
}
})
if (!allIds.size) return
// 从 channelGroups 找自媒体1/3/4 的 channel_code
const allChannels = channelGroups.value.flatMap(g => g.channels)
const douyinCode = allChannels.find(c => c.channel_name.includes('自媒体1'))?.channel_code
const t3Code = allChannels.find(c => c.channel_name.includes('自媒体3'))?.channel_code
const t4Code = allChannels.find(c => c.channel_name.includes('自媒体4'))?.channel_code
const deptIdsStr = Array.from(allIds).join(',')
// 并发拉取:无渠道(总诊金/昨日)+ 各渠道(completed_performance_amount
const [perfRes, douyinRes, t3Res, t4Res] = await Promise.all([
yejiStatsMulti({ dept_ids: deptIdsStr }) as any,
douyinCode ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: douyinCode }) as any : Promise.resolve(null),
t3Code ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: t3Code }) as any : Promise.resolve(null),
t4Code ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: t4Code }) as any : Promise.resolve(null),
])
const getMonthTbl = (res: any) => (res?.tables ?? []).find((t: any) => String(t.label).includes('月')) ?? res?.tables?.[0]
const getYdayTbl = (res: any) => (res?.tables ?? []).find((t: any) => String(t.label).includes('昨')) ?? res?.tables?.[res?.tables?.length - 1]
function buildMap(tbl: any, field: string): Map<number, number> {
const m = new Map<number, number>()
for (const r of tbl?.rows ?? []) m.set(Number(r.dept_id), Number(r[field] ?? 0))
return m
}
const monthPerf = buildMap(getMonthTbl(perfRes), 'performance_amount')
const ydayPerf = buildMap(getYdayTbl(perfRes), 'performance_amount')
const douyinPerf = douyinRes ? buildMap(getMonthTbl(douyinRes), 'completed_performance_amount') : null
const t3Perf = t3Res ? buildMap(getMonthTbl(t3Res), 'completed_performance_amount') : null
const t4Perf = t4Res ? buildMap(getMonthTbl(t4Res), 'completed_performance_amount') : null
const sumIds = (m: Map<number, number>, ids: number[]) => ids.reduce((s, id) => s + (m.get(id) ?? 0), 0)
const today = new Date()
const totalDays = daysInMonth(ym)
const elapsed = today.getDate()
const sections: TargetProgressSection[] = specs.map(spec => {
const total = sumIds(monthPerf, spec.totalIds)
const yesterday = sumIds(ydayPerf, spec.totalIds)
const douyinAmt = douyinPerf ? sumIds(douyinPerf, spec.totalIds) : null
const t3Amt = t3Perf ? sumIds(t3Perf, spec.totalIds) : null
const t4Amt = t4Perf ? sumIds(t4Perf, spec.totalIds) : null
// 推粉 = 自媒体3 + 自媒体4,有任一即显示
const tuifenAmt = (t3Amt !== null || t4Amt !== null) ? (t3Amt ?? 0) + (t4Amt ?? 0) : null
const targetProgress = Math.round(spec.target_amount * (elapsed / totalDays) * 100) / 100
const completion = spec.target_amount > 0 ? total / spec.target_amount : null
return {
dept_id: spec.dept_id,
dept_name: spec.dept_name,
target_amount: spec.target_amount,
total_amount: total,
yesterday_amount: yesterday,
douyin_amount: douyinAmt && douyinAmt > 0 ? douyinAmt : null,
tuifen_amount: tuifenAmt && tuifenAmt > 0 ? tuifenAmt : null,
target_progress: targetProgress,
completion_rate: completion,
}
})
// 取根部门名作为卡片标题前缀
const rootName = treeRows[0]?.dept_name ?? ''
const m = today.getMonth() + 1
const d = today.getDate()
targetProgressCard.value = {
title: `${rootName}诊金-${m}.${d}`,
sections,
}
} catch {
// 静默失败,不影响主页面
} finally {
targetProgressLoading.value = false
}
}
const channelFilterNote = computed(() => {
for (const t of tables.value) {
if (t.channel_filter_note) return t.channel_filter_note
}
return ''
})
const leaderboardVisibleTables = computed(() => {
const list = leaderboardBlock.value?.leaderboards
if (!list) return 0
return list.filter((x) => x.rows.length > 0).length
})
const dateShortcuts = [
{
text: '昨天',
value: () => {
const d = new Date()
d.setDate(d.getDate() - 1)
return [d, d]
},
},
{
text: '最近七天',
value: () => {
const e = new Date()
const s = new Date()
s.setDate(s.getDate() - 6)
return [s, e]
},
},
{
text: '本月',
value: () => {
const e = new Date()
const s = new Date(e.getFullYear(), e.getMonth(), 1)
return [s, e]
},
},
{
text: '今日',
value: () => {
const d = new Date()
return [d, d]
},
},
{
text: '本周',
value: () => {
const e = new Date()
const s = new Date()
const day = s.getDay() || 7
s.setDate(s.getDate() - (day - 1))
return [s, e]
},
},
{
text: '上月',
value: () => {
const e0 = new Date()
const s = new Date(e0.getFullYear(), e0.getMonth() - 1, 1)
const e = new Date(e0.getFullYear(), e0.getMonth(), 0)
return [s, e]
},
},
]
function applyDateShortcut(shortcut: (typeof dateShortcuts)[number]) {
const pair = shortcut.value() as [Date, Date]
customRange.value = [formatLocalYmd(pair[0]), formatLocalYmd(pair[1])]
loadData()
}
async function loadDeptOptions() {
deptOptionsLoading.value = true
try {
const res: DeptOption[] = await yejiStatsDeptOptions()
deptOptions.value = (res || []).sort((a, b) => {
const aIsCenter = a.name.includes('中心') ? -1 : 1
const bIsCenter = b.name.includes('中心') ? -1 : 1
if (aIsCenter !== bIsCenter) return aIsCenter - bIsCenter
return a.id - b.id
})
} catch (_e) {
// ignore
} finally {
deptOptionsLoading.value = false
}
}
async function loadChannelOptions() {
channelOptionsLoading.value = true
try {
const res: any = await yejiStatsChannelOptions()
const groups: ChannelGroup[] = res?.groups || []
channelGroups.value = groups
} catch (_e) {
// ignore
} finally {
channelOptionsLoading.value = false
}
}
async function loadLeaderboard(range: { start: string; end: string }) {
leaderboardsLoading.value = true
try {
const p: Record<string, any> = {
start_date: range.start,
end_date: range.end,
}
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
const res: any = await yejiStatsLeaderboard(p as any)
leaderboardBlock.value = {
start_date: res.start_date,
end_date: res.end_date,
channel_code: res.channel_code || '',
channel_name: res.channel_name || '',
channel_filter_note: res.channel_filter_note || '',
range_note: res.range_note || '',
leaderboards: res.leaderboards || [],
}
} catch (_e) {
leaderboardBlock.value = null
} finally {
leaderboardsLoading.value = false
}
}
async function loadData() {
loading.value = true
leaderboardBlock.value = null
try {
const baseParams: Record<string, any> = {}
if (selectedDeptIds.value.length > 0) {
baseParams.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
baseParams.channel_code = selectedChannel.value
}
// 自定义日期 → 单表 overview 模式;否则 → 多表 multi
if (customRange.value && customRange.value[0] && customRange.value[1]) {
const [s, e] = customRange.value
const params: Record<string, any> = { ...baseParams, start_date: s, end_date: e }
const res: any = await yejiStatsOverview(params as any)
tables.value = [
{
label: s === e ? s : `${s} ~ ${e}`,
start_date: res.start_date,
end_date: res.end_date,
channel_code: res.channel_code || '',
channel_name: res.channel_name || '',
channel_group: res.channel_group || '',
channel_filter_note: res.channel_filter_note || '',
rows: res.rows || [],
total: res.total,
},
]
await loadLeaderboard({ start: res.start_date, end: res.end_date })
return
}
const res: any = await yejiStatsMulti(baseParams as any)
tables.value = (res?.tables || []).map((t: any) => ({
label: t.label,
start_date: t.start_date,
end_date: t.end_date,
channel_code: t.channel_code || '',
channel_name: t.channel_name || '',
channel_group: t.channel_group || '',
channel_filter_note: t.channel_filter_note || '',
rows: t.rows || [],
total: t.total,
}))
const ymd = formatLocalYmd(new Date())
await loadLeaderboard({ start: ymd, end: ymd })
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载失败')
} finally {
loading.value = false
}
}
function handleReset() {
selectedDeptIds.value = []
customRange.value = null
selectedChannel.value = ''
loadData()
}
// 表标题:参考截图「【4月】各分部 自媒体1 数据」格式
function buildTableTitle(t: YejiTable): string {
const channelLbl = t.channel_name ? ` · ${t.channel_name}` : ''
return `${t.label}${channelLbl}`
}
// 表头:「{渠道名}业绩」(未选渠道时该列被 v-if 隐藏,因为它和「合计业绩」同值)
function buildCompletedColTitle(t: YejiTable): string {
return t.channel_name ? `${t.channel_name}业绩` : '合计业绩'
}
// 通用:「{渠道名}{后缀}」,仅选渠道时使用
function buildChannelColTitle(t: YejiTable, suffix: string): string {
return `${t.channel_name}${suffix}`
}
// ── 格式化 ───────────────────────────────
function formatInt(v: any) {
if (v === null || v === undefined || v === '') return '0'
return Number(v).toLocaleString('en-US')
}
function formatMoney(v: any) {
if (v === null || v === undefined || v === '') return '0.0'
return Number(v).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
function formatNumber(v: any) {
if (v === null || v === undefined || v === '') return '—'
return Number(v).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
function formatRoi(v: any) {
if (v === null || v === undefined || v === '') return '—'
return Number(v).toFixed(1)
}
function roiClass(v: any) {
if (v === null || v === undefined || v === '') return ''
const n = Number(v)
if (n >= 1) return 'roi-good'
if (n >= 0.8) return 'roi-mid'
return 'roi-bad'
}
function formatAchievement(v: number | null): string {
if (v === null) return '—'
return (v * 100).toFixed(2) + '%'
}
function achievementClass(v: number | null): string {
if (v === null) return ''
if (v >= 1) return 'tp-good'
if (v >= 0.8) return 'tp-mid'
return 'tp-bad'
}
function formatMoneyW(v: number): string {
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return String(v)
}
/** 本地日历日 yyyy-mm-dd(排行榜「今日」、日期快捷与日期控件一致) */
function formatLocalYmd(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
onMounted(async () => {
await Promise.all([loadDeptOptions(), loadChannelOptions()])
loadData()
loadTargetProgress()
})
</script>
<style lang="scss" scoped>
.yeji-page {
padding: 16px;
}
.filter-card {
margin-bottom: 16px;
border-radius: 12px;
border: 1px solid var(--el-border-color-lighter);
overflow: hidden;
:deep(.el-card__body) {
padding: 0;
}
}
.filter-surface {
padding: 18px 20px 16px;
background: linear-gradient(180deg, var(--el-fill-color-blank) 0%, var(--el-fill-color-light) 100%);
}
.filter-row {
&--top {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 16px 20px;
}
}
.filter-field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
.lbl {
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
letter-spacing: 0.02em;
}
&--wide {
flex: 1 1 280px;
min-width: 240px;
}
&--date-top {
flex: 1 1 320px;
min-width: 260px;
}
}
.filter-select {
width: 100%;
&--channel {
min-width: 220px;
max-width: 320px;
}
&--dept {
min-width: 280px;
}
}
.filter-date-picker {
width: 100%;
max-width: 320px;
}
.filter-hint {
margin: 14px 0 0;
padding: 10px 12px;
font-size: 12px;
line-height: 1.55;
color: var(--el-text-color-secondary);
background: var(--el-fill-color);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
}
.filter-quick {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.filter-quick__title {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.date-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.date-pill {
appearance: none;
margin: 0;
border: 1px solid var(--el-border-color);
background: var(--el-bg-color);
color: var(--el-text-color-primary);
font-size: 13px;
font-weight: 500;
padding: 7px 16px;
border-radius: 999px;
cursor: pointer;
line-height: 1.35;
transition:
border-color 0.18s ease,
background 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease,
transform 0.12s ease;
&:hover {
border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
&:active {
transform: scale(0.97);
}
&:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
}
.filter-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-left: auto;
flex-shrink: 0;
}
.filter-btn {
padding: 10px 22px;
font-weight: 600;
letter-spacing: 0.02em;
&__icon {
margin-right: 6px;
font-size: 16px;
}
&--query {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
&--reset {
border: 1px solid var(--el-border-color);
background: var(--el-bg-color);
color: var(--el-text-color-regular);
&:hover {
border-color: var(--el-color-info-light-5);
color: var(--el-color-primary);
background: var(--el-fill-color-light);
}
&:disabled {
opacity: 0.55;
}
}
}
.channel-opt-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding-right: 4px;
}
.opt-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.opt-count {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.filter-alert {
margin-bottom: 12px;
}
.tables-wrap {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(560px, 1fr));
gap: 16px;
}
.leaderboards-wrap {
margin-top: 20px;
min-height: 48px;
}
.leaderboard-hint {
margin: 0 0 12px;
font-size: 13px;
color: var(--el-text-color-primary);
line-height: 1.6;
.muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
/* ── 目标进度卡片 ── */
.tp-wrap {
min-height: 40px;
}
.tp-card {
display: inline-block;
border: 2px solid #e8b800;
border-radius: 6px;
overflow: hidden;
font-size: 13px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&__title {
background: #ffe600;
color: #1f1f1f;
font-weight: 700;
font-size: 15px;
text-align: center;
padding: 8px 20px;
letter-spacing: 1px;
}
&__body {
display: flex;
gap: 0;
}
}
.tp-section {
min-width: 200px;
border-right: 1px solid #e8d800;
&:last-child {
border-right: none;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: #fff8c0;
padding: 6px 12px;
border-bottom: 1px solid #e8d800;
}
&__name {
font-weight: 700;
font-size: 13px;
color: #333;
}
&__target {
font-size: 13px;
font-weight: 700;
color: #b45309;
}
}
.tp-table {
width: 100%;
border-collapse: collapse;
.tp-lbl {
padding: 5px 10px;
color: #555;
font-size: 12px;
border-bottom: 1px solid #f0e860;
white-space: nowrap;
}
.tp-val {
padding: 5px 12px;
font-weight: 600;
text-align: right;
border-bottom: 1px solid #f0e860;
white-space: nowrap;
color: #111;
&--total {
color: #b45309;
}
}
tr:last-child td {
border-bottom: none;
}
}
.tp-good { color: #16a34a !important; font-weight: 700; }
.tp-mid { color: #d97706 !important; font-weight: 700; }
.tp-bad { color: #dc2626 !important; font-weight: 700; }
.leaderboard-sub {
font-size: 13px;
font-weight: 500;
}
.leaderboard-table {
.th-sub {
display: block;
font-size: 11px;
font-weight: 500;
opacity: 0.85;
}
.col-rank {
width: 52px;
font-weight: 600;
}
.col-abbr {
width: 48px;
}
.col-name {
min-width: 72px;
font-weight: 500;
}
}
.leaderboard-empty {
grid-column: 1 / -1;
}
.yeji-table__foot--left {
text-align: left;
}
.empty-tip {
grid-column: 1 / -1;
background: #fff;
border-radius: 6px;
padding: 32px 0;
}
.yeji-table {
background: #fff;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
border: 1px solid #e6e6e6;
&__header {
background: #ffe600; // 仿截图的黄色标题条
color: #1f1f1f;
font-weight: 700;
font-size: 16px;
text-align: center;
padding: 10px 0;
letter-spacing: 1px;
}
&__body {
width: 100%;
border-collapse: collapse;
text-align: center;
font-size: 13px;
color: #333;
thead th {
background: #ffe600;
color: #1f1f1f;
font-weight: 600;
padding: 8px 6px;
border: 1px solid #d8d8d8;
.col-info {
margin-left: 4px;
color: #6b7280;
cursor: help;
vertical-align: -2px;
font-size: 13px;
}
}
tbody td {
padding: 8px 6px;
border: 1px solid #e6e6e6;
white-space: nowrap;
}
.col-dept {
width: 80px;
font-weight: 600;
background: #fafafa;
}
.col-completed {
color: #0d9488;
font-weight: 600;
background: #f0fdfa;
}
.total-row {
font-weight: 700;
background: #fff8d6;
}
.roi-good {
color: #16a34a;
font-weight: 600;
}
.roi-mid {
color: #d97706;
font-weight: 600;
}
.roi-bad {
color: #dc2626;
font-weight: 600;
}
}
&__foot {
padding: 6px 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: #fafafa;
border-top: 1px solid #e6e6e6;
text-align: right;
}
}
</style>