Files
intc-vue3/src/views/invest/transactionAnalysisScreen/index.vue
2026-06-18 22:49:26 +08:00

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>