更新
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user