1385 lines
49 KiB
Vue
1385 lines
49 KiB
Vue
<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>
|