1859 lines
52 KiB
Vue
1859 lines
52 KiB
Vue
<template>
|
|
<div class="transaction-screen">
|
|
<div class="screen-backdrop"></div>
|
|
|
|
<header class="screen-header">
|
|
<div class="screen-heading">
|
|
<div class="screen-title">交易综合分析大屏</div>
|
|
<div class="screen-subtitle">账户交易、POS刷卡、信用卡账单、分期贷款与地点消费综合洞察</div>
|
|
</div>
|
|
<div class="screen-actions">
|
|
<el-select v-model="queryParams.type" class="filter-type" popper-class="analysis-screen-popper" @change="handleTimeTypeChange">
|
|
<el-option label="按日" value="1" />
|
|
<el-option label="按月" value="2" />
|
|
<el-option label="按年" value="3" />
|
|
</el-select>
|
|
<el-date-picker
|
|
v-if="queryParams.type === '1'"
|
|
v-model="queryParams.time"
|
|
type="daterange"
|
|
range-separator="至"
|
|
start-placeholder="开始日期"
|
|
end-placeholder="结束日期"
|
|
value-format="YYYY-MM-DD"
|
|
class="filter-date"
|
|
popper-class="analysis-screen-popper"
|
|
/>
|
|
<el-date-picker
|
|
v-else-if="queryParams.type === '2'"
|
|
v-model="queryParams.time"
|
|
type="monthrange"
|
|
range-separator="至"
|
|
start-placeholder="开始月份"
|
|
end-placeholder="结束月份"
|
|
value-format="YYYY-MM"
|
|
class="filter-date"
|
|
popper-class="analysis-screen-popper"
|
|
/>
|
|
<template v-else>
|
|
<el-date-picker
|
|
v-model="queryParams.time[0]"
|
|
type="year"
|
|
placeholder="开始年份"
|
|
value-format="YYYY"
|
|
class="filter-year"
|
|
popper-class="analysis-screen-popper"
|
|
/>
|
|
<span class="range-split">至</span>
|
|
<el-date-picker
|
|
v-model="queryParams.time[1]"
|
|
type="year"
|
|
placeholder="结束年份"
|
|
value-format="YYYY"
|
|
class="filter-year"
|
|
popper-class="analysis-screen-popper"
|
|
/>
|
|
</template>
|
|
<div class="screen-action-group">
|
|
<el-button type="primary" icon="Search" :loading="loading" class="screen-btn screen-btn-primary" @click="loadDashboard">查询</el-button>
|
|
<el-button icon="Refresh" class="screen-btn screen-btn-plain" @click="resetQuery">重置</el-button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="screen-main">
|
|
<section class="metric-grid">
|
|
<div v-for="item in topMetrics" :key="item.key" class="metric-card">
|
|
<span>{{ item.label }}</span>
|
|
<strong :class="item.className">{{ item.value }}</strong>
|
|
<em>{{ item.remark }}</em>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="insight-grid">
|
|
<div class="panel panel-span-2">
|
|
<div class="panel-head">
|
|
<span>收支趋势</span>
|
|
<em>{{ rangeText }}</em>
|
|
</div>
|
|
<div id="transactionTrendChart" class="chart chart-large"></div>
|
|
</div>
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>支出结构</span>
|
|
<em>日常 / 手续费 / 分期 / 贷款</em>
|
|
</div>
|
|
<div id="expenseStructureChart" class="chart"></div>
|
|
</div>
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>风险压力</span>
|
|
<em>信用卡 / 分期 / 贷款</em>
|
|
</div>
|
|
<div id="riskRadarChart" class="chart"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="analysis-grid">
|
|
<div class="panel daily-rank-panel">
|
|
<div class="panel-head">
|
|
<span>日常消费子类排行</span>
|
|
<em class="rank-total">{{ formatMoney(dailyExpenseTotal) }}</em>
|
|
</div>
|
|
<div class="rank-list">
|
|
<div v-for="(item, index) in dailyExpenseRanks" :key="item.name" class="rank-row" :style="{ '--rank-width': `${item.percent}%` }">
|
|
<span class="rank-index">{{ index + 1 }}</span>
|
|
<span class="rank-name">{{ item.name }}</span>
|
|
<strong>{{ formatMoney(item.value) }}</strong>
|
|
<i></i>
|
|
</div>
|
|
<el-empty v-if="!dailyExpenseRanks.length" description="暂无日常消费数据" :image-size="70" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>POS刷卡分析</span>
|
|
<em>{{ posSummary.count || 0 }}笔</em>
|
|
</div>
|
|
<div id="posTrendChart" class="chart chart-small"></div>
|
|
<div class="mini-stats">
|
|
<div>
|
|
<span>刷卡金额</span>
|
|
<strong>{{ formatMoney(posSummary.amount) }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>手续费</span>
|
|
<strong>{{ formatMoney(posSummary.commission) }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>入账金额</span>
|
|
<strong>{{ formatMoney(posSummary.actualAmount) }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>信用卡消费统计</span>
|
|
<em>账单与额度</em>
|
|
</div>
|
|
<div id="creditBillChart" class="chart chart-small"></div>
|
|
<div class="mini-stats">
|
|
<div>
|
|
<span>累计账单</span>
|
|
<strong>{{ formatMoney(creditSummary.actualCreditBill) }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>月均账单</span>
|
|
<strong>{{ formatMoney(creditSummary.averageCreditBill) }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>使用率</span>
|
|
<strong>{{ formatPercent(creditSummary.creditBillRate) }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>分期与贷款待还</span>
|
|
<em>未来6个月计划</em>
|
|
</div>
|
|
<div id="repaymentChart" class="chart chart-small"></div>
|
|
<div class="dual-summary">
|
|
<div>
|
|
<span>信用卡分期待还</span>
|
|
<strong>{{ formatMoney(installmentSummary.leftDetail) }}</strong>
|
|
<em>未结清 {{ installmentSummary.unclearedDetailCount || 0 }}笔</em>
|
|
</div>
|
|
<div>
|
|
<span>贷款待还</span>
|
|
<strong>{{ formatMoney(loanSummary.leftDetail) }}</strong>
|
|
<em>未结清 {{ loanSummary.unclearedDetailCount || 0 }}笔</em>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="bottom-grid">
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>地点消费热点</span>
|
|
<em>{{ locationOverview.locationCount || 0 }}个地点</em>
|
|
</div>
|
|
<div class="location-list">
|
|
<div v-for="(item, index) in locationRanks" :key="item.locationKey" class="location-row">
|
|
<span>{{ String(index + 1).padStart(2, '0') }}</span>
|
|
<div>
|
|
<strong>{{ item.locationName || '未命名地点' }}</strong>
|
|
<em>{{ item.locationAddress || formatCoordinate(item) }}</em>
|
|
</div>
|
|
<b>{{ formatMoney(item.totalAmount) }}</b>
|
|
</div>
|
|
<el-empty v-if="!locationRanks.length" description="暂无地点消费数据" :image-size="70" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>综合洞察</span>
|
|
<em>基于当前筛选周期</em>
|
|
</div>
|
|
<div class="insight-list">
|
|
<div v-for="item in insightItems" :key="item.title" class="insight-row">
|
|
<span>{{ item.title }}</span>
|
|
<strong>{{ item.value }}</strong>
|
|
<em>{{ item.desc }}</em>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>重点明细</span>
|
|
<em>账单 / POS / 还款</em>
|
|
</div>
|
|
<div class="event-list">
|
|
<div v-for="item in eventItems" :key="`${item.type}-${item.time}-${item.value}`" class="event-row">
|
|
<span :class="`event-tag event-${item.type}`">{{ item.label }}</span>
|
|
<strong>{{ item.time }}</strong>
|
|
<em>{{ item.name }}</em>
|
|
<b>{{ item.value }}</b>
|
|
</div>
|
|
<el-empty v-if="!eventItems.length" description="暂无重点明细" :image-size="70" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="decision-grid">
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>智能洞察结论</span>
|
|
<em>{{ conclusionItems.length }}条自动结论</em>
|
|
</div>
|
|
<div class="conclusion-list">
|
|
<div v-for="item in conclusionItems" :key="item.title" :class="['conclusion-card', `level-${item.level}`]">
|
|
<span>{{ item.type }}</span>
|
|
<strong>{{ item.title }}</strong>
|
|
<em>{{ item.desc }}</em>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span>交易风险提醒</span>
|
|
<em>{{ alertItems.length }}条提醒</em>
|
|
</div>
|
|
<div class="forecast-grid">
|
|
<div>
|
|
<span>预计周期总支出</span>
|
|
<strong>{{ formatMoney(forecastExpense) }}</strong>
|
|
<em>{{ forecastDesc }}</em>
|
|
</div>
|
|
<div>
|
|
<span>现金流状态</span>
|
|
<strong :class="netBalance < 0 ? 'value-warning' : 'value-cyan'">{{ formatMoney(netBalance) }}</strong>
|
|
<em>收入 {{ formatMoney(totalIncome) }} / 支出 {{ formatMoney(totalOutcome) }}</em>
|
|
</div>
|
|
</div>
|
|
<div class="alert-list">
|
|
<div v-for="item in alertItems" :key="item.title" :class="['alert-row', `alert-${item.level}`]">
|
|
<span>{{ item.title }}</span>
|
|
<strong>{{ item.value }}</strong>
|
|
<em>{{ item.desc }}</em>
|
|
</div>
|
|
<el-empty v-if="!alertItems.length" description="暂无风险提醒" :image-size="70" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<div v-if="loading" class="screen-loading">
|
|
<el-icon class="is-loading"><Loading /></el-icon>
|
|
<span>正在汇总交易数据</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup name="TransactionAnalysisScreen">
|
|
import dayjs from 'dayjs'
|
|
import * as echarts from 'echarts'
|
|
import {
|
|
getAcccountsOutInAnalysis,
|
|
getDailyExpensesAnalysis,
|
|
getPosAnalysis,
|
|
getCreditAnalysis,
|
|
getInstallmentHistoryAnalysis,
|
|
getAccountDealLocationOverview,
|
|
getAccountDealLocationPoints
|
|
} from '@/api/invest/statisticAnalysis'
|
|
|
|
const loading = ref(false)
|
|
const queryParams = ref(getDefaultQueryParams())
|
|
|
|
const outInSummary = ref({})
|
|
const dailySummary = ref({})
|
|
const posSummary = ref({})
|
|
const creditSummary = ref({})
|
|
const installmentSummary = ref({})
|
|
const loanSummary = ref({})
|
|
const locationOverview = ref({})
|
|
const locationPoints = ref([])
|
|
const trendData = ref([])
|
|
const dailyTrendData = ref([])
|
|
const posTrendData = ref([])
|
|
const creditBillData = ref([])
|
|
const installmentPlanData = ref([])
|
|
const loanPlanData = ref([])
|
|
|
|
let trendChart = null
|
|
let expenseStructureChart = null
|
|
let riskRadarChart = null
|
|
let posTrendChart = null
|
|
let creditBillChart = null
|
|
let repaymentChart = null
|
|
|
|
const rangeText = computed(() => {
|
|
const time = queryParams.value.time || []
|
|
if (time.length !== 2) return '全部周期'
|
|
return `${time[0]} 至 ${time[1]}`
|
|
})
|
|
|
|
const totalIncome = computed(() => toNumber(outInSummary.value.salaryIncome) + toNumber(outInSummary.value.investIncome))
|
|
const totalOutcome = computed(() => toNumber(outInSummary.value.totalOutcome))
|
|
const netBalance = computed(() => toNumber(outInSummary.value.balance))
|
|
const dailyExpenseTotal = computed(() => toNumber(dailySummary.value.dailyExpenses))
|
|
const financeCost = computed(
|
|
() => toNumber(outInSummary.value.posExpenses) + toNumber(outInSummary.value.creditInterest) + toNumber(outInSummary.value.onLineInterest)
|
|
)
|
|
const repaymentPressure = computed(() => toNumber(installmentSummary.value.leftDetail) + toNumber(loanSummary.value.leftDetail))
|
|
const topLocation = computed(() => locationPoints.value[0] || null)
|
|
const selectedPeriodCount = computed(() => {
|
|
const [start, end] = queryParams.value.time || []
|
|
if (!start || !end) return Math.max(trendData.value.length, dailyTrendData.value.length, posTrendData.value.length, creditBillData.value.length, 1)
|
|
if (queryParams.value.type === '1') return Math.max(dayjs(end).diff(dayjs(start), 'day') + 1, 1)
|
|
if (queryParams.value.type === '2') return Math.max(dayjs(`${end}-01`).diff(dayjs(`${start}-01`), 'month') + 1, 1)
|
|
return Math.max(Number(end) - Number(start) + 1, 1)
|
|
})
|
|
const elapsedPeriodRate = computed(() => {
|
|
const time = queryParams.value.time || []
|
|
if (queryParams.value.type === '2' && time[1] === dayjs().format('YYYY-MM')) {
|
|
return Math.min(1, Math.max(0.03, dayjs().date() / dayjs().daysInMonth()))
|
|
}
|
|
if (queryParams.value.type === '3' && time[1] === dayjs().format('YYYY')) {
|
|
const yearStart = dayjs().startOf('year')
|
|
const yearDays = dayjs().endOf('year').diff(yearStart, 'day') + 1
|
|
return Math.min(1, Math.max(0.03, (dayjs().diff(yearStart, 'day') + 1) / yearDays))
|
|
}
|
|
return 1
|
|
})
|
|
const currentPeriodExpense = computed(() => {
|
|
const currentDailyExpense = getLatestTrendValue(dailyTrendData.value)
|
|
if (currentDailyExpense > 0 && dailyExpenseTotal.value > 0) {
|
|
return Math.min(totalOutcome.value, currentDailyExpense * (totalOutcome.value / dailyExpenseTotal.value))
|
|
}
|
|
return selectedPeriodCount.value ? totalOutcome.value / selectedPeriodCount.value : totalOutcome.value
|
|
})
|
|
const forecastExpense = computed(() => {
|
|
if (elapsedPeriodRate.value < 1) {
|
|
const historicalExpense = Math.max(0, totalOutcome.value - currentPeriodExpense.value)
|
|
const currentForecast = currentPeriodExpense.value / elapsedPeriodRate.value
|
|
return Math.max(totalOutcome.value, historicalExpense + currentForecast)
|
|
}
|
|
return totalOutcome.value
|
|
})
|
|
const forecastDesc = computed(() => {
|
|
if (elapsedPeriodRate.value < 1) return queryParams.value.type === '3' ? '当前年按已过天数外推' : '当前月按已过天数外推'
|
|
return `按 ${selectedPeriodCount.value} 个统计周期汇总`
|
|
})
|
|
|
|
const topMetrics = computed(() => [
|
|
{
|
|
key: 'income',
|
|
label: '收入合计',
|
|
value: formatMoney(totalIncome.value),
|
|
remark: `工资 ${formatMoney(outInSummary.value.salaryIncome)} / 投资 ${formatMoney(outInSummary.value.investIncome)}`,
|
|
className: 'value-income'
|
|
},
|
|
{
|
|
key: 'outcome',
|
|
label: '支出合计',
|
|
value: formatMoney(totalOutcome.value),
|
|
remark: `日常 ${formatMoney(dailyExpenseTotal.value)} / 非日常 ${formatMoney(outInSummary.value.notDailyExpenses)}`,
|
|
className: 'value-expense'
|
|
},
|
|
{
|
|
key: 'balance',
|
|
label: '收支差额',
|
|
value: formatMoney(netBalance.value),
|
|
remark: netBalance.value >= 0 ? '当前周期净流入' : '当前周期净流出',
|
|
className: netBalance.value >= 0 ? 'value-income' : 'value-warning'
|
|
},
|
|
{
|
|
key: 'pos',
|
|
label: 'POS刷卡',
|
|
value: formatMoney(posSummary.value.amount),
|
|
remark: `${posSummary.value.count || 0}笔,手续费 ${formatMoney(posSummary.value.commission)}`,
|
|
className: 'value-cyan'
|
|
},
|
|
{
|
|
key: 'credit',
|
|
label: '信用卡账单',
|
|
value: formatMoney(creditSummary.value.actualCreditBill),
|
|
remark: `月均 ${formatMoney(creditSummary.value.averageCreditBill)},使用率 ${formatPercent(creditSummary.value.creditBillRate)}`,
|
|
className: 'value-blue'
|
|
},
|
|
{
|
|
key: 'repayment',
|
|
label: '待还压力',
|
|
value: formatMoney(repaymentPressure.value),
|
|
remark: `分期 ${formatMoney(installmentSummary.value.leftDetail)} / 贷款 ${formatMoney(loanSummary.value.leftDetail)}`,
|
|
className: 'value-warning'
|
|
},
|
|
{
|
|
key: 'location',
|
|
label: '定位消费',
|
|
value: formatMoney(locationOverview.value.totalAmount),
|
|
remark: `${locationOverview.value.locationCount || 0}个地点 / ${locationOverview.value.dealCount || 0}笔`,
|
|
className: 'value-cyan'
|
|
},
|
|
{
|
|
key: 'financeCost',
|
|
label: '资金成本',
|
|
value: formatMoney(financeCost.value),
|
|
remark: `POS费 ${formatMoney(outInSummary.value.posExpenses)} / 分期息 ${formatMoney(outInSummary.value.creditInterest)} / 贷款息 ${formatMoney(
|
|
outInSummary.value.onLineInterest
|
|
)}`,
|
|
className: 'value-expense'
|
|
}
|
|
])
|
|
|
|
const dailyExpenseRanks = computed(() => {
|
|
const rows = normalizeSeries(dailySummary.value.accountsList, 'time', 'value').sort((first, second) => second.value - first.value)
|
|
const maxValue = Math.max(...rows.map((item) => item.value), 0)
|
|
return rows.slice(0, 6).map((item) => ({
|
|
name: item.name,
|
|
value: item.value,
|
|
percent: maxValue ? Math.max(6, (item.value / maxValue) * 100) : 0
|
|
}))
|
|
})
|
|
|
|
const locationRanks = computed(() => locationPoints.value.slice(0, 8))
|
|
|
|
const insightItems = computed(() => {
|
|
const avgDailyExpense = dailyTrendData.value.length ? dailyExpenseTotal.value / dailyTrendData.value.length : 0
|
|
const posFeeRate = toNumber(posSummary.value.amount) ? (toNumber(posSummary.value.commission) / toNumber(posSummary.value.amount)) * 100 : 0
|
|
const topDaily = dailyExpenseRanks.value[0]
|
|
return [
|
|
{
|
|
title: '日均消费',
|
|
value: formatMoney(avgDailyExpense),
|
|
desc: `按 ${dailyTrendData.value.length || 0} 个统计点计算`
|
|
},
|
|
{
|
|
title: '最高消费子类',
|
|
value: topDaily ? topDaily.name : '--',
|
|
desc: topDaily ? formatMoney(topDaily.value) : '暂无日常支出'
|
|
},
|
|
{
|
|
title: 'POS费率',
|
|
value: formatPercent(posFeeRate),
|
|
desc: `刷卡 ${formatMoney(posSummary.value.amount)},手续费 ${formatMoney(posSummary.value.commission)}`
|
|
},
|
|
{
|
|
title: '热点地点',
|
|
value: topLocation.value?.locationName || '--',
|
|
desc: topLocation.value ? `${formatMoney(topLocation.value.totalAmount)} / ${topLocation.value.dealCount || 0}笔` : '暂无定位交易'
|
|
},
|
|
{
|
|
title: '分期贷款压力',
|
|
value: formatMoney(repaymentPressure.value),
|
|
desc: `待还占支出 ${formatPercent(totalOutcome.value ? (repaymentPressure.value / totalOutcome.value) * 100 : 0)}`
|
|
}
|
|
]
|
|
})
|
|
|
|
const eventItems = computed(() => {
|
|
const creditEvents = normalizeSeries(creditSummary.value.tableCreditBillList || creditBillData.value, 'time', 'value')
|
|
.slice(0, 4)
|
|
.map((item) => ({ type: 'credit', label: '账单', time: item.name, name: '信用卡账单', value: formatMoney(item.value) }))
|
|
const posEvents = normalizeSeries(posSummary.value.tablePosList || posTrendData.value, 'time', 'value')
|
|
.slice(0, 4)
|
|
.map((item) => ({ type: 'pos', label: 'POS', time: item.name, name: '刷卡消费', value: formatMoney(item.value) }))
|
|
const repayEvents = normalizeSeries(
|
|
[...(installmentSummary.value.tableHistoryDetailList || []), ...(loanSummary.value.tableHistoryDetailList || [])],
|
|
'time',
|
|
'value'
|
|
)
|
|
.slice(0, 4)
|
|
.map((item) => ({ type: 'repay', label: '还款', time: item.name, name: '分期/贷款待还', value: formatMoney(item.value) }))
|
|
return [...creditEvents, ...posEvents, ...repayEvents].sort((first, second) => String(second.time).localeCompare(String(first.time))).slice(0, 10)
|
|
})
|
|
|
|
const conclusionItems = computed(() => {
|
|
const items = []
|
|
const topDaily = dailyExpenseRanks.value[0]
|
|
const topDailyRatio = dailyExpenseTotal.value && topDaily ? (topDaily.value / dailyExpenseTotal.value) * 100 : 0
|
|
const posFeeRate = toNumber(posSummary.value.amount) ? (toNumber(posSummary.value.commission) / toNumber(posSummary.value.amount)) * 100 : 0
|
|
const repaymentRatio = totalOutcome.value ? (repaymentPressure.value / totalOutcome.value) * 100 : 0
|
|
const financeCostRatio = totalOutcome.value ? (financeCost.value / totalOutcome.value) * 100 : 0
|
|
const locationRatio = totalOutcome.value ? (toNumber(locationOverview.value.totalAmount) / totalOutcome.value) * 100 : 0
|
|
const creditUsage = toNumber(creditSummary.value.creditBillRate)
|
|
|
|
items.push({
|
|
type: netBalance.value >= 0 ? '现金流' : '现金流预警',
|
|
level: netBalance.value >= 0 ? 'good' : 'danger',
|
|
title: netBalance.value >= 0 ? '当前周期保持净流入' : '当前周期出现净流出',
|
|
desc: `收入 ${formatMoney(totalIncome.value)},支出 ${formatMoney(totalOutcome.value)},差额 ${formatMoney(netBalance.value)}。`
|
|
})
|
|
|
|
if (topDaily) {
|
|
items.push({
|
|
type: '消费结构',
|
|
level: topDailyRatio >= 35 ? 'warning' : 'info',
|
|
title: `${topDaily.name} 是当前最高消费子类`,
|
|
desc: `消费 ${formatMoney(topDaily.value)},占日常消费 ${formatPercent(topDailyRatio)}。`
|
|
})
|
|
}
|
|
|
|
items.push({
|
|
type: '信用卡',
|
|
level: creditUsage >= 80 ? 'warning' : 'info',
|
|
title: `信用卡使用率 ${formatPercent(creditUsage)}`,
|
|
desc: `累计账单 ${formatMoney(creditSummary.value.actualCreditBill)},月均 ${formatMoney(creditSummary.value.averageCreditBill)}。`
|
|
})
|
|
|
|
items.push({
|
|
type: 'POS刷卡',
|
|
level: posFeeRate >= 0.8 ? 'warning' : 'info',
|
|
title: `POS刷卡费率约 ${formatPercent(posFeeRate)}`,
|
|
desc: `刷卡 ${formatMoney(posSummary.value.amount)},手续费 ${formatMoney(posSummary.value.commission)},入账 ${formatMoney(
|
|
posSummary.value.actualAmount
|
|
)}。`
|
|
})
|
|
|
|
items.push({
|
|
type: '分期贷款',
|
|
level: repaymentRatio >= 50 ? 'danger' : repaymentRatio >= 25 ? 'warning' : 'info',
|
|
title: `待还压力占支出 ${formatPercent(repaymentRatio)}`,
|
|
desc: `分期待还 ${formatMoney(installmentSummary.value.leftDetail)},贷款待还 ${formatMoney(loanSummary.value.leftDetail)}。`
|
|
})
|
|
|
|
items.push({
|
|
type: '资金成本',
|
|
level: financeCostRatio >= 10 ? 'warning' : 'info',
|
|
title: `资金成本占支出 ${formatPercent(financeCostRatio)}`,
|
|
desc: `POS费、分期利息、贷款利息合计 ${formatMoney(financeCost.value)}。`
|
|
})
|
|
|
|
if (topLocation.value) {
|
|
items.push({
|
|
type: '地点消费',
|
|
level: locationRatio >= 60 ? 'warning' : 'info',
|
|
title: `${topLocation.value.locationName || '未命名地点'} 是热点消费地点`,
|
|
desc: `定位消费 ${formatMoney(locationOverview.value.totalAmount)},占总支出 ${formatPercent(locationRatio)}。`
|
|
})
|
|
}
|
|
|
|
return items.slice(0, 8)
|
|
})
|
|
|
|
const alertItems = computed(() => {
|
|
const items = []
|
|
const creditRate = toNumber(creditSummary.value.creditBillRate)
|
|
const repaymentRatio = totalOutcome.value ? (repaymentPressure.value / totalOutcome.value) * 100 : 0
|
|
const financeRate = totalOutcome.value ? (financeCost.value / totalOutcome.value) * 100 : 0
|
|
const dailyRate = totalOutcome.value ? (dailyExpenseTotal.value / totalOutcome.value) * 100 : 0
|
|
|
|
if (netBalance.value < 0) {
|
|
items.push({
|
|
title: '当前周期现金流为负',
|
|
value: formatMoney(netBalance.value),
|
|
desc: `收入 ${formatMoney(totalIncome.value)},支出 ${formatMoney(totalOutcome.value)}。`,
|
|
level: 'danger'
|
|
})
|
|
}
|
|
|
|
if (dailyRate >= 70) {
|
|
items.push({
|
|
title: '日常消费占比较高',
|
|
value: formatPercent(dailyRate),
|
|
desc: `日常消费 ${formatMoney(dailyExpenseTotal.value)},总支出 ${formatMoney(totalOutcome.value)}。`,
|
|
level: 'warning'
|
|
})
|
|
}
|
|
|
|
if (financeRate >= 10) {
|
|
items.push({
|
|
title: '资金成本偏高',
|
|
value: formatPercent(financeRate),
|
|
desc: `POS费、分期利息、贷款利息合计 ${formatMoney(financeCost.value)}。`,
|
|
level: 'warning'
|
|
})
|
|
}
|
|
|
|
if (repaymentRatio >= 50) {
|
|
items.push({
|
|
title: '待还压力偏高',
|
|
value: formatMoney(repaymentPressure.value),
|
|
desc: `待还占当前周期支出 ${formatPercent(repaymentRatio)},建议关注近期现金流。`,
|
|
level: repaymentRatio >= 80 ? 'danger' : 'warning'
|
|
})
|
|
}
|
|
|
|
if (creditRate >= 80) {
|
|
items.push({
|
|
title: '信用卡使用率偏高',
|
|
value: formatPercent(creditRate),
|
|
desc: `累计账单 ${formatMoney(creditSummary.value.actualCreditBill)},注意账单集中还款压力。`,
|
|
level: 'warning'
|
|
})
|
|
}
|
|
|
|
if (!items.length && totalOutcome.value > 0) {
|
|
items.push({
|
|
title: '交易风险平稳',
|
|
value: formatMoney(totalOutcome.value),
|
|
desc: '当前周期现金流、资金成本和待还压力未触发明显风险。',
|
|
level: 'good'
|
|
})
|
|
}
|
|
|
|
return items
|
|
})
|
|
|
|
function getQueryRange() {
|
|
const [start, end] = queryParams.value.time || []
|
|
return {
|
|
type: queryParams.value.type,
|
|
startTime: start || '',
|
|
endTime: end || ''
|
|
}
|
|
}
|
|
|
|
function getLocationQueryRange() {
|
|
const { type, startTime, endTime } = getQueryRange()
|
|
if (type === '2') {
|
|
const nowMonth = dayjs().format('YYYY-MM')
|
|
return {
|
|
startTime: `${startTime}-01`,
|
|
endTime: endTime === nowMonth ? dayjs().format('YYYY-MM-DD') : dayjs(`${endTime}-01`).endOf('month').format('YYYY-MM-DD')
|
|
}
|
|
}
|
|
if (type === '3') {
|
|
const nowYear = dayjs().format('YYYY')
|
|
return {
|
|
startTime: `${startTime}-01-01`,
|
|
endTime: endTime === nowYear ? dayjs().format('YYYY-MM-DD') : `${endTime}-12-31`
|
|
}
|
|
}
|
|
return { startTime, endTime }
|
|
}
|
|
|
|
function getRepaymentQueryRange() {
|
|
const now = dayjs()
|
|
return {
|
|
type: '2',
|
|
startTime: now.format('YYYY-MM'),
|
|
endTime: now.add(5, 'month').format('YYYY-MM')
|
|
}
|
|
}
|
|
|
|
function handleTimeTypeChange(type) {
|
|
const now = dayjs()
|
|
if (type === '1') {
|
|
queryParams.value.time = [now.subtract(30, 'day').format('YYYY-MM-DD'), now.format('YYYY-MM-DD')]
|
|
} else if (type === '2') {
|
|
queryParams.value.time = [now.subtract(11, 'month').format('YYYY-MM'), now.format('YYYY-MM')]
|
|
} else {
|
|
queryParams.value.time = [now.subtract(4, 'year').format('YYYY'), now.format('YYYY')]
|
|
}
|
|
}
|
|
|
|
function resetQuery() {
|
|
queryParams.value = getDefaultQueryParams()
|
|
loadDashboard()
|
|
}
|
|
|
|
function getDefaultQueryParams() {
|
|
const now = dayjs()
|
|
return {
|
|
type: '1',
|
|
time: [now.subtract(30, 'day').format('YYYY-MM-DD'), now.format('YYYY-MM-DD')]
|
|
}
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
loading.value = true
|
|
try {
|
|
const range = getQueryRange()
|
|
const locationRange = getLocationQueryRange()
|
|
const repaymentRange = getRepaymentQueryRange()
|
|
const [outInRes, dailyRes, posRes, creditRes, installmentRes, loanRes, locationOverviewRes, locationPointsRes] = await Promise.all([
|
|
getAcccountsOutInAnalysis({ ...range }),
|
|
getDailyExpensesAnalysis({ ...range }),
|
|
getPosAnalysis({ ...range }),
|
|
getCreditAnalysis({
|
|
type: queryParams.value.type === '1' ? 2 : queryParams.value.type,
|
|
startTime: normalizeCreditStart(range),
|
|
endTime: normalizeCreditEnd(range)
|
|
}),
|
|
getInstallmentHistoryAnalysis({ ...repaymentRange, dataType: '2' }),
|
|
getInstallmentHistoryAnalysis({ ...repaymentRange, dataType: '3' }),
|
|
getAccountDealLocationOverview({ ...locationRange, dealType: '2' }),
|
|
getAccountDealLocationPoints({ ...locationRange, dealType: '2' })
|
|
])
|
|
|
|
outInSummary.value = outInRes.data || {}
|
|
dailySummary.value = dailyRes.data || {}
|
|
posSummary.value = posRes.data || {}
|
|
creditSummary.value = creditRes.data || {}
|
|
installmentSummary.value = installmentRes.data || {}
|
|
loanSummary.value = loanRes.data || {}
|
|
locationOverview.value = locationOverviewRes.data || {}
|
|
locationPoints.value = locationPointsRes.data || []
|
|
|
|
trendData.value = sortSeriesByTime(normalizeSeries(outInSummary.value.acccountsList, 'time', 'value'))
|
|
dailyTrendData.value = sortSeriesByTime(normalizeSeries(dailySummary.value.acccountsDateList, 'time', 'value'))
|
|
posTrendData.value = sortSeriesByTime(normalizeSeries(posSummary.value.posList, 'time', 'value'))
|
|
creditBillData.value = sortSeriesByTime(normalizeSeries(creditSummary.value.creditBillList, 'time', 'value'))
|
|
installmentPlanData.value = sortSeriesByTime(normalizeSeries(installmentSummary.value.historyDetailList, 'time', 'value'))
|
|
loanPlanData.value = sortSeriesByTime(normalizeSeries(loanSummary.value.historyDetailList, 'time', 'value'))
|
|
|
|
await nextTick()
|
|
renderCharts()
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function normalizeCreditStart(range) {
|
|
if (range.type === '1') return dayjs(range.startTime).format('YYYY-MM')
|
|
return range.startTime
|
|
}
|
|
|
|
function normalizeCreditEnd(range) {
|
|
if (range.type === '1') return dayjs(range.endTime).format('YYYY-MM')
|
|
return range.endTime
|
|
}
|
|
|
|
function normalizeSeries(list, nameKey = 'time', valueKey = 'value') {
|
|
if (!Array.isArray(list)) return []
|
|
return list
|
|
.filter((item) => item)
|
|
.map((item) => ({
|
|
name: item[nameKey] ?? item.time ?? item.remark ?? '--',
|
|
value: toNumber(item[valueKey] ?? item.amount ?? item.totalAmount ?? 0),
|
|
raw: item
|
|
}))
|
|
}
|
|
|
|
function sortSeriesByTime(list) {
|
|
return [...list].sort((first, second) => getSeriesTimeValue(first.name) - getSeriesTimeValue(second.name))
|
|
}
|
|
|
|
function getSeriesTimeValue(value) {
|
|
const date = dayjs(String(value))
|
|
if (date.isValid()) return date.valueOf()
|
|
const number = Number(value)
|
|
return Number.isFinite(number) ? number : 0
|
|
}
|
|
|
|
function getLatestTrendValue(list) {
|
|
if (!Array.isArray(list) || !list.length) return 0
|
|
return toNumber(list[list.length - 1]?.value)
|
|
}
|
|
|
|
function toNumber(value) {
|
|
const number = Number(value || 0)
|
|
return Number.isFinite(number) ? number : 0
|
|
}
|
|
|
|
function formatMoney(value) {
|
|
return toNumber(value).toLocaleString('zh-CN', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2
|
|
})
|
|
}
|
|
|
|
function formatPercent(value) {
|
|
const number = toNumber(value)
|
|
return `${number.toFixed(number >= 10 ? 0 : 1)}%`
|
|
}
|
|
|
|
function formatCoordinate(point) {
|
|
const longitude = Number(point?.longitude)
|
|
const latitude = Number(point?.latitude)
|
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return '暂无地址'
|
|
return `${longitude.toFixed(6)}, ${latitude.toFixed(6)}`
|
|
}
|
|
|
|
function getChart(id, current) {
|
|
const el = document.getElementById(id)
|
|
if (!el) return null
|
|
if (!current) return echarts.init(el)
|
|
return current
|
|
}
|
|
|
|
function renderCharts() {
|
|
renderTrendChart()
|
|
renderExpenseStructureChart()
|
|
renderRiskRadarChart()
|
|
renderPosTrendChart()
|
|
renderCreditBillChart()
|
|
renderRepaymentChart()
|
|
}
|
|
|
|
function baseChartOption() {
|
|
return {
|
|
backgroundColor: 'transparent',
|
|
textStyle: {
|
|
color: 'rgba(226, 232, 240, 0.76)',
|
|
fontFamily: 'Microsoft YaHei, Arial, sans-serif'
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
backgroundColor: 'rgba(3, 15, 28, 0.94)',
|
|
borderColor: 'rgba(103, 232, 249, 0.28)',
|
|
textStyle: { color: '#e0faff' }
|
|
},
|
|
grid: {
|
|
left: 34,
|
|
right: 18,
|
|
top: 34,
|
|
bottom: 26,
|
|
containLabel: true
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderTrendChart() {
|
|
trendChart = getChart('transactionTrendChart', trendChart)
|
|
if (!trendChart) return
|
|
const allTimes = Array.from(new Set([...trendData.value.map((item) => item.name), ...dailyTrendData.value.map((item) => item.name)]))
|
|
const trendMap = new Map(trendData.value.map((item) => [item.name, item.value]))
|
|
const dailyMap = new Map(dailyTrendData.value.map((item) => [item.name, item.value]))
|
|
trendChart.setOption({
|
|
...baseChartOption(),
|
|
legend: {
|
|
top: 2,
|
|
right: 12,
|
|
textStyle: { color: 'rgba(226, 232, 240, 0.68)' },
|
|
data: ['收支差额', '日常支出']
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: allTimes,
|
|
axisLine: { lineStyle: { color: 'rgba(125, 211, 252, 0.22)' } },
|
|
axisLabel: { color: 'rgba(226, 232, 240, 0.62)' }
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
axisLabel: { color: 'rgba(226, 232, 240, 0.62)' },
|
|
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.12)' } }
|
|
},
|
|
series: [
|
|
{
|
|
name: '收支差额',
|
|
type: 'line',
|
|
smooth: true,
|
|
symbolSize: 6,
|
|
data: allTimes.map((time) => trendMap.get(time) || 0),
|
|
lineStyle: { width: 3, color: '#67e8f9' },
|
|
itemStyle: { color: '#67e8f9' },
|
|
areaStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: 'rgba(103, 232, 249, 0.28)' },
|
|
{ offset: 1, color: 'rgba(103, 232, 249, 0.02)' }
|
|
])
|
|
}
|
|
},
|
|
{
|
|
name: '日常支出',
|
|
type: 'bar',
|
|
barMaxWidth: 18,
|
|
data: allTimes.map((time) => dailyMap.get(time) || 0),
|
|
itemStyle: { color: '#2dd4bf', borderRadius: [4, 4, 0, 0] }
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
function renderExpenseStructureChart() {
|
|
expenseStructureChart = getChart('expenseStructureChart', expenseStructureChart)
|
|
if (!expenseStructureChart) return
|
|
const data = [
|
|
{ name: '日常支出', value: dailyExpenseTotal.value },
|
|
{ name: 'POS手续费', value: toNumber(outInSummary.value.posExpenses) },
|
|
{ name: '分期利息', value: toNumber(outInSummary.value.creditInterest) },
|
|
{ name: '贷款利息', value: toNumber(outInSummary.value.onLineInterest) },
|
|
{ name: '其他支出', value: Math.max(0, totalOutcome.value - dailyExpenseTotal.value - financeCost.value) }
|
|
].filter((item) => item.value > 0)
|
|
expenseStructureChart.setOption({
|
|
backgroundColor: 'transparent',
|
|
tooltip: {
|
|
trigger: 'item',
|
|
backgroundColor: 'rgba(3, 15, 28, 0.94)',
|
|
borderColor: 'rgba(103, 232, 249, 0.28)',
|
|
textStyle: { color: '#e0faff' }
|
|
},
|
|
color: ['#67e8f9', '#2dd4bf', '#facc15', '#fb7185', '#a78bfa'],
|
|
series: [
|
|
{
|
|
type: 'pie',
|
|
radius: ['52%', '72%'],
|
|
center: ['50%', '50%'],
|
|
avoidLabelOverlap: true,
|
|
label: { color: 'rgba(226, 232, 240, 0.72)', formatter: '{b}\n{d}%' },
|
|
labelLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.26)' } },
|
|
data
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
function renderRiskRadarChart() {
|
|
riskRadarChart = getChart('riskRadarChart', riskRadarChart)
|
|
if (!riskRadarChart) return
|
|
const creditRate = toNumber(creditSummary.value.creditBillRate)
|
|
const installmentRate = totalOutcome.value ? (toNumber(installmentSummary.value.leftDetail) / totalOutcome.value) * 100 : 0
|
|
const loanRate = totalOutcome.value ? (toNumber(loanSummary.value.leftDetail) / totalOutcome.value) * 100 : 0
|
|
const feeRate = totalOutcome.value ? (financeCost.value / totalOutcome.value) * 100 : 0
|
|
riskRadarChart.setOption({
|
|
backgroundColor: 'transparent',
|
|
tooltip: {
|
|
backgroundColor: 'rgba(3, 15, 28, 0.94)',
|
|
borderColor: 'rgba(103, 232, 249, 0.28)',
|
|
textStyle: { color: '#e0faff' }
|
|
},
|
|
radar: {
|
|
radius: '66%',
|
|
center: ['50%', '55%'],
|
|
indicator: [
|
|
{ name: '信用卡使用率', max: 100 },
|
|
{ name: '分期压力', max: 100 },
|
|
{ name: '贷款压力', max: 100 },
|
|
{ name: '资金成本', max: 100 },
|
|
{ name: '支出强度', max: 100 }
|
|
],
|
|
axisName: { color: 'rgba(226, 232, 240, 0.72)' },
|
|
splitLine: { lineStyle: { color: 'rgba(125, 211, 252, 0.18)' } },
|
|
splitArea: { areaStyle: { color: ['rgba(14, 165, 233, 0.04)', 'rgba(45, 212, 191, 0.03)'] } },
|
|
axisLine: { lineStyle: { color: 'rgba(125, 211, 252, 0.18)' } }
|
|
},
|
|
series: [
|
|
{
|
|
type: 'radar',
|
|
data: [
|
|
{
|
|
value: [creditRate, installmentRate, loanRate, feeRate, Math.min(100, (totalOutcome.value / Math.max(totalIncome.value, 1)) * 100)],
|
|
name: '压力指数',
|
|
areaStyle: { color: 'rgba(103, 232, 249, 0.22)' },
|
|
lineStyle: { color: '#67e8f9', width: 2 },
|
|
itemStyle: { color: '#67e8f9' }
|
|
}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
function renderPosTrendChart() {
|
|
posTrendChart = getChart('posTrendChart', posTrendChart)
|
|
if (!posTrendChart) return
|
|
posTrendChart.setOption({
|
|
...baseChartOption(),
|
|
xAxis: {
|
|
type: 'category',
|
|
data: posTrendData.value.map((item) => item.name),
|
|
axisLabel: { color: 'rgba(226, 232, 240, 0.56)' },
|
|
axisLine: { lineStyle: { color: 'rgba(125, 211, 252, 0.16)' } }
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
axisLabel: { color: 'rgba(226, 232, 240, 0.56)' },
|
|
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.1)' } }
|
|
},
|
|
series: [
|
|
{
|
|
name: 'POS刷卡',
|
|
type: 'bar',
|
|
barMaxWidth: 18,
|
|
data: posTrendData.value.map((item) => item.value),
|
|
itemStyle: { color: '#67e8f9', borderRadius: [4, 4, 0, 0] }
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
function renderCreditBillChart() {
|
|
creditBillChart = getChart('creditBillChart', creditBillChart)
|
|
if (!creditBillChart) return
|
|
creditBillChart.setOption({
|
|
...baseChartOption(),
|
|
xAxis: {
|
|
type: 'category',
|
|
data: creditBillData.value.map((item) => item.name),
|
|
axisLabel: { color: 'rgba(226, 232, 240, 0.56)' },
|
|
axisLine: { lineStyle: { color: 'rgba(125, 211, 252, 0.16)' } }
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
axisLabel: { color: 'rgba(226, 232, 240, 0.56)' },
|
|
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.1)' } }
|
|
},
|
|
series: [
|
|
{
|
|
name: '信用卡账单',
|
|
type: 'line',
|
|
smooth: true,
|
|
data: creditBillData.value.map((item) => item.value),
|
|
lineStyle: { width: 3, color: '#a78bfa' },
|
|
itemStyle: { color: '#a78bfa' },
|
|
areaStyle: { color: 'rgba(167, 139, 250, 0.18)' }
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
function renderRepaymentChart() {
|
|
repaymentChart = getChart('repaymentChart', repaymentChart)
|
|
if (!repaymentChart) return
|
|
const allTimes = Array.from(new Set([...installmentPlanData.value.map((item) => item.name), ...loanPlanData.value.map((item) => item.name)])).sort(
|
|
(first, second) => getSeriesTimeValue(first) - getSeriesTimeValue(second)
|
|
)
|
|
const installmentMap = new Map(installmentPlanData.value.map((item) => [item.name, item.value]))
|
|
const loanMap = new Map(loanPlanData.value.map((item) => [item.name, item.value]))
|
|
repaymentChart.setOption({
|
|
...baseChartOption(),
|
|
legend: {
|
|
top: 2,
|
|
right: 12,
|
|
textStyle: { color: 'rgba(226, 232, 240, 0.68)' },
|
|
data: ['信用卡分期', '贷款']
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: allTimes,
|
|
axisLabel: { color: 'rgba(226, 232, 240, 0.56)' },
|
|
axisLine: { lineStyle: { color: 'rgba(125, 211, 252, 0.16)' } }
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
axisLabel: { color: 'rgba(226, 232, 240, 0.56)' },
|
|
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.1)' } }
|
|
},
|
|
series: [
|
|
{
|
|
name: '信用卡分期',
|
|
type: 'bar',
|
|
stack: 'repayment',
|
|
barMaxWidth: 20,
|
|
data: allTimes.map((time) => installmentMap.get(time) || 0),
|
|
itemStyle: { color: '#facc15', borderRadius: [4, 4, 0, 0] }
|
|
},
|
|
{
|
|
name: '贷款',
|
|
type: 'bar',
|
|
stack: 'repayment',
|
|
barMaxWidth: 20,
|
|
data: allTimes.map((time) => loanMap.get(time) || 0),
|
|
itemStyle: { color: '#fb7185', borderRadius: [4, 4, 0, 0] }
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
function resizeCharts() {
|
|
;[trendChart, expenseStructureChart, riskRadarChart, posTrendChart, creditBillChart, repaymentChart].forEach((chart) => chart?.resize())
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadDashboard()
|
|
window.addEventListener('resize', resizeCharts)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('resize', resizeCharts)
|
|
;[trendChart, expenseStructureChart, riskRadarChart, posTrendChart, creditBillChart, repaymentChart].forEach((chart) => chart?.dispose())
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.transaction-screen {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
box-sizing: border-box;
|
|
color: #f8fafc;
|
|
background: #030a13;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
.screen-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background: linear-gradient(90deg, rgba(14, 165, 233, 0.08) 1px, transparent 1px), linear-gradient(180deg, rgba(14, 165, 233, 0.07) 1px, transparent 1px),
|
|
linear-gradient(120deg, rgba(14, 165, 233, 0.14), transparent 42%), linear-gradient(250deg, rgba(20, 184, 166, 0.12), transparent 48%), #030a13;
|
|
background-size: 44px 44px, 44px 44px, auto, auto, auto;
|
|
}
|
|
|
|
.screen-header,
|
|
.screen-main,
|
|
.screen-loading {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.screen-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 16px 20px;
|
|
background: rgba(3, 15, 28, 0.86);
|
|
border-bottom: 1px solid rgba(125, 211, 252, 0.22);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.screen-title {
|
|
font-size: 27px;
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.screen-subtitle {
|
|
margin-top: 6px;
|
|
color: rgba(186, 230, 253, 0.64);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.screen-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.filter-type {
|
|
width: 94px;
|
|
}
|
|
|
|
.filter-date {
|
|
width: 260px;
|
|
}
|
|
|
|
.filter-year {
|
|
width: 128px;
|
|
}
|
|
|
|
.range-split {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 34px;
|
|
color: rgba(186, 230, 253, 0.68);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.screen-actions :deep(.el-input__wrapper),
|
|
.screen-actions :deep(.el-select__wrapper) {
|
|
min-height: 34px;
|
|
color: #dff7ff;
|
|
background: rgba(3, 15, 28, 0.68);
|
|
border: 1px solid rgba(103, 232, 249, 0.32);
|
|
border-radius: 4px;
|
|
box-shadow: inset 0 0 18px rgba(14, 165, 233, 0.12);
|
|
}
|
|
|
|
.screen-actions :deep(.el-input__inner),
|
|
.screen-actions :deep(.el-range-input),
|
|
.screen-actions :deep(.el-select__selected-item),
|
|
.screen-actions :deep(.el-range-separator) {
|
|
color: #e0faff;
|
|
}
|
|
|
|
.screen-action-group {
|
|
display: inline-flex;
|
|
gap: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.screen-action-group :deep(.el-button + .el-button) {
|
|
margin-left: 0;
|
|
}
|
|
|
|
.screen-btn {
|
|
height: 34px;
|
|
min-width: 72px;
|
|
padding: 0 14px;
|
|
color: #dff7ff;
|
|
font-weight: 700;
|
|
background: rgba(3, 15, 28, 0.66);
|
|
border: 1px solid rgba(103, 232, 249, 0.34);
|
|
border-radius: 4px;
|
|
box-shadow: inset 0 0 14px rgba(14, 165, 233, 0.16);
|
|
}
|
|
|
|
.screen-btn-primary {
|
|
color: #06121e;
|
|
background: linear-gradient(135deg, #67e8f9 0%, #2dd4bf 100%);
|
|
border-color: rgba(103, 232, 249, 0.86);
|
|
}
|
|
|
|
.screen-main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
padding: 12px 18px 18px;
|
|
}
|
|
|
|
.metric-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(8, minmax(132px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.metric-card,
|
|
.panel {
|
|
background: linear-gradient(145deg, rgba(8, 20, 32, 0.9), rgba(8, 47, 73, 0.66));
|
|
border: 1px solid rgba(125, 211, 252, 0.18);
|
|
border-radius: 6px;
|
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28), inset 0 0 26px rgba(14, 165, 233, 0.08);
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
|
|
.metric-card {
|
|
min-width: 0;
|
|
min-height: 82px;
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.metric-card span,
|
|
.metric-card em {
|
|
display: block;
|
|
color: rgba(186, 230, 253, 0.62);
|
|
font-size: 12px;
|
|
line-height: 1.3;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.metric-card strong {
|
|
display: block;
|
|
margin-top: 7px;
|
|
font-size: 20px;
|
|
line-height: 1.1;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.metric-card em {
|
|
margin-top: 8px;
|
|
font-style: normal;
|
|
}
|
|
|
|
.value-income {
|
|
color: #86efac;
|
|
}
|
|
|
|
.value-expense,
|
|
.value-warning {
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.value-cyan {
|
|
color: #67e8f9;
|
|
}
|
|
|
|
.value-blue {
|
|
color: #a5b4fc;
|
|
}
|
|
|
|
.insight-grid,
|
|
.analysis-grid,
|
|
.bottom-grid,
|
|
.decision-grid {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.insight-grid {
|
|
grid-template-columns: 1.2fr 1.2fr 1fr 1fr;
|
|
}
|
|
|
|
.analysis-grid {
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
|
|
.bottom-grid {
|
|
grid-template-columns: 1.1fr 1fr 1.2fr;
|
|
}
|
|
|
|
.bottom-grid .panel {
|
|
min-height: 236px;
|
|
}
|
|
|
|
.decision-grid {
|
|
grid-template-columns: 1.4fr 0.9fr;
|
|
}
|
|
|
|
.panel {
|
|
min-width: 0;
|
|
min-height: 260px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.panel-span-2 {
|
|
grid-column: span 2;
|
|
}
|
|
|
|
.panel-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.panel-head span {
|
|
color: #e0faff;
|
|
font-size: 15px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.panel-head em {
|
|
color: rgba(186, 230, 253, 0.54);
|
|
font-size: 12px;
|
|
font-style: normal;
|
|
overflow-wrap: anywhere;
|
|
text-align: right;
|
|
white-space: normal;
|
|
}
|
|
|
|
.panel-head .rank-total {
|
|
color: #67e8f9;
|
|
font-size: 16px;
|
|
font-weight: 800;
|
|
text-shadow: 0 0 14px rgba(103, 232, 249, 0.28);
|
|
}
|
|
|
|
.chart {
|
|
width: 100%;
|
|
height: 220px;
|
|
}
|
|
|
|
.chart-large {
|
|
height: 226px;
|
|
}
|
|
|
|
.chart-small {
|
|
height: 142px;
|
|
}
|
|
|
|
.rank-list,
|
|
.location-list,
|
|
.insight-list,
|
|
.event-list,
|
|
.conclusion-list,
|
|
.alert-list {
|
|
min-height: 0;
|
|
}
|
|
|
|
.daily-rank-panel {
|
|
min-height: 0;
|
|
}
|
|
|
|
.daily-rank-panel .rank-list {
|
|
padding-right: 4px;
|
|
}
|
|
|
|
.rank-row {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: 26px minmax(0, 1fr) auto;
|
|
gap: 8px;
|
|
align-items: center;
|
|
min-height: 34px;
|
|
padding: 8px 9px;
|
|
border: 1px solid rgba(125, 211, 252, 0.11);
|
|
border-radius: 5px;
|
|
background: rgba(3, 15, 28, 0.44);
|
|
}
|
|
|
|
.daily-rank-panel .rank-row {
|
|
min-height: 28px;
|
|
padding: 5px 9px;
|
|
}
|
|
|
|
.rank-row + .rank-row {
|
|
margin-top: 7px;
|
|
}
|
|
|
|
.daily-rank-panel .rank-row + .rank-row {
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.rank-row i {
|
|
position: absolute;
|
|
left: 0;
|
|
bottom: 0;
|
|
width: var(--rank-width, 0%);
|
|
height: 2px;
|
|
background: linear-gradient(90deg, #67e8f9, #2dd4bf);
|
|
}
|
|
|
|
.rank-index {
|
|
color: rgba(103, 232, 249, 0.86);
|
|
font-weight: 800;
|
|
}
|
|
|
|
.rank-name,
|
|
.rank-row strong {
|
|
font-size: 12px;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.rank-name {
|
|
color: rgba(226, 232, 240, 0.78);
|
|
}
|
|
|
|
.rank-row strong {
|
|
color: #e0faff;
|
|
}
|
|
|
|
.daily-rank-panel .rank-row strong {
|
|
color: #e0faff;
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
text-shadow: 0 0 12px rgba(103, 232, 249, 0.26);
|
|
}
|
|
|
|
.mini-stats,
|
|
.dual-summary {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.mini-stats {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.dual-summary {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.mini-stats div,
|
|
.dual-summary div {
|
|
min-width: 0;
|
|
padding: 8px;
|
|
background: rgba(3, 15, 28, 0.42);
|
|
border: 1px solid rgba(125, 211, 252, 0.12);
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.mini-stats span,
|
|
.dual-summary span,
|
|
.dual-summary em {
|
|
display: block;
|
|
color: rgba(186, 230, 253, 0.58);
|
|
font-size: 12px;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.mini-stats strong,
|
|
.dual-summary strong {
|
|
display: block;
|
|
margin-top: 5px;
|
|
color: #e0faff;
|
|
font-size: 15px;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.dual-summary em {
|
|
margin-top: 5px;
|
|
font-style: normal;
|
|
}
|
|
|
|
.location-list,
|
|
.event-list {
|
|
padding-right: 0;
|
|
}
|
|
|
|
.location-row,
|
|
.insight-row,
|
|
.event-row,
|
|
.conclusion-card,
|
|
.alert-row {
|
|
min-width: 0;
|
|
border: 1px solid rgba(125, 211, 252, 0.11);
|
|
border-radius: 5px;
|
|
background: rgba(3, 15, 28, 0.42);
|
|
}
|
|
|
|
.location-row {
|
|
display: grid;
|
|
grid-template-columns: 34px minmax(0, 1fr) auto;
|
|
gap: 9px;
|
|
align-items: center;
|
|
min-height: 46px;
|
|
padding: 8px;
|
|
}
|
|
|
|
.location-row + .location-row,
|
|
.insight-row + .insight-row,
|
|
.event-row + .event-row {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.location-row > span {
|
|
color: rgba(103, 232, 249, 0.72);
|
|
font-weight: 800;
|
|
}
|
|
|
|
.location-row div {
|
|
min-width: 0;
|
|
}
|
|
|
|
.location-row strong,
|
|
.location-row em,
|
|
.location-row b {
|
|
display: block;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.location-row strong {
|
|
color: #f8fafc;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.location-row em {
|
|
margin-top: 5px;
|
|
color: rgba(226, 232, 240, 0.54);
|
|
font-size: 12px;
|
|
font-style: normal;
|
|
}
|
|
|
|
.location-row b {
|
|
color: #67e8f9;
|
|
font-size: 13px;
|
|
text-align: right;
|
|
}
|
|
|
|
.insight-row {
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.insight-row span,
|
|
.insight-row em {
|
|
display: block;
|
|
color: rgba(186, 230, 253, 0.58);
|
|
font-size: 12px;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.insight-row strong {
|
|
display: block;
|
|
margin-top: 5px;
|
|
color: #e0faff;
|
|
font-size: 15px;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.insight-row em {
|
|
margin-top: 6px;
|
|
font-style: normal;
|
|
}
|
|
|
|
.event-row {
|
|
display: grid;
|
|
grid-template-columns: 46px 78px minmax(0, 1fr) auto;
|
|
gap: 8px;
|
|
align-items: center;
|
|
min-height: 38px;
|
|
padding: 7px 8px;
|
|
}
|
|
|
|
.event-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 23px;
|
|
color: #06121e;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.event-credit {
|
|
background: #a78bfa;
|
|
}
|
|
|
|
.event-pos {
|
|
background: #67e8f9;
|
|
}
|
|
|
|
.event-repay {
|
|
background: #facc15;
|
|
}
|
|
|
|
.event-row strong,
|
|
.event-row em,
|
|
.event-row b {
|
|
font-size: 12px;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.event-row strong {
|
|
color: rgba(226, 232, 240, 0.72);
|
|
}
|
|
|
|
.event-row em {
|
|
color: rgba(226, 232, 240, 0.58);
|
|
font-style: normal;
|
|
}
|
|
|
|
.event-row b {
|
|
color: #e0faff;
|
|
text-align: right;
|
|
}
|
|
|
|
.conclusion-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.conclusion-card {
|
|
position: relative;
|
|
min-height: 82px;
|
|
padding: 11px 12px 11px 14px;
|
|
}
|
|
|
|
.conclusion-card::before {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 10px;
|
|
bottom: 10px;
|
|
width: 3px;
|
|
content: '';
|
|
background: #67e8f9;
|
|
border-radius: 0 99px 99px 0;
|
|
}
|
|
|
|
.conclusion-card.level-good::before {
|
|
background: #86efac;
|
|
}
|
|
|
|
.conclusion-card.level-warning::before {
|
|
background: #facc15;
|
|
}
|
|
|
|
.conclusion-card.level-danger::before {
|
|
background: #fb7185;
|
|
}
|
|
|
|
.conclusion-card span,
|
|
.conclusion-card em {
|
|
display: block;
|
|
color: rgba(186, 230, 253, 0.58);
|
|
font-size: 12px;
|
|
line-height: 1.35;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.conclusion-card strong {
|
|
display: block;
|
|
margin-top: 6px;
|
|
color: #e0faff;
|
|
font-size: 15px;
|
|
line-height: 1.35;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.conclusion-card em {
|
|
margin-top: 7px;
|
|
white-space: normal;
|
|
}
|
|
|
|
.forecast-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.forecast-grid div {
|
|
min-width: 0;
|
|
padding: 10px;
|
|
background: rgba(3, 15, 28, 0.42);
|
|
border: 1px solid rgba(125, 211, 252, 0.11);
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.forecast-grid span,
|
|
.forecast-grid em,
|
|
.alert-row span,
|
|
.alert-row em {
|
|
display: block;
|
|
color: rgba(186, 230, 253, 0.58);
|
|
font-size: 12px;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.forecast-grid strong,
|
|
.alert-row strong {
|
|
display: block;
|
|
margin-top: 6px;
|
|
color: #e0faff;
|
|
font-size: 18px;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.forecast-grid em,
|
|
.alert-row em {
|
|
margin-top: 6px;
|
|
font-style: normal;
|
|
}
|
|
|
|
.alert-list {
|
|
padding-right: 0;
|
|
}
|
|
|
|
.alert-row {
|
|
position: relative;
|
|
padding: 10px 11px 10px 14px;
|
|
}
|
|
|
|
.alert-row + .alert-row {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.alert-row::before {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 9px;
|
|
bottom: 9px;
|
|
width: 3px;
|
|
content: '';
|
|
background: #67e8f9;
|
|
border-radius: 0 99px 99px 0;
|
|
}
|
|
|
|
.alert-good::before {
|
|
background: #86efac;
|
|
}
|
|
|
|
.alert-warning::before {
|
|
background: #facc15;
|
|
}
|
|
|
|
.alert-danger::before {
|
|
background: #fb7185;
|
|
}
|
|
|
|
.screen-loading {
|
|
position: fixed;
|
|
left: 50%;
|
|
top: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 18px;
|
|
color: #e0f2fe;
|
|
background: rgba(8, 20, 32, 0.9);
|
|
border: 1px solid rgba(125, 211, 252, 0.38);
|
|
border-radius: 6px;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
|
|
:global(.analysis-screen-popper) {
|
|
background: rgba(3, 15, 28, 0.96) !important;
|
|
border: 1px solid rgba(103, 232, 249, 0.28) !important;
|
|
border-radius: 6px !important;
|
|
}
|
|
|
|
:global(.analysis-screen-popper .el-popper__arrow::before) {
|
|
background: rgba(3, 15, 28, 0.96) !important;
|
|
border-color: rgba(103, 232, 249, 0.28) !important;
|
|
}
|
|
|
|
:global(.analysis-screen-popper .el-select-dropdown__item) {
|
|
color: rgba(224, 250, 255, 0.82);
|
|
}
|
|
|
|
:global(.analysis-screen-popper .el-select-dropdown__item.hover),
|
|
:global(.analysis-screen-popper .el-select-dropdown__item:hover),
|
|
:global(.analysis-screen-popper .el-select-dropdown__item.selected) {
|
|
color: #67e8f9;
|
|
background: rgba(14, 165, 233, 0.18);
|
|
}
|
|
|
|
@media (max-width: 1500px) {
|
|
.metric-grid {
|
|
grid-template-columns: repeat(4, minmax(132px, 1fr));
|
|
}
|
|
|
|
.insight-grid,
|
|
.analysis-grid,
|
|
.bottom-grid,
|
|
.decision-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.panel-span-2 {
|
|
grid-column: span 2;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
.screen-header {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.screen-actions {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.metric-grid,
|
|
.insight-grid,
|
|
.analysis-grid,
|
|
.bottom-grid,
|
|
.decision-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.panel-span-2 {
|
|
grid-column: span 1;
|
|
}
|
|
|
|
.filter-date {
|
|
width: min(100%, 260px);
|
|
}
|
|
|
|
.conclusion-list,
|
|
.forecast-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
}
|
|
</style>
|