1247 lines
33 KiB
Vue
1247 lines
33 KiB
Vue
<template>
|
||
<div class="finance-page">
|
||
<div class="finance-shell">
|
||
<div class="finance-topbar">
|
||
<div class="period-tabs" aria-label="统计周期">
|
||
<button
|
||
v-for="item in periodOptions"
|
||
:key="item.value"
|
||
type="button"
|
||
class="period-tab"
|
||
:class="{ active: period === item.value }"
|
||
@click="setPeriod(item.value)"
|
||
>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-actions">
|
||
<el-input
|
||
v-model="keyword"
|
||
class="search-input"
|
||
clearable
|
||
placeholder="搜索名称 / 类型 / 错误"
|
||
>
|
||
<template #prefix>
|
||
<Search :size="16" />
|
||
</template>
|
||
</el-input>
|
||
<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 :loading="loading" class="icon-button" @click="loadData">
|
||
<RefreshCw :size="16" />
|
||
刷新
|
||
</el-button>
|
||
<el-button :disabled="!summary" class="icon-button" @click="exportCsv">
|
||
<Download :size="16" />
|
||
导出
|
||
</el-button>
|
||
<el-button
|
||
v-if="period === 'day' && summary"
|
||
:loading="comparing"
|
||
type="primary"
|
||
class="icon-button compare-button"
|
||
@click="handleCompare"
|
||
>
|
||
<DatabaseZap :size="16" />
|
||
重新统计比对
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert v-if="fetchError" :title="fetchError" type="error" show-icon :closable="false" class="page-alert" />
|
||
<el-alert
|
||
v-if="summary?.partial"
|
||
:title="partialTitle"
|
||
type="warning"
|
||
show-icon
|
||
:closable="false"
|
||
class="page-alert"
|
||
/>
|
||
|
||
<el-skeleton v-if="loading && !summary" :rows="10" animated class="loading-panel" />
|
||
|
||
<template v-if="summary">
|
||
<div class="metrics-grid">
|
||
<div class="metric-card">
|
||
<div class="metric-head">
|
||
<span>总收入</span>
|
||
<span class="metric-icon income"><TrendingUp :size="18" /></span>
|
||
</div>
|
||
<strong>{{ formatAmount(summary.total_revenue) }}</strong>
|
||
<small>{{ dateRangeLabel }}</small>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-head">
|
||
<span>总消费</span>
|
||
<span class="metric-icon cost"><ReceiptText :size="18" /></span>
|
||
</div>
|
||
<strong>{{ formatAmount(summary.total_cost) }}</strong>
|
||
<small>{{ summary.upstream_items.length }} 个上游源</small>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-head">
|
||
<span>净收入</span>
|
||
<span class="metric-icon net"><WalletCards :size="18" /></span>
|
||
</div>
|
||
<strong :class="summary.net_income >= 0 ? 'positive' : 'negative'">
|
||
{{ formatAmount(summary.net_income) }}
|
||
</strong>
|
||
<small>毛利率 {{ formatPercent(summary.margin_percent) }}</small>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-head">
|
||
<span>{{ summary.partial ? '部分汇总' : '失败项' }}</span>
|
||
<span class="metric-icon risk"><BadgeAlert :size="18" /></span>
|
||
</div>
|
||
<strong :class="summary.failed_count > 0 || summary.partial ? 'negative' : ''">
|
||
{{ summary.failed_count }}
|
||
</strong>
|
||
<small>{{ summary.partial ? `缺失 ${summary.missing_dates.length} 天快照` : failureText }}</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-grid">
|
||
<section class="dashboard-panel trend-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h2>收入 / 消费趋势</h2>
|
||
<p>{{ trendCaption }}</p>
|
||
</div>
|
||
<span class="panel-chip">{{ summary.included_days }} 天已入库</span>
|
||
</div>
|
||
<div ref="trendChartRef" class="chart trend-chart" />
|
||
</section>
|
||
|
||
<section class="dashboard-panel share-panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h2>上游占比</h2>
|
||
<p>按当前周期消费金额汇总</p>
|
||
</div>
|
||
</div>
|
||
<div ref="shareChartRef" class="chart share-chart" />
|
||
</section>
|
||
</div>
|
||
|
||
<section class="dashboard-panel detail-panel">
|
||
<div class="panel-header detail-header">
|
||
<div>
|
||
<h2>收入 / 消费明细</h2>
|
||
<p>{{ filteredWebsiteItems.length }} 个收入项,{{ filteredUpstreamItems.length }} 个消费项</p>
|
||
</div>
|
||
<div class="table-tabs">
|
||
<button
|
||
type="button"
|
||
:class="{ active: activeTable === 'websites' }"
|
||
@click="activeTable = 'websites'"
|
||
>
|
||
收入
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:class="{ active: activeTable === 'upstreams' }"
|
||
@click="activeTable = 'upstreams'"
|
||
>
|
||
消费
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<el-table
|
||
v-if="activeTable === 'websites'"
|
||
:data="filteredWebsiteItems"
|
||
class="finance-table"
|
||
empty-text="暂无收入明细"
|
||
>
|
||
<el-table-column label="名称" prop="name" min-width="180" />
|
||
<el-table-column label="收入" min-width="140" align="right">
|
||
<template #default="{ row }">
|
||
<span class="amount income">{{ formatAmount(row.amount) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="100" 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="280">
|
||
<template #default="{ row }">
|
||
<span v-if="row.error" class="error-text">{{ row.error }}</span>
|
||
<span v-else class="muted-text">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<el-table
|
||
v-else
|
||
:data="filteredUpstreamItems"
|
||
class="finance-table"
|
||
empty-text="暂无消费明细"
|
||
>
|
||
<el-table-column label="名称" prop="name" min-width="180" />
|
||
<el-table-column label="类型" width="120" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="upstreamTagType(row.upstream_type)" size="small">
|
||
{{ upstreamTypeLabel(row.upstream_type) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="消费" min-width="140" align="right">
|
||
<template #default="{ row }">
|
||
<span class="amount cost">{{ formatAmount(row.amount) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="100" 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="280">
|
||
<template #default="{ row }">
|
||
<span v-if="row.error" class="error-text">{{ row.error }}</span>
|
||
<span v-else class="muted-text">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<el-empty v-else-if="!loading && !fetchError" description="暂无财务汇总数据" class="empty-state">
|
||
<el-button type="primary" @click="loadData">加载数据</el-button>
|
||
</el-empty>
|
||
</div>
|
||
|
||
<el-dialog
|
||
v-model="compareDialogVisible"
|
||
title="重新统计比对结果"
|
||
width="760px"
|
||
class="finance-compare-dialog"
|
||
: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-card">
|
||
<h3>已存快照</h3>
|
||
<div v-if="compareResult.stored" class="compare-stat">
|
||
<span>收入 <strong>{{ formatAmount(compareResult.stored.total_revenue) }}</strong></span>
|
||
<span>消费 <strong>{{ formatAmount(compareResult.stored.total_cost) }}</strong></span>
|
||
<span>净收入 <strong>{{ formatAmount(compareResult.stored.net_income) }}</strong></span>
|
||
<span>失败项 <strong>{{ compareResult.stored.failed_count }}</strong></span>
|
||
</div>
|
||
<div v-else class="compare-empty">无快照</div>
|
||
</div>
|
||
<div class="compare-card">
|
||
<h3>实时统计</h3>
|
||
<div class="compare-stat">
|
||
<span>收入 <strong>{{ formatAmount(compareResult.current.total_revenue) }}</strong></span>
|
||
<span>消费 <strong>{{ formatAmount(compareResult.current.total_cost) }}</strong></span>
|
||
<span>净收入 <strong>{{ formatAmount(compareResult.current.net_income) }}</strong></span>
|
||
<span>失败项 <strong>{{ compareResult.current.failed_count }}</strong></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="compareResult.diff_items.length > 0" class="diff-section">
|
||
<h3>变动明细({{ compareResult.diff_items.length }} 项)</h3>
|
||
<div class="table-wrap">
|
||
<el-table :data="compareResult.diff_items" class="finance-table" 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="120" align="right">
|
||
<template #default="{ row }">
|
||
<span class="diff-old">{{ formatDiffValue(row, 'old') }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="新值" min-width="120" align="right">
|
||
<template #default="{ row }">
|
||
<span class="diff-new">{{ formatDiffValue(row, 'new') }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</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 { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { api } from '@/api'
|
||
import {
|
||
BadgeAlert,
|
||
DatabaseZap,
|
||
Download,
|
||
ReceiptText,
|
||
RefreshCw,
|
||
Search,
|
||
TrendingUp,
|
||
WalletCards,
|
||
} from 'lucide-vue-next'
|
||
import * as echarts from 'echarts/core'
|
||
import { LineChart, PieChart } from 'echarts/charts'
|
||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||
import { CanvasRenderer } from 'echarts/renderers'
|
||
|
||
echarts.use([LineChart, PieChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
|
||
|
||
type Period = 'day' | 'week' | 'month'
|
||
type DiffField = 'amount' | 'status' | 'error'
|
||
type DiffSide = 'old' | 'new'
|
||
|
||
interface FinanceItem {
|
||
id: number | string
|
||
name: string
|
||
upstream_type: string
|
||
amount: number
|
||
status: 'success' | 'failed'
|
||
error: string | null
|
||
}
|
||
|
||
interface FinanceChart {
|
||
labels: string[]
|
||
revenue: number[]
|
||
cost: number[]
|
||
}
|
||
|
||
interface FinanceSummary {
|
||
period: Period
|
||
date: string
|
||
start_date: string
|
||
end_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
|
||
chart: FinanceChart
|
||
missing_dates: string[]
|
||
included_days: number
|
||
partial: 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
|
||
}
|
||
|
||
interface CompareResponse {
|
||
stored: FinanceSummary | null
|
||
current: FinanceSummary
|
||
has_difference: boolean
|
||
diff_items: DiffItem[]
|
||
}
|
||
|
||
const periodOptions: Array<{ value: Period; label: string }> = [
|
||
{ value: 'day', label: '日' },
|
||
{ value: 'week', label: '周' },
|
||
{ value: 'month', label: '月' },
|
||
]
|
||
|
||
const period = ref<Period>('day')
|
||
const selectedDate = ref<string | null>(null)
|
||
const keyword = ref('')
|
||
const loading = ref(false)
|
||
const comparing = ref(false)
|
||
const overwriting = ref(false)
|
||
const fetchError = ref<string | null>(null)
|
||
const summary = ref<FinanceSummary | null>(null)
|
||
const activeTable = ref<'websites' | 'upstreams'>('websites')
|
||
const compareDialogVisible = ref(false)
|
||
const compareResult = ref<CompareResponse | null>(null)
|
||
|
||
const trendChartRef = ref<HTMLElement | null>(null)
|
||
const shareChartRef = ref<HTMLElement | null>(null)
|
||
let trendChart: echarts.ECharts | null = null
|
||
let shareChart: echarts.ECharts | null = null
|
||
|
||
const dateRangeLabel = computed(() => {
|
||
if (!summary.value) return ''
|
||
if (summary.value.start_date === summary.value.end_date) return summary.value.start_date
|
||
return `${summary.value.start_date} 至 ${summary.value.end_date}`
|
||
})
|
||
|
||
const trendCaption = computed(() => {
|
||
if (!summary.value) return ''
|
||
if (period.value === 'day') return '近 7 天已入库日快照'
|
||
return `${dateRangeLabel.value} 已入库快照`
|
||
})
|
||
|
||
const partialTitle = computed(() => {
|
||
if (!summary.value?.partial) return ''
|
||
const dates = summary.value.missing_dates.slice(0, 5).join('、')
|
||
const more = summary.value.missing_dates.length > 5 ? ' 等' : ''
|
||
return `部分汇总:缺失 ${summary.value.missing_dates.length} 天快照(${dates}${more})`
|
||
})
|
||
|
||
const failureText = computed(() => {
|
||
if (!summary.value) return ''
|
||
return summary.value.failed_count > 0 ? '数据不完整,净收入仅供参考' : '全部统计成功'
|
||
})
|
||
|
||
const normalizedKeyword = computed(() => keyword.value.trim().toLowerCase())
|
||
|
||
const filteredWebsiteItems = computed(() => filterItems(summary.value?.website_items ?? []))
|
||
const filteredUpstreamItems = computed(() => filterItems(summary.value?.upstream_items ?? []))
|
||
|
||
function setPeriod(nextPeriod: Period) {
|
||
if (period.value === nextPeriod) return
|
||
period.value = nextPeriod
|
||
compareDialogVisible.value = false
|
||
void loadData()
|
||
}
|
||
|
||
function disabledDate(d: Date) {
|
||
const today = new Date()
|
||
today.setHours(23, 59, 59, 999)
|
||
return d > today
|
||
}
|
||
|
||
function filterItems(items: FinanceItem[]) {
|
||
const q = normalizedKeyword.value
|
||
if (!q) return items
|
||
return items.filter(item => {
|
||
return [
|
||
item.name,
|
||
item.upstream_type,
|
||
upstreamTypeLabel(item.upstream_type),
|
||
item.status === 'success' ? '成功' : '失败',
|
||
item.error ?? '',
|
||
].some(value => value.toLowerCase().includes(q))
|
||
})
|
||
}
|
||
|
||
function formatAmount(val: number): string {
|
||
return '$' + Number(val || 0).toFixed(6)
|
||
}
|
||
|
||
function formatPercent(val: number): string {
|
||
return `${Number(val || 0).toFixed(2)}%`
|
||
}
|
||
|
||
function upstreamTypeLabel(type: string): string {
|
||
if (type === 'sub2api') return 'Sub2API'
|
||
if (type === 'new_api') return 'New-API'
|
||
return '未知'
|
||
}
|
||
|
||
function upstreamTagType(type: string): 'primary' | 'warning' | 'info' {
|
||
if (type === 'sub2api') return 'primary'
|
||
if (type === 'new_api') return 'warning'
|
||
return 'info'
|
||
}
|
||
|
||
function buildParams(extra?: Record<string, string>) {
|
||
const params = new URLSearchParams({ period: period.value, ...(extra ?? {}) })
|
||
if (selectedDate.value) params.set('date', selectedDate.value)
|
||
return params
|
||
}
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
fetchError.value = null
|
||
try {
|
||
const { data } = await api.get<FinanceSummary>('/api/finance/summary', {
|
||
params: Object.fromEntries(buildParams()),
|
||
})
|
||
summary.value = data
|
||
await nextTick()
|
||
renderCharts()
|
||
if (data.partial) {
|
||
ElMessage.warning(`当前周期缺失 ${data.missing_dates.length} 天快照,已按入库数据部分汇总`)
|
||
} else if (data.failed_count > 0) {
|
||
ElMessage.warning(`${data.failed_count} 个数据源统计失败,净收入仅供参考`)
|
||
}
|
||
} catch (e: any) {
|
||
fetchError.value = e.response?.data?.detail || e.message || '请求失败'
|
||
ElMessage.error(fetchError.value ?? '请求失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleCompare() {
|
||
if (period.value !== 'day') return
|
||
comparing.value = true
|
||
try {
|
||
const params = new URLSearchParams()
|
||
if (selectedDate.value) params.set('date', selectedDate.value)
|
||
const { data } = await api.post<CompareResponse>('/api/finance/daily-summary/compare', null, {
|
||
params: Object.fromEntries(params),
|
||
})
|
||
compareResult.value = data
|
||
compareDialogVisible.value = true
|
||
if (!data.has_difference) ElMessage.info('比对结果一致,无差异')
|
||
} catch (e: any) {
|
||
ElMessage.error('比对失败: ' + (e.response?.data?.detail || e.message || '请求失败'))
|
||
} finally {
|
||
comparing.value = false
|
||
}
|
||
}
|
||
|
||
async function handleOverwrite() {
|
||
overwriting.value = true
|
||
try {
|
||
const params = new URLSearchParams()
|
||
if (selectedDate.value) params.set('date', selectedDate.value)
|
||
await api.post('/api/finance/daily-summary/overwrite', null, {
|
||
params: Object.fromEntries(params),
|
||
})
|
||
compareDialogVisible.value = false
|
||
ElMessage.success('快照已覆盖更新')
|
||
await loadData()
|
||
} catch (e: any) {
|
||
ElMessage.error('覆盖失败: ' + (e.response?.data?.detail || e.message || '请求失败'))
|
||
} finally {
|
||
overwriting.value = false
|
||
}
|
||
}
|
||
|
||
function renderCharts() {
|
||
renderTrendChart()
|
||
renderShareChart()
|
||
}
|
||
|
||
function renderTrendChart() {
|
||
if (!trendChartRef.value || !summary.value) return
|
||
trendChart ??= echarts.init(trendChartRef.value)
|
||
const labels = summary.value.chart.labels.map(label => label.slice(5))
|
||
trendChart.setOption({
|
||
color: ['#1f9d7a', '#d97706'],
|
||
grid: { left: 12, right: 18, top: 24, bottom: 12, containLabel: true },
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
valueFormatter: (value: number) => formatAmount(value),
|
||
},
|
||
legend: {
|
||
top: 0,
|
||
right: 0,
|
||
icon: 'circle',
|
||
textStyle: { color: '#64748b', fontSize: 12 },
|
||
data: ['收入', '消费'],
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
boundaryGap: false,
|
||
data: labels,
|
||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||
axisTick: { show: false },
|
||
axisLabel: { color: '#64748b' },
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
axisLabel: { color: '#64748b', formatter: (value: number) => `$${value}` },
|
||
splitLine: { lineStyle: { color: '#eef2f7' } },
|
||
},
|
||
series: [
|
||
{
|
||
name: '收入',
|
||
type: 'line',
|
||
smooth: true,
|
||
symbolSize: 7,
|
||
data: summary.value.chart.revenue,
|
||
areaStyle: { color: 'rgba(31, 157, 122, 0.12)' },
|
||
lineStyle: { width: 3 },
|
||
},
|
||
{
|
||
name: '消费',
|
||
type: 'line',
|
||
smooth: true,
|
||
symbolSize: 7,
|
||
data: summary.value.chart.cost,
|
||
areaStyle: { color: 'rgba(217, 119, 6, 0.12)' },
|
||
lineStyle: { width: 3 },
|
||
},
|
||
],
|
||
})
|
||
}
|
||
|
||
function renderShareChart() {
|
||
if (!shareChartRef.value || !summary.value) return
|
||
shareChart ??= echarts.init(shareChartRef.value)
|
||
const data = summary.value.upstream_items
|
||
.filter(item => item.amount > 0)
|
||
.map(item => ({ name: item.name, value: Number(item.amount.toFixed(6)) }))
|
||
shareChart.setOption({
|
||
color: ['#1f9d7a', '#2563eb', '#d97706', '#9333ea', '#dc2626', '#0891b2'],
|
||
tooltip: {
|
||
trigger: 'item',
|
||
formatter: (params: any) => `${params.name}<br/>${formatAmount(params.value)} (${params.percent}%)`,
|
||
},
|
||
legend: {
|
||
bottom: 0,
|
||
type: 'scroll',
|
||
icon: 'circle',
|
||
textStyle: { color: '#64748b', fontSize: 12 },
|
||
},
|
||
series: [
|
||
{
|
||
name: '上游消费',
|
||
type: 'pie',
|
||
radius: ['54%', '74%'],
|
||
center: ['50%', '43%'],
|
||
avoidLabelOverlap: true,
|
||
minAngle: 4,
|
||
label: {
|
||
color: '#334155',
|
||
formatter: '{b}\n{d}%',
|
||
},
|
||
itemStyle: {
|
||
borderColor: '#fff',
|
||
borderWidth: 3,
|
||
},
|
||
data: data.length ? data : [{ name: '暂无消费', value: 1, itemStyle: { color: '#e2e8f0' } }],
|
||
},
|
||
],
|
||
})
|
||
}
|
||
|
||
function exportCsv() {
|
||
if (!summary.value) return
|
||
const rows: Array<Array<string | number>> = [
|
||
['周期', periodLabel(period.value)],
|
||
['日期范围', dateRangeLabel.value],
|
||
['总收入', summary.value.total_revenue],
|
||
['总消费', summary.value.total_cost],
|
||
['净收入', summary.value.net_income],
|
||
['毛利率', summary.value.margin_percent],
|
||
['入库天数', summary.value.included_days],
|
||
['缺失日期', summary.value.missing_dates.join(' ')],
|
||
[],
|
||
['类型', '名称', '上游类型', '金额', '状态', '错误信息'],
|
||
...filteredWebsiteItems.value.map(item => ['收入', item.name, item.upstream_type, item.amount, item.status, item.error ?? '']),
|
||
...filteredUpstreamItems.value.map(item => ['消费', item.name, item.upstream_type, item.amount, item.status, item.error ?? '']),
|
||
]
|
||
const csv = rows.map(row => row.map(csvCell).join(',')).join('\n')
|
||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `finance-${period.value}-${summary.value.start_date}-${summary.value.end_date}.csv`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
function csvCell(value: string | number): string {
|
||
const text = String(value ?? '')
|
||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
||
}
|
||
|
||
function periodLabel(value: Period): string {
|
||
const item = periodOptions.find(option => option.value === value)
|
||
return item?.label ?? value
|
||
}
|
||
|
||
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 resizeCharts() {
|
||
trendChart?.resize()
|
||
shareChart?.resize()
|
||
}
|
||
|
||
watch(selectedDate, () => {
|
||
void loadData()
|
||
})
|
||
|
||
watch([filteredWebsiteItems, filteredUpstreamItems], () => {
|
||
if (summary.value) renderShareChart()
|
||
})
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('resize', resizeCharts)
|
||
void loadData()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', resizeCharts)
|
||
trendChart?.dispose()
|
||
shareChart?.dispose()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.finance-page {
|
||
--finance-bg: #f4f7fb;
|
||
--finance-card: #ffffff;
|
||
--finance-border: #e2e8f0;
|
||
--finance-border-strong: #cbd5e1;
|
||
--finance-text: #0f172a;
|
||
--finance-muted: #64748b;
|
||
--finance-soft: #94a3b8;
|
||
--finance-green: #1f9d7a;
|
||
--finance-orange: #d97706;
|
||
--finance-blue: #2563eb;
|
||
--finance-red: #dc2626;
|
||
|
||
width: 100%;
|
||
color: var(--finance-text);
|
||
font-family: Inter, "Noto Sans SC", system-ui, sans-serif;
|
||
}
|
||
|
||
.finance-shell {
|
||
display: grid;
|
||
gap: 18px;
|
||
max-width: 1600px;
|
||
margin: 0 auto;
|
||
padding: 18px;
|
||
border-radius: 18px;
|
||
background: var(--finance-bg);
|
||
}
|
||
|
||
.finance-topbar,
|
||
.dashboard-panel,
|
||
.metric-card,
|
||
.loading-panel,
|
||
.empty-state {
|
||
background: var(--finance-card);
|
||
border: 1px solid var(--finance-border);
|
||
border-radius: 8px;
|
||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
|
||
}
|
||
|
||
.finance-topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
padding: 14px;
|
||
}
|
||
|
||
.period-tabs,
|
||
.table-tabs {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px;
|
||
border-radius: 8px;
|
||
background: #eef2f7;
|
||
border: 1px solid var(--finance-border);
|
||
}
|
||
|
||
.period-tab,
|
||
.table-tabs button {
|
||
min-width: 54px;
|
||
height: 32px;
|
||
padding: 0 14px;
|
||
border: 0;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: var(--finance-muted);
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.period-tab.active,
|
||
.table-tabs button.active {
|
||
color: var(--finance-text);
|
||
background: #fff;
|
||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.search-input {
|
||
width: 240px;
|
||
}
|
||
|
||
.date-picker {
|
||
width: 170px;
|
||
}
|
||
|
||
.icon-button {
|
||
border-radius: 8px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.icon-button :deep(svg) {
|
||
margin-right: 6px;
|
||
}
|
||
|
||
.compare-button {
|
||
color: #fff;
|
||
}
|
||
|
||
.page-alert {
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.metrics-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.metric-card {
|
||
min-height: 136px;
|
||
padding: 18px;
|
||
display: grid;
|
||
align-content: space-between;
|
||
}
|
||
|
||
.metric-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
color: var(--finance-muted);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.metric-icon {
|
||
width: 34px;
|
||
height: 34px;
|
||
display: inline-grid;
|
||
place-items: center;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.metric-icon.income {
|
||
color: var(--finance-green);
|
||
background: rgba(31, 157, 122, 0.1);
|
||
}
|
||
|
||
.metric-icon.cost {
|
||
color: var(--finance-orange);
|
||
background: rgba(217, 119, 6, 0.12);
|
||
}
|
||
|
||
.metric-icon.net {
|
||
color: var(--finance-blue);
|
||
background: rgba(37, 99, 235, 0.1);
|
||
}
|
||
|
||
.metric-icon.risk {
|
||
color: var(--finance-red);
|
||
background: rgba(220, 38, 38, 0.1);
|
||
}
|
||
|
||
.metric-card strong {
|
||
color: var(--finance-text);
|
||
font-family: "JetBrains Mono", Consolas, monospace;
|
||
font-size: clamp(22px, 2vw, 32px);
|
||
font-weight: 800;
|
||
line-height: 1.12;
|
||
word-break: break-word;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.metric-card small {
|
||
color: var(--finance-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.positive {
|
||
color: var(--finance-green) !important;
|
||
}
|
||
|
||
.negative {
|
||
color: var(--finance-red) !important;
|
||
}
|
||
|
||
.chart-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.8fr);
|
||
gap: 14px;
|
||
}
|
||
|
||
.dashboard-panel {
|
||
padding: 18px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.panel-header h2 {
|
||
margin: 0;
|
||
color: var(--finance-text);
|
||
font-size: 17px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.panel-header p {
|
||
margin: 5px 0 0;
|
||
color: var(--finance-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.panel-chip {
|
||
flex: 0 0 auto;
|
||
padding: 5px 9px;
|
||
border-radius: 999px;
|
||
background: #eef2f7;
|
||
color: var(--finance-muted);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.chart {
|
||
width: 100%;
|
||
}
|
||
|
||
.trend-chart {
|
||
height: 326px;
|
||
}
|
||
|
||
.share-chart {
|
||
height: 326px;
|
||
}
|
||
|
||
.detail-header {
|
||
align-items: center;
|
||
}
|
||
|
||
.table-wrap {
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
border: 1px solid var(--finance-border);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.finance-table {
|
||
min-width: 760px;
|
||
}
|
||
|
||
.amount,
|
||
.diff-old,
|
||
.diff-new,
|
||
.compare-stat strong {
|
||
font-family: "JetBrains Mono", Consolas, monospace;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.amount.income,
|
||
.diff-new {
|
||
color: var(--finance-green);
|
||
font-weight: 800;
|
||
}
|
||
|
||
.amount.cost {
|
||
color: var(--finance-orange);
|
||
font-weight: 800;
|
||
}
|
||
|
||
.diff-old {
|
||
color: var(--finance-red);
|
||
}
|
||
|
||
.error-text {
|
||
color: var(--finance-red);
|
||
font-size: 12px;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.muted-text {
|
||
color: var(--finance-soft);
|
||
}
|
||
|
||
.empty-state,
|
||
.loading-panel {
|
||
padding: 44px 18px;
|
||
}
|
||
|
||
.compare-alert {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.compare-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
}
|
||
|
||
.compare-card {
|
||
padding: 14px;
|
||
border: 1px solid var(--finance-border);
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
.compare-card h3,
|
||
.diff-section h3 {
|
||
margin: 0 0 12px;
|
||
color: var(--finance-text);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.compare-stat {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.compare-stat span {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
color: var(--finance-muted);
|
||
}
|
||
|
||
.compare-stat strong {
|
||
color: var(--finance-text);
|
||
}
|
||
|
||
.compare-empty {
|
||
padding: 26px 0;
|
||
color: var(--finance-soft);
|
||
text-align: center;
|
||
}
|
||
|
||
.diff-section {
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.finance-page :deep(.el-input__wrapper) {
|
||
min-height: 34px;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
box-shadow: 0 0 0 1px var(--finance-border) inset;
|
||
}
|
||
|
||
.finance-page :deep(.el-input__wrapper:hover),
|
||
.finance-page :deep(.el-input__wrapper.is-focus) {
|
||
box-shadow: 0 0 0 1px var(--finance-border-strong) inset;
|
||
}
|
||
|
||
.finance-page :deep(.el-input__inner) {
|
||
color: var(--finance-text);
|
||
}
|
||
|
||
.finance-page :deep(.el-input__inner::placeholder),
|
||
.finance-page :deep(.el-input__prefix) {
|
||
color: var(--finance-soft);
|
||
}
|
||
|
||
.finance-page :deep(.el-button--primary) {
|
||
--el-button-bg-color: #0f172a;
|
||
--el-button-border-color: #0f172a;
|
||
--el-button-hover-bg-color: #263449;
|
||
--el-button-hover-border-color: #263449;
|
||
}
|
||
|
||
.finance-page :deep(.el-button:not(.el-button--primary)) {
|
||
--el-button-bg-color: #fff;
|
||
--el-button-border-color: var(--finance-border);
|
||
--el-button-text-color: var(--finance-text);
|
||
--el-button-hover-bg-color: #f8fafc;
|
||
--el-button-hover-border-color: var(--finance-border-strong);
|
||
--el-button-hover-text-color: var(--finance-text);
|
||
}
|
||
|
||
.finance-page :deep(.el-table) {
|
||
--el-table-bg-color: #fff;
|
||
--el-table-tr-bg-color: #fff;
|
||
--el-table-header-bg-color: #f8fafc;
|
||
--el-table-header-text-color: #475569;
|
||
--el-table-text-color: var(--finance-text);
|
||
--el-table-row-hover-bg-color: #f8fafc;
|
||
--el-table-border-color: var(--finance-border);
|
||
}
|
||
|
||
.finance-page :deep(.el-table th.el-table__cell) {
|
||
background: #f8fafc;
|
||
color: #475569;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.finance-page :deep(.el-table td.el-table__cell) {
|
||
border-bottom-color: #eef2f7;
|
||
}
|
||
|
||
.finance-page :deep(.el-table__inner-wrapper::before),
|
||
.finance-page :deep(.el-table__border-left-patch) {
|
||
background: var(--finance-border);
|
||
}
|
||
|
||
.finance-page :deep(.el-tag) {
|
||
border-radius: 4px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
:global(.finance-compare-dialog) {
|
||
--el-dialog-bg-color: #fff;
|
||
--el-text-color-primary: #0f172a;
|
||
--el-text-color-regular: #0f172a;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
:global(.finance-compare-dialog .el-dialog__body) {
|
||
color: #0f172a;
|
||
}
|
||
|
||
:global(.finance-compare-dialog .el-table) {
|
||
--el-table-bg-color: #fff;
|
||
--el-table-tr-bg-color: #fff;
|
||
--el-table-header-bg-color: #f8fafc;
|
||
--el-table-text-color: #0f172a;
|
||
--el-table-border-color: #e2e8f0;
|
||
}
|
||
|
||
@media (max-width: 1180px) {
|
||
.metrics-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.chart-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.finance-shell {
|
||
padding: 12px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.finance-topbar {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.period-tabs,
|
||
.toolbar-actions,
|
||
.search-input,
|
||
.date-picker,
|
||
.icon-button {
|
||
width: 100%;
|
||
}
|
||
|
||
.period-tab {
|
||
flex: 1;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.metrics-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.panel-header,
|
||
.detail-header,
|
||
.compare-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.table-tabs {
|
||
width: 100%;
|
||
}
|
||
|
||
.table-tabs button {
|
||
flex: 1;
|
||
}
|
||
|
||
:global(.finance-compare-dialog) {
|
||
width: calc(100vw - 24px) !important;
|
||
}
|
||
}
|
||
</style>
|