feat: persist finance daily summaries
This commit is contained in:
+315
-19
@@ -7,7 +7,7 @@
|
||||
<el-icon class="title-icon"><TrendCharts /></el-icon>
|
||||
财务对账
|
||||
</h1>
|
||||
<p class="page-subtitle">实时计算,数据不入库;失败项不计入利润,避免虚高</p>
|
||||
<p class="page-subtitle">已统计入库数据,定时快照不自动覆盖</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-date-picker
|
||||
@@ -23,6 +23,15 @@
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="summary"
|
||||
:loading="comparing"
|
||||
@click="handleCompare"
|
||||
class="compare-btn"
|
||||
>
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
重新统计并比对
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,12 +43,17 @@
|
||||
<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 class="stat-sub">
|
||||
{{ summary.date }}
|
||||
<span v-if="summary.from_snapshot && summary.computed_at" class="snapshot-badge">
|
||||
· 快照 {{ formatTime(summary.computed_at) }}
|
||||
</span>
|
||||
</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 class="stat-sub">{{ upstreamItemCount }} 个上游</div>
|
||||
</div>
|
||||
<div class="stat-card net" :class="summary.net_income >= 0 ? 'positive' : 'negative'">
|
||||
<div class="stat-label">净收入</div>
|
||||
@@ -70,8 +84,7 @@
|
||||
网站收入明细
|
||||
</span>
|
||||
<el-tag :type="allWebsitesOk ? 'success' : 'danger'" size="small">
|
||||
{{ summary.website_items.filter(i => i.status === 'success').length }} /
|
||||
{{ summary.website_items.length }} 成功
|
||||
{{ successWebsiteCount }} / {{ summary.website_items.length }} 成功
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="summary.website_items" class="detail-table" stripe>
|
||||
@@ -108,8 +121,7 @@
|
||||
上游消费明细
|
||||
</span>
|
||||
<el-tag :type="allUpstreamsOk ? 'success' : 'danger'" size="small">
|
||||
{{ summary.upstream_items.filter(i => i.status === 'success').length }} /
|
||||
{{ summary.upstream_items.length }} 成功
|
||||
{{ successUpstreamCount }} / {{ summary.upstream_items.length }} 成功
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="summary.upstream_items" class="detail-table" stripe>
|
||||
@@ -148,13 +160,128 @@
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Compare Dialog -->
|
||||
<el-dialog
|
||||
v-model="compareDialogVisible"
|
||||
title="重新统计比对结果"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<template v-if="compareResult">
|
||||
<el-alert
|
||||
v-if="!compareResult.has_difference"
|
||||
title="比对结果一致,与已存快照无差异"
|
||||
type="success"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="compare-alert"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
title="发现差异,下方展示快照与实时统计的对比"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="compare-alert"
|
||||
/>
|
||||
<div class="compare-grid">
|
||||
<div class="compare-col">
|
||||
<h4 class="compare-col-title">已存快照</h4>
|
||||
<div class="compare-stat" v-if="compareResult.stored">
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">收入</span>
|
||||
<span class="compare-value">{{ formatAmount(compareResult.stored.total_revenue) }}</span>
|
||||
</div>
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">消费</span>
|
||||
<span class="compare-value">{{ formatAmount(compareResult.stored.total_cost) }}</span>
|
||||
</div>
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">净收入</span>
|
||||
<span class="compare-value" :class="compareResult.stored.net_income >= 0 ? 'positive' : 'negative'">
|
||||
{{ formatAmount(compareResult.stored.net_income) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">失败项</span>
|
||||
<span class="compare-value">{{ compareResult.stored.failed_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="compare-empty">无快照</div>
|
||||
</div>
|
||||
<div class="compare-col">
|
||||
<h4 class="compare-col-title">实时统计</h4>
|
||||
<div class="compare-stat">
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">收入</span>
|
||||
<span class="compare-value">{{ formatAmount(compareResult.current.total_revenue) }}</span>
|
||||
</div>
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">消费</span>
|
||||
<span class="compare-value">{{ formatAmount(compareResult.current.total_cost) }}</span>
|
||||
</div>
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">净收入</span>
|
||||
<span class="compare-value" :class="compareResult.current.net_income >= 0 ? 'positive' : 'negative'">
|
||||
{{ formatAmount(compareResult.current.net_income) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">失败项</span>
|
||||
<span class="compare-value">{{ compareResult.current.failed_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changed items table -->
|
||||
<div v-if="compareResult.diff_items && compareResult.diff_items.length > 0" class="diff-section">
|
||||
<h4 class="diff-title">变动明细({{ compareResult.diff_items.length }} 项)</h4>
|
||||
<el-table :data="compareResult.diff_items" class="detail-table" stripe size="small">
|
||||
<el-table-column label="名称" prop="name" min-width="120" />
|
||||
<el-table-column label="类型" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.side === 'website_items' ? '网站' : '上游' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字段" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ diffFieldLabel(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="diffValueClass(row, 'old')">{{ formatDiffValue(row, 'old') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新值" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="diffValueClass(row, 'new')">{{ formatDiffValue(row, 'new') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="compareDialogVisible = false">关闭</el-button>
|
||||
<el-button
|
||||
v-if="compareResult?.has_difference"
|
||||
type="primary"
|
||||
:loading="overwriting"
|
||||
@click="handleOverwrite"
|
||||
>
|
||||
确认覆盖快照
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</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 { TrendCharts, Refresh, OfficeBuilding, Connection, DataAnalysis } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface FinanceItem {
|
||||
@@ -176,17 +303,49 @@ interface DailySummary {
|
||||
upstream_items: FinanceItem[]
|
||||
failed_count: number
|
||||
success: boolean
|
||||
computed_at?: string
|
||||
from_snapshot?: boolean
|
||||
}
|
||||
|
||||
interface DiffItem {
|
||||
side: 'website_items' | 'upstream_items'
|
||||
id: number
|
||||
name: string
|
||||
upstream_type: string
|
||||
old_status: string | null
|
||||
new_status: string | null
|
||||
old_amount: number | null
|
||||
new_amount: number | null
|
||||
old_error: string | null
|
||||
new_error: string | null
|
||||
}
|
||||
|
||||
type DiffField = 'amount' | 'status' | 'error'
|
||||
type DiffSide = 'old' | 'new'
|
||||
|
||||
interface CompareResponse {
|
||||
stored: DailySummary | null
|
||||
current: DailySummary
|
||||
has_difference: boolean
|
||||
diff_items: DiffItem[]
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const comparing = ref(false)
|
||||
const overwriting = ref(false)
|
||||
const fetchError = ref<string | null>(null)
|
||||
const summary = ref<DailySummary | null>(null)
|
||||
const selectedDate = ref<string | null>(null)
|
||||
|
||||
const compareDialogVisible = ref(false)
|
||||
const compareResult = ref<CompareResponse | 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)
|
||||
const successWebsiteCount = computed(() => summary.value?.website_items.filter(i => i.status === 'success').length ?? 0)
|
||||
const successUpstreamCount = computed(() => summary.value?.upstream_items.filter(i => i.status === 'success').length ?? 0)
|
||||
const upstreamItemCount = computed(() => summary.value?.upstream_items.length ?? 0)
|
||||
|
||||
function disabledDate(d: Date) {
|
||||
return d > new Date()
|
||||
@@ -197,20 +356,71 @@ function formatAmount(val: number): string {
|
||||
return '$' + val.toFixed(6)
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function diffField(row: DiffItem): DiffField {
|
||||
if (row.old_amount !== row.new_amount) return 'amount'
|
||||
if (row.old_status !== row.new_status) return 'status'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function diffFieldLabel(row: DiffItem): string {
|
||||
const labels: Record<DiffField, string> = {
|
||||
amount: '金额',
|
||||
status: '状态',
|
||||
error: '错误',
|
||||
}
|
||||
return labels[diffField(row)]
|
||||
}
|
||||
|
||||
function diffRawValue(row: DiffItem, side: DiffSide): number | string | null {
|
||||
const field = diffField(row)
|
||||
if (field === 'amount') return side === 'old' ? row.old_amount : row.new_amount
|
||||
if (field === 'status') return side === 'old' ? row.old_status : row.new_status
|
||||
return side === 'old' ? row.old_error : row.new_error
|
||||
}
|
||||
|
||||
function formatDiffValue(row: DiffItem, side: DiffSide): string {
|
||||
const value = diffRawValue(row, side)
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (diffField(row) === 'amount') return formatAmount(Number(value))
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function diffValueClass(row: DiffItem, side: DiffSide): string {
|
||||
const value = diffRawValue(row, side)
|
||||
if (value === null || value === undefined || value === '') return 'absent-value'
|
||||
const base = side === 'old' ? 'old-value' : 'new-value'
|
||||
return diffField(row) === 'error' ? `${base} error-text` : base
|
||||
}
|
||||
|
||||
function dateParam(): string {
|
||||
const params = new URLSearchParams()
|
||||
if (selectedDate.value) params.set('date', selectedDate.value)
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${auth.token}`, 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}))
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
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()
|
||||
const data = await apiFetch<DailySummary>(`/api/finance/daily-summary?${dateParam()}`)
|
||||
summary.value = data
|
||||
if (data.failed_count > 0) {
|
||||
ElMessage.warning(`${data.failed_count} 个数据源统计失败,净收入仅供参考`)
|
||||
@@ -225,6 +435,42 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompare() {
|
||||
comparing.value = true
|
||||
try {
|
||||
const result = await apiFetch<CompareResponse>(
|
||||
`/api/finance/daily-summary/compare?${dateParam()}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
compareResult.value = result
|
||||
compareDialogVisible.value = true
|
||||
if (!result.has_difference) {
|
||||
ElMessage.info('比对结果一致,无差异')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error('比对失败: ' + (e.message || '请求失败'))
|
||||
} finally {
|
||||
comparing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOverwrite() {
|
||||
overwriting.value = true
|
||||
try {
|
||||
const result = await apiFetch<DailySummary>(
|
||||
`/api/finance/daily-summary/overwrite?${dateParam()}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
summary.value = result
|
||||
compareDialogVisible.value = false
|
||||
ElMessage.success('快照已覆盖更新')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('覆盖失败: ' + (e.message || '请求失败'))
|
||||
} finally {
|
||||
overwriting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
@@ -303,6 +549,10 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.snapshot-badge {
|
||||
font-weight: 500;
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
.stat-card.net.positive .stat-value { color: #22c55e; }
|
||||
.stat-card.net.negative .stat-value { color: var(--el-color-danger); }
|
||||
.stat-card.failed.has-failures {
|
||||
@@ -342,4 +592,50 @@ onMounted(() => {
|
||||
.error-alert { margin-bottom: 20px; }
|
||||
.skeleton { margin-top: 16px; }
|
||||
.empty-state { margin-top: 60px; }
|
||||
|
||||
/* ── Compare dialog ── */
|
||||
.compare-alert { margin-bottom: 20px; }
|
||||
.compare-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.compare-col-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
.compare-stat { display: flex; flex-direction: column; gap: 8px; }
|
||||
.compare-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.compare-label { font-size: 13px; color: var(--el-text-color-secondary); }
|
||||
.compare-value { font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.compare-value.positive { color: #22c55e; }
|
||||
.compare-value.negative { color: var(--el-color-danger); }
|
||||
.compare-empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* ── Diff items table ── */
|
||||
.diff-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.diff-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.old-value { color: var(--el-color-danger); }
|
||||
.new-value { color: #22c55e; font-weight: 600; }
|
||||
.absent-value { color: var(--el-text-color-placeholder); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user