Files
SmartUp/frontend/src/views/Finance.vue
T

1247 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>