feat: 财务对账页面 — GET /api/finance/daily-summary + Finance.vue
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user