This commit is contained in:
Your Name
2026-04-30 14:04:12 +08:00
parent eb782305e6
commit d3003ce0ac
19 changed files with 1560 additions and 313 deletions
+326
View File
@@ -125,6 +125,57 @@
: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">
@@ -349,6 +400,7 @@ import {
yejiStatsMulti,
yejiStatsOverview,
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
interface DeptOption {
id: number
@@ -440,6 +492,169 @@ 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
@@ -675,6 +890,20 @@ function roiClass(v: any) {
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 {
@@ -687,6 +916,7 @@ function formatLocalYmd(d: Date): string {
onMounted(async () => {
await Promise.all([loadDeptOptions(), loadChannelOptions()])
loadData()
loadTargetProgress()
})
</script>
@@ -924,6 +1154,101 @@ onMounted(async () => {
}
}
/* ── 目标进度卡片 ── */
.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;
@@ -1025,6 +1350,7 @@ onMounted(async () => {
background: #f0fdfa;
}
.total-row {
font-weight: 700;
background: #fff8d6;