feat: 财务对账页面 — GET /api/finance/daily-summary + Finance.vue

This commit is contained in:
SmartUp Developer
2026-07-03 15:28:06 +08:00
parent 9997b9d56a
commit 5e99abcbc5
7 changed files with 1006 additions and 1 deletions
+8
View File
@@ -54,6 +54,13 @@
<small>外部请求耗时慢接口汇总</small>
</span>
</router-link>
<router-link to="/finance" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><TrendCharts /></el-icon></span>
<span class="nav-copy">
<strong>财务对账</strong>
<small>昨日收入上游消费净利润</small>
</span>
</router-link>
</nav>
</div>
</aside>
@@ -101,6 +108,7 @@ import {
User,
SwitchButton,
Operation,
TrendCharts,
} from '@element-plus/icons-vue'
const router = useRouter()
+1
View File
@@ -21,6 +21,7 @@ const router = createRouter({
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
{ path: 'external-api-logs', component: () => import('@/views/ExternalApiLogs.vue') },
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
{ path: 'finance', component: () => import('@/views/Finance.vue') },
{ path: 'page/:id', redirect: '/custom-pages' },
],
},
+345
View File
@@ -0,0 +1,345 @@
<template>
<div class="finance-page">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<h1 class="page-title">
<el-icon class="title-icon"><TrendCharts /></el-icon>
财务对账
</h1>
<p class="page-subtitle">实时计算数据不入库失败项不计入利润避免虚高</p>
</div>
<div class="header-actions">
<el-date-picker
v-model="selectedDate"
type="date"
placeholder="选择日期(默认昨天)"
:disabled-date="disabledDate"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="date-picker"
/>
<el-button type="primary" :loading="loading" @click="loadData" class="refresh-btn">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- Error banner -->
<el-alert v-if="fetchError" :title="fetchError" type="error" show-icon :closable="false" class="error-alert" />
<!-- Summary cards -->
<div v-if="summary" class="summary-cards">
<div class="stat-card revenue">
<div class="stat-label">网站收入</div>
<div class="stat-value">{{ formatAmount(summary.total_revenue) }}</div>
<div class="stat-sub">{{ summary.date }}</div>
</div>
<div class="stat-card cost">
<div class="stat-label">上游消费</div>
<div class="stat-value">{{ formatAmount(summary.total_cost) }}</div>
<div class="stat-sub">{{ enabledUpstreamCount }} 个上游</div>
</div>
<div class="stat-card net" :class="summary.net_income >= 0 ? 'positive' : 'negative'">
<div class="stat-label">净收入</div>
<div class="stat-value">{{ formatAmount(summary.net_income) }}</div>
<div class="stat-sub">毛利率 {{ summary.margin_percent.toFixed(2) }}%</div>
</div>
<div class="stat-card failed" :class="summary.failed_count > 0 ? 'has-failures' : ''">
<div class="stat-label">失败项</div>
<div class="stat-value">{{ summary.failed_count }}</div>
<div class="stat-sub">{{ summary.failed_count > 0 ? '部分数据缺失,利润可能偏低' : '全部统计成功' }}</div>
</div>
</div>
<!-- Empty state -->
<el-empty v-else-if="!loading && !fetchError" description="点击刷新加载昨日对账数据" class="empty-state">
<el-button type="primary" @click="loadData">立即加载</el-button>
</el-empty>
<el-skeleton v-if="loading" :rows="8" animated class="skeleton" />
<!-- Tables -->
<template v-if="summary && !loading">
<!-- Website Revenue Detail -->
<div class="section">
<div class="section-header">
<span class="section-title">
<el-icon><OfficeBuilding /></el-icon>
网站收入明细
</span>
<el-tag :type="allWebsitesOk ? 'success' : 'danger'" size="small">
{{ summary.website_items.filter(i => i.status === 'success').length }} /
{{ summary.website_items.length }} 成功
</el-tag>
</div>
<el-table :data="summary.website_items" class="detail-table" stripe>
<el-table-column label="网站名称" prop="name" min-width="180" />
<el-table-column label="收入" min-width="140" align="right">
<template #default="{ row }">
<span v-if="row.status === 'success'" class="amount-positive">
{{ formatAmount(row.amount) }}
</span>
<span v-else class="amount-zero"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="错误信息" min-width="260">
<template #default="{ row }">
<span v-if="row.error" class="error-text">{{ row.error }}</span>
<span v-else class="ok-text"></span>
</template>
</el-table-column>
</el-table>
</div>
<!-- Upstream Cost Detail -->
<div class="section">
<div class="section-header">
<span class="section-title">
<el-icon><Connection /></el-icon>
上游消费明细
</span>
<el-tag :type="allUpstreamsOk ? 'success' : 'danger'" size="small">
{{ summary.upstream_items.filter(i => i.status === 'success').length }} /
{{ summary.upstream_items.length }} 成功
</el-tag>
</div>
<el-table :data="summary.upstream_items" class="detail-table" stripe>
<el-table-column label="上游名称" prop="name" min-width="180" />
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.upstream_type === 'sub2api' ? 'primary' : row.upstream_type === 'new_api' ? 'warning' : 'info'"
size="small"
>
{{ row.upstream_type === 'sub2api' ? 'Sub2API' : row.upstream_type === 'new_api' ? 'New-API' : '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="消费" min-width="140" align="right">
<template #default="{ row }">
<span v-if="row.status === 'success'" class="amount-cost">
{{ formatAmount(row.amount) }}
</span>
<span v-else class="amount-zero"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="错误信息" min-width="260">
<template #default="{ row }">
<span v-if="row.error" class="error-text">{{ row.error }}</span>
<span v-else class="ok-text"></span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { TrendCharts, Refresh, OfficeBuilding, Connection } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
interface FinanceItem {
id: number
name: string
upstream_type: string
amount: number
status: 'success' | 'failed'
error: string | null
}
interface DailySummary {
date: string
total_revenue: number
total_cost: number
net_income: number
margin_percent: number
website_items: FinanceItem[]
upstream_items: FinanceItem[]
failed_count: number
success: boolean
}
const auth = useAuthStore()
const loading = ref(false)
const fetchError = ref<string | null>(null)
const summary = ref<DailySummary | null>(null)
const selectedDate = ref<string | null>(null)
const allWebsitesOk = computed(() => summary.value?.website_items.every(i => i.status === 'success') ?? false)
const allUpstreamsOk = computed(() => summary.value?.upstream_items.every(i => i.status === 'success') ?? false)
const enabledUpstreamCount = computed(() => summary.value?.upstream_items.length ?? 0)
function disabledDate(d: Date) {
return d > new Date()
}
function formatAmount(val: number): string {
if (val === 0) return '$0.000000'
return '$' + val.toFixed(6)
}
async function loadData() {
loading.value = true
fetchError.value = null
try {
const params = new URLSearchParams()
if (selectedDate.value) params.set('date', selectedDate.value)
const resp = await fetch(`/api/finance/daily-summary?${params}`, {
headers: { Authorization: `Bearer ${auth.token}` },
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.detail || `HTTP ${resp.status}`)
}
const data: DailySummary = await resp.json()
summary.value = data
if (data.failed_count > 0) {
ElMessage.warning(`${data.failed_count} 个数据源统计失败,利润数据可能偏低`)
} else {
ElMessage.success('对账数据加载成功')
}
} catch (e: any) {
fetchError.value = e.message || '请求失败'
ElMessage.error(fetchError.value ?? '请求失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.finance-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
/* ── Header ── */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 28px;
}
.page-title {
font-size: 22px;
font-weight: 700;
color: var(--el-text-color-primary);
margin: 0 0 6px;
display: flex;
align-items: center;
gap: 8px;
}
.title-icon {
color: var(--el-color-primary);
}
.page-subtitle {
font-size: 13px;
color: var(--el-text-color-secondary);
margin: 0;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.date-picker { width: 180px; }
.refresh-btn { min-width: 90px; }
/* ── Cards ── */
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
padding: 20px 24px;
transition: box-shadow 0.2s;
}
.stat-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); }
.stat-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
font-weight: 500;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--el-text-color-primary);
margin-bottom: 4px;
font-variant-numeric: tabular-nums;
}
.stat-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.stat-card.net.positive .stat-value { color: #22c55e; }
.stat-card.net.negative .stat-value { color: var(--el-color-danger); }
.stat-card.failed.has-failures {
border-color: var(--el-color-danger-light-5);
background: var(--el-color-danger-light-9);
}
.stat-card.failed.has-failures .stat-value { color: var(--el-color-danger); }
/* ── Section ── */
.section { margin-bottom: 32px; }
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
display: flex;
align-items: center;
gap: 6px;
}
.detail-table { border-radius: 8px; overflow: hidden; }
/* ── Amount styles ── */
.amount-positive { color: #22c55e; font-weight: 600; font-variant-numeric: tabular-nums; }
.amount-cost { color: var(--el-color-warning); font-weight: 600; font-variant-numeric: tabular-nums; }
.amount-zero { color: var(--el-text-color-placeholder); }
/* ── Status ── */
.error-text { color: var(--el-color-danger); font-size: 12px; word-break: break-all; }
.ok-text { color: var(--el-text-color-placeholder); }
/* ── Misc ── */
.error-alert { margin-bottom: 20px; }
.skeleton { margin-top: 16px; }
.empty-state { margin-top: 60px; }
</style>