Files
yunzerwebsiteallinone/uniapp/pages/erp/report/index.vue
T
2026-10-19 01:13:17 +08:00

1113 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view class="tab-bar">
<scroll-view class="tab-scroll" scroll-x :show-scrollbar="false">
<view class="tab-list">
<view
v-for="t in TABS"
:key="t.key"
class="tab"
:class="{ active: tab === t.key }"
@tap="switchTab(t.key)"
>
<text>{{ t.label }}</text>
</view>
</view>
</scroll-view>
</view>
<view class="filter-bar">
<view class="date-row">
<picker mode="date" :value="dateFrom" @change="onDate('from', $event)">
<view class="date-cell">
<FaIcon name="calendar-days" color="#94a3b8" :size="14" />
<text>{{ dateFrom || '开始日期' }}</text>
</view>
</picker>
<text class="date-sep">至</text>
<picker mode="date" :value="dateTo" @change="onDate('to', $event)">
<view class="date-cell">
<FaIcon name="calendar-days" color="#94a3b8" :size="14" />
<text>{{ dateTo || '结束日期' }}</text>
</view>
</picker>
<text class="quick" @tap="useQuick">近30天</text>
</view>
<!-- 购销汇总:方向 + 维度 -->
<view v-if="tab === 'summary'" class="chip-row">
<view
v-for="s in SIDES"
:key="s.value"
class="chip"
:class="{ active: side === s.value }"
@tap="onSide(s.value)"
>
<text>{{ s.label }}</text>
</view>
<view class="chip ghost" @tap="openDimension">
<text>{{ dimensionLabel }}</text>
<FaIcon name="chevron-down" color="#94a3b8" :size="11" />
</view>
</view>
<!-- 排行:榜单方向 + 条数 -->
<view v-else-if="tab === 'rank'" class="chip-row">
<view class="chip" :class="{ active: order === 'desc' }" @tap="onOrder('desc')">
<text>热销榜</text>
</view>
<view class="chip" :class="{ active: order === 'asc' }" @tap="onOrder('asc')">
<text>滞销榜</text>
</view>
<view class="chip ghost" @tap="openTop">
<text>Top {{ top }}</text>
<FaIcon name="chevron-down" color="#94a3b8" :size="11" />
</view>
</view>
<!-- 收发存:仓库 + 关键字 -->
<template v-else-if="tab === 'move'">
<view class="chip-row">
<view class="chip" :class="{ active: !!warehouseId }" @tap="openWarehouse">
<text>{{ warehouseName || '全部仓库' }}</text>
</view>
</view>
<view class="search-box">
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
<input
v-model="keyword"
class="search-input"
placeholder="搜索商品名称 / 编码"
confirm-type="search"
@confirm="run"
/>
</view>
</template>
</view>
<view v-if="loading" class="state-box">
<text class="state-text">统计中...</text>
</view>
<!-- 购销汇总 -->
<template v-else-if="tab === 'summary'">
<view class="kpi-row">
<view class="kpi">
<text class="kpi-label">{{ side === 'purchase' ? '采购总量' : '销售总量' }}</text>
<text class="kpi-value">{{ qtyText(sumSummary.summary.qty) }}</text>
</view>
<view class="kpi">
<text class="kpi-label">{{ side === 'purchase' ? '采购总额' : '销售总额' }}</text>
<text class="kpi-value strong">¥{{ moneyText(sumSummary.summary.amount) }}</text>
</view>
</view>
<view v-if="!sumSummary.list.length" class="state-box">
<u-empty mode="search" text="该区间没有已生效单据" />
</view>
<view v-for="(r, i) in sumSummary.list" :key="`${r.key}-${i}`" class="row-card">
<view class="row-main">
<text class="row-title">{{ r.label }}</text>
<text class="row-sub">{{ r.lines }} 行明细 · 数量 {{ qtyText(r.qty) }}</text>
</view>
<text class="row-amount" :class="r.amount < 0 ? 'negative' : ''">¥{{ moneyText(r.amount) }}</text>
</view>
</template>
<!-- 热销 / 滞销排行 -->
<template v-else-if="tab === 'rank'">
<view v-if="!rank.list.length" class="state-box">
<u-empty mode="search" text="暂无排行数据" />
</view>
<view v-for="(r, i) in rank.list" :key="r.product_id" class="row-card" @tap="openProduct(r)">
<view class="rk-no" :class="{ top: i < 3 }">{{ i + 1 }}</view>
<view class="row-main">
<text class="row-title">{{ r.product_name }}</text>
<text class="row-sub">{{ [r.product_code, r.spec].filter(Boolean).join(' · ') }} · {{ qtyText(r.qty) }} {{ r.unit || '' }}</text>
</view>
<text class="row-amount">¥{{ moneyText(r.amount) }}</text>
</view>
</template>
<!-- 收发存汇总 -->
<template v-else-if="tab === 'move'">
<view class="kpi-row">
<view class="kpi">
<text class="kpi-label">本期入</text>
<text class="kpi-value">¥{{ moneyText(move.summary.in_amount) }}</text>
</view>
<view class="kpi">
<text class="kpi-label">本期出</text>
<text class="kpi-value">¥{{ moneyText(move.summary.out_amount) }}</text>
</view>
<view class="kpi">
<text class="kpi-label">期末货值</text>
<text class="kpi-value strong">¥{{ moneyText(move.summary.end_amount) }}</text>
</view>
</view>
<view v-if="!move.list.length" class="state-box">
<u-empty mode="search" text="该区间没有收发存记录" />
</view>
<view v-for="(r, i) in move.list" :key="`${r.product_id}-${r.warehouse_id}-${i}`" class="mv-card" @tap="openLedger(r)">
<view class="mv-top">
<text class="row-title">{{ r.product_name }}</text>
<text class="mv-wh">{{ r.warehouse_name || '未分仓' }}</text>
</view>
<text class="row-sub">{{ [r.product_code, r.category_name].filter(Boolean).join(' · ') }}</text>
<view class="mv-grid">
<view class="mv-cell">
<text class="mv-label">期初</text>
<text class="mv-value">{{ qtyText(r.open_qty) }}</text>
<text class="mv-money">¥{{ moneyText(r.open_amount) }}</text>
</view>
<view class="mv-cell">
<text class="mv-label in">入</text>
<text class="mv-value">{{ qtyText(r.in_qty) }}</text>
<text class="mv-money">¥{{ moneyText(r.in_amount) }}</text>
</view>
<view class="mv-cell">
<text class="mv-label out">出</text>
<text class="mv-value">{{ qtyText(r.out_qty) }}</text>
<text class="mv-money">¥{{ moneyText(r.out_amount) }}</text>
</view>
<view class="mv-cell">
<text class="mv-label">期末</text>
<text class="mv-value strong">{{ qtyText(r.end_qty) }}</text>
<text class="mv-money">¥{{ moneyText(r.end_amount) }}</text>
</view>
</view>
</view>
<view v-if="move.list.length < move.total" class="more" @tap="loadMoreMove">
<text>加载更多({{ move.list.length }}/{{ move.total }})</text>
</view>
</template>
<!-- 毛利分析 -->
<template v-else-if="tab === 'profit'">
<view class="hero-card">
<view class="hero-line">
<text class="hero-label">毛利额</text>
<text class="hero-value">¥{{ moneyText(profit.summary.profit) }}</text>
</view>
<view class="hero-grid">
<view class="hero-cell">
<text class="hero-cell-label">收入</text>
<text class="hero-cell-value">¥{{ moneyText(profit.summary.revenue) }}</text>
</view>
<view class="hero-cell">
<text class="hero-cell-label">成本</text>
<text class="hero-cell-value">¥{{ moneyText(profit.summary.cost) }}</text>
</view>
<view class="hero-cell">
<text class="hero-cell-label">毛利率</text>
<text class="hero-cell-value">{{ Number(profit.summary.profit_rate || 0) }}%</text>
</view>
</view>
</view>
<view v-if="!profit.list.length" class="state-box">
<u-empty mode="search" text="该区间没有销售出库" />
</view>
<view v-for="r in profit.list" :key="r.product_id" class="pf-card" @tap="openProduct(r)">
<view class="mv-top">
<text class="row-title">{{ r.product_name }}</text>
<text class="pf-rate" :class="Number(r.profit_rate) < 0 ? 'negative' : ''">{{ Number(r.profit_rate) }}%</text>
</view>
<text class="row-sub">{{ qtyText(r.qty) }} {{ r.unit || '' }} · 收入 ¥{{ moneyText(r.revenue) }} · 成本 ¥{{ moneyText(r.cost) }}</text>
<view class="pf-bar">
<view class="pf-bar-fill" :style="{ width: barWidth(r, 'revenue') }"></view>
</view>
</view>
</template>
<!-- 账龄分析 -->
<template v-else-if="tab === 'aging'">
<view v-for="side in AGING_SIDES" :key="side.key" class="ag-block">
<view class="ag-head">
<text class="ag-title">{{ side.title }}</text>
<text class="ag-total">¥{{ moneyText(agingTotalOf(side.key)) }}</text>
</view>
<view class="ag-bar">
<view
v-for="(b, i) in aging.buckets"
:key="b.label"
class="ag-seg"
:style="{ width: segWidth(side.key, b.label), background: SEG_COLORS[i % SEG_COLORS.length] }"
></view>
</view>
<view class="ag-legend">
<view v-for="(b, i) in aging.buckets" :key="b.label" class="ag-legend-item">
<view class="dot" :style="{ background: SEG_COLORS[i % SEG_COLORS.length] }"></view>
<text class="lg-label">{{ b.label }}</text>
<text class="lg-amount">¥{{ moneyText(bucketAmount(side.key, b.label)) }}</text>
</view>
</view>
<view v-for="row in rowsOf(side.value)" :key="`${side.value}-${row.partner_id}`" class="ag-row" @tap="openBills(side, row)">
<view class="row-main">
<text class="row-title">{{ row.partner_name }}</text>
<text class="row-sub">{{ bucketText(row) }}</text>
</view>
<text class="row-amount">¥{{ moneyText(row.total) }}</text>
</view>
<view v-if="!rowsOf(side.value).length" class="ag-empty">
<text>无{{ side.title }}未结余额</text>
</view>
</view>
</template>
<!-- 资金流水 -->
<template v-else>
<view class="kpi-row">
<view class="kpi">
<text class="kpi-label">总收入</text>
<text class="kpi-value in">¥{{ moneyText(cash.totalIn) }}</text>
</view>
<view class="kpi">
<text class="kpi-label">总支出</text>
<text class="kpi-value">¥{{ moneyText(cash.totalOut) }}</text>
</view>
<view class="kpi">
<text class="kpi-label">净额</text>
<text class="kpi-value strong" :class="cash.net < 0 ? 'negative' : ''">
{{ cash.net < 0 ? '-' : '+' }}¥{{ moneyText(Math.abs(cash.net)) }}
</text>
</view>
</view>
<view v-if="!cash.list.length" class="state-box">
<u-empty mode="search" text="该区间没有已确认的收付款" />
</view>
<view v-for="d in cash.list" :key="d.date" class="row-card">
<view class="row-main">
<text class="row-title">{{ d.date }}</text>
<text class="row-sub">入 ¥{{ moneyText(d.in) }} · 出 ¥{{ moneyText(d.out) }}</text>
</view>
<text class="row-amount" :class="d.net < 0 ? 'negative' : 'positive'">
{{ d.net < 0 ? '-' : '+' }}¥{{ moneyText(Math.abs(d.net)) }}
</text>
</view>
</template>
<ErpPicker
:show="picker.show"
:title="picker.title"
placeholder="搜索"
:options="picker.options"
:model-value="picker.current"
@update:show="picker.show = false"
@select="onPick"
/>
</view>
</template>
<script setup>
/**
* 报表中心。所有口径都直接取后端结果,端上不再二次汇总:
* 购销汇总的退货冲减、毛利的实际出库成本、收发存的期初倒推,与 Web 端同一份 core。
*/
import { computed, ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import FaIcon from '@/components/FaIcon.vue'
import ErpPicker from '@/components/erp/ErpPicker.vue'
import {
REPORT_DIMENSIONS,
fetchAging,
fetchCashFlow,
fetchProfit,
fetchPurchaseSummary,
fetchSalesRank,
fetchSalesSummary,
fetchStockMove,
fetchWarehouses,
moneyText,
qtyText
} from '@/api/erp.js'
const TABS = [
{ key: 'summary', label: '购销汇总' },
{ key: 'rank', label: '销售排行' },
{ key: 'move', label: '收发存' },
{ key: 'profit', label: '毛利分析' },
{ key: 'aging', label: '账龄' },
{ key: 'cash', label: '资金流水' }
]
const SIDES = [
{ value: 'purchase', label: '采购' },
{ value: 'sales', label: '销售' }
]
const AGING_SIDES = [
{ key: 'receivable', value: 1, title: '应收账龄' },
{ key: 'payable', value: 2, title: '应付账龄' }
]
const SEG_COLORS = ['#22c55e', '#f59e0b', '#f97316', '#ef4444', '#7c3aed']
const TOP_OPTIONS = [10, 20, 50]
const tab = ref('summary')
const loading = ref(false)
const dateFrom = ref('')
const dateTo = ref('')
const side = ref('sales')
const dimension = ref('product')
const order = ref('desc')
const top = ref(10)
const warehouseId = ref('')
const warehouseName = ref('')
const keyword = ref('')
const sumSummary = ref({ list: [], summary: { qty: 0, amount: 0 } })
const rank = ref({ list: [], order: 'desc' })
const move = ref({ list: [], total: 0, summary: {}, page: 1 })
const profit = ref({ list: [], summary: {} })
const aging = ref({ list: [], summary: {}, buckets: [] })
const cash = ref({ list: [], totalIn: 0, totalOut: 0, net: 0 })
const warehouses = ref([])
const picker = ref({ show: false, key: '', title: '', options: [], current: '' })
const dimensionLabel = computed(() => {
return (REPORT_DIMENSIONS.find((d) => d.value === dimension.value) || {}).label || '按商品'
})
const dimensionOptions = computed(() => {
const allow = side.value === 'purchase' ? ['product', 'supplier', 'month'] : ['product', 'customer', 'month']
return REPORT_DIMENSIONS.filter((d) => allow.includes(d.value))
})
onLoad((query = {}) => {
if (query.tab && TABS.some((t) => t.key === query.tab)) tab.value = query.tab
if (query.dimension) dimension.value = String(query.dimension)
useQuick()
run()
})
onReachBottom(() => {
if (tab.value === 'move' && !loading.value) loadMoreMove()
})
function switchTab(key) {
if (tab.value === key) return
tab.value = key
if (key !== 'move') move.value.page = 1
run()
}
function onDate(key, e) {
const val = e.detail.value
if (key === 'from') dateFrom.value = val
else dateTo.value = val
if (dateFrom.value && dateTo.value && dateFrom.value > dateTo.value) dateFrom.value = dateTo.value
run()
}
/** 报表默认看最近一个周转周期,空区间会把「没数据」误读成「没配好」 */
function useQuick() {
const to = new Date()
const from = new Date(to.getTime() - 29 * 86400000)
const p = (n) => String(n).padStart(2, '0')
const fmt = (d) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
dateFrom.value = fmt(from)
dateTo.value = fmt(to)
}
function onSide(value) {
if (side.value === value) return
side.value = value
if (!dimensionOptions.value.some((d) => d.value === dimension.value)) dimension.value = 'product'
run()
}
function onOrder(value) {
if (order.value === value) return
order.value = value
run()
}
async function run() {
loading.value = true
try {
if (tab.value === 'summary') await loadSummary()
else if (tab.value === 'rank') await loadRank()
else if (tab.value === 'move') await loadMove(true)
else if (tab.value === 'profit') await loadProfit()
else if (tab.value === 'aging') await loadAging()
else await loadCash()
} catch (e) {
// 单个报表失败只清当前视图,不影响切到其他 tab 再看
} finally {
loading.value = false
}
}
async function loadSummary() {
const args = { dimension: dimension.value, dateFrom: dateFrom.value, dateTo: dateTo.value }
sumSummary.value = side.value === 'purchase' ? await fetchPurchaseSummary(args) : await fetchSalesSummary(args)
}
async function loadRank() {
rank.value = await fetchSalesRank({
order: order.value,
top: top.value,
dateFrom: dateFrom.value,
dateTo: dateTo.value
})
}
async function loadMove(reset) {
if (reset) move.value = { list: [], total: 0, summary: {}, page: 1 }
const data = await fetchStockMove({
page: move.value.page,
pageSize: 20,
warehouseId: warehouseId.value,
keyword: keyword.value.trim(),
dateFrom: dateFrom.value,
dateTo: dateTo.value
})
const rows = data.list || []
move.value = {
list: reset ? rows : [...move.value.list, ...rows],
total: data.total || 0,
summary: data.summary || {},
page: move.value.page
}
}
function loadMoreMove() {
if (move.value.list.length >= move.value.total) return
move.value.page += 1
loadMove(false)
}
async function loadProfit() {
profit.value = await fetchProfit({ dateFrom: dateFrom.value, dateTo: dateTo.value })
}
async function loadAging() {
aging.value = await fetchAging()
}
async function loadCash() {
cash.value = await fetchCashFlow({ dateFrom: dateFrom.value, dateTo: dateTo.value })
}
function barWidth(row, field) {
const max = profit.value.list.reduce((m, r) => Math.max(m, Number(r[field] || 0)), 0)
if (!max) return '0%'
return `${Math.max(2, Math.round((Number(row[field] || 0) / max) * 100))}%`
}
function agingTotalOf(key) {
return aging.value.list
.filter((r) => r.bill_type === (key === 'receivable' ? 1 : 2))
.reduce((sum, r) => sum + Number(r.total || 0), 0)
}
function bucketAmount(key, label) {
return Number(((aging.value.summary || {})[key] || {})[label] || 0)
}
function segWidth(key, label) {
const total = agingTotalOf(key)
if (!total) return '0%'
return `${Math.max(0, Math.round((bucketAmount(key, label) / total) * 100))}%`
}
function rowsOf(billType) {
return aging.value.list.filter((r) => Number(r.bill_type) === billType)
}
function bucketText(row) {
return (aging.value.buckets || [])
.map((b) => (Number((row.buckets || {})[b.label] || 0) > 0 ? `${b.label} ¥${moneyText(row.buckets[b.label])}` : ''))
.filter(Boolean)
.join(' · ') || '暂无分桶金额'
}
function openDimension() {
picker.value = {
show: true,
key: 'dimension',
title: '统计维度',
current: dimension.value,
options: dimensionOptions.value.map((d) => ({ value: d.value, label: d.label }))
}
}
function openTop() {
picker.value = {
show: true,
key: 'top',
title: '榜单条数',
current: top.value,
options: TOP_OPTIONS.map((n) => ({ value: n, label: `前 ${n} 名` }))
}
}
async function openWarehouse() {
if (!warehouses.value.length) {
warehouses.value = await fetchWarehouses({ onlyEnabled: true }).catch(() => [])
}
picker.value = {
show: true,
key: 'warehouse',
title: '选择仓库',
current: warehouseId.value,
options: [
{ value: '', label: '全部仓库' },
...warehouses.value.map((w) => ({ value: w.id, label: w.warehouse_name, sub: w.warehouse_code || '' }))
]
}
}
function onPick(opt) {
picker.value.show = false
if (picker.value.key === 'dimension') {
dimension.value = String(opt.value)
run()
} else if (picker.value.key === 'top') {
top.value = Number(opt.value)
run()
} else {
warehouseId.value = String(opt.value ?? '')
warehouseName.value = warehouseId.value ? opt.label : ''
run()
}
}
function openProduct(row) {
if (!row.product_id) return
uni.navigateTo({ url: `/pages/erp/basic/product-detail?id=${row.product_id}` })
}
function openLedger(row) {
uni.navigateTo({
url: `/pages/erp/stock/ledger?product_id=${row.product_id}&warehouse_id=${row.warehouse_id || ''}`
})
}
function openBills(sideRow, row) {
uni.navigateTo({
url: `/pages/erp/finance/bill?bill_type=${sideRow.value}&partner_id=${row.partner_id}&partner_name=${encodeURIComponent(
row.partner_name || ''
)}&settle_state=open`
})
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: 40rpx;
}
.tab-bar {
position: sticky;
top: 0;
z-index: 12;
background: $color-card;
box-shadow: $shadow-header;
}
.tab-scroll {
white-space: nowrap;
box-sizing: border-box;
}
.tab-list {
display: inline-flex;
padding: 0 12rpx;
}
.tab {
padding: 26rpx 26rpx 22rpx;
position: relative;
text {
font-size: 26rpx;
color: $color-text-muted;
}
&.active {
text {
color: $color-primary;
font-weight: 600;
}
&::after {
content: '';
position: absolute;
bottom: 6rpx;
left: 50%;
width: 40rpx;
height: 6rpx;
margin-left: -20rpx;
border-radius: 3rpx;
background: $color-primary;
}
}
}
.filter-bar {
padding: 20rpx $page-padding-x 6rpx;
}
.date-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.date-cell {
display: flex;
align-items: center;
gap: 8rpx;
height: 58rpx;
padding: 0 20rpx;
border-radius: $radius-md;
background: $color-card;
font-size: 23rpx;
color: $color-text-secondary;
box-shadow: $shadow-card;
}
.date-sep {
font-size: 22rpx;
color: $color-text-muted;
}
.quick {
margin-left: auto;
padding: 8rpx 20rpx;
border-radius: $radius-full;
background: $color-primary-bg;
font-size: 22rpx;
color: $color-primary-dark;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-top: 16rpx;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 22rpx;
border-radius: $radius-full;
background: $color-card;
font-size: 23rpx;
color: $color-text-secondary;
box-shadow: $shadow-card;
&.ghost {
background: #f1f5f9;
box-shadow: none;
}
&.active {
background: #2563eb;
color: #ffffff;
}
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 72rpx;
margin-top: 16rpx;
padding: 0 24rpx;
background: $color-card;
border-radius: $radius-full;
box-shadow: $shadow-card;
.search-input {
flex: 1;
font-size: 26rpx;
color: $color-text;
}
}
.kpi-row {
display: flex;
gap: 16rpx;
margin: 16rpx $page-padding-x 0;
}
.kpi {
flex: 1;
min-width: 0;
@include card;
padding: 20rpx 22rpx;
.kpi-label {
display: block;
font-size: 21rpx;
color: $color-text-muted;
}
.kpi-value {
display: block;
margin-top: 8rpx;
font-size: 28rpx;
font-weight: 600;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.strong {
color: $color-primary-dark;
}
&.in {
color: #16a34a;
}
}
}
.row-card {
display: flex;
align-items: center;
gap: 16rpx;
@include card;
margin: 16rpx $page-padding-x 0;
padding: 22rpx 28rpx;
&:active {
opacity: 0.85;
}
}
.row-main {
flex: 1;
min-width: 0;
}
.row-title {
display: block;
font-size: 28rpx;
font-weight: 500;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-amount {
flex-shrink: 0;
font-size: 29rpx;
font-weight: 600;
color: $color-text;
&.negative {
color: $color-danger;
}
&.positive {
color: #16a34a;
}
}
.rk-no {
width: 44rpx;
height: 44rpx;
flex-shrink: 0;
border-radius: $radius-sm;
background: #f1f5f9;
color: $color-text-secondary;
font-size: 23rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
&.top {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: #ffffff;
}
}
.mv-card {
@include card;
margin: 16rpx $page-padding-x 0;
padding: 22rpx 28rpx;
}
.mv-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.mv-wh {
flex-shrink: 0;
font-size: 22rpx;
color: $color-text-muted;
}
.mv-grid {
display: flex;
gap: 10rpx;
margin-top: 16rpx;
padding-top: 14rpx;
border-top: 1rpx solid $color-divider;
}
.mv-cell {
flex: 1;
min-width: 0;
.mv-label {
display: block;
font-size: 21rpx;
color: $color-text-muted;
&.in {
color: #16a34a;
}
&.out {
color: $color-danger;
}
}
.mv-value {
display: block;
margin-top: 4rpx;
font-size: 25rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.strong {
font-weight: 600;
color: $color-primary-dark;
}
}
.mv-money {
display: block;
margin-top: 2rpx;
font-size: 20rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.hero-card {
margin: 16rpx $page-padding-x 0;
padding: 30rpx 32rpx;
border-radius: $radius-lg;
background: $hero-gradient;
color: #ffffff;
box-shadow: $shadow-card;
}
.hero-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16rpx;
.hero-label {
font-size: 24rpx;
opacity: 0.9;
}
.hero-value {
font-size: 46rpx;
font-weight: 600;
}
}
.hero-grid {
display: flex;
margin-top: 22rpx;
padding-top: 18rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.25);
}
.hero-cell {
flex: 1;
min-width: 0;
.hero-cell-label {
display: block;
font-size: 21rpx;
opacity: 0.85;
}
.hero-cell-value {
display: block;
margin-top: 6rpx;
font-size: 26rpx;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.pf-card {
@include card;
margin: 16rpx $page-padding-x 0;
padding: 22rpx 28rpx;
}
.pf-rate {
flex-shrink: 0;
font-size: 27rpx;
font-weight: 600;
color: #16a34a;
&.negative {
color: $color-danger;
}
}
.pf-bar {
height: 8rpx;
margin-top: 14rpx;
border-radius: 4rpx;
background: #f1f5f9;
overflow: hidden;
}
.pf-bar-fill {
height: 100%;
border-radius: 4rpx;
background: linear-gradient(90deg, #60a5fa, #2563eb);
}
.ag-block {
@include card;
margin: 16rpx $page-padding-x 0;
padding: 24rpx 28rpx;
}
.ag-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.ag-title {
@include section-title;
}
.ag-total {
font-size: 26rpx;
font-weight: 600;
color: $color-primary-dark;
}
.ag-bar {
display: flex;
height: 14rpx;
border-radius: 7rpx;
background: #f1f5f9;
overflow: hidden;
}
.ag-seg {
height: 100%;
}
.ag-legend {
display: flex;
flex-wrap: wrap;
gap: 12rpx 22rpx;
margin-top: 16rpx;
}
.ag-legend-item {
display: flex;
align-items: center;
gap: 8rpx;
.dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
}
.lg-label {
font-size: 21rpx;
color: $color-text-muted;
}
.lg-amount {
font-size: 21rpx;
color: $color-text-secondary;
}
}
.ag-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 18rpx 0;
border-bottom: 1rpx solid $color-divider;
&:last-child {
border-bottom: none;
padding-bottom: 4rpx;
}
}
.ag-empty {
padding: 20rpx 0 6rpx;
font-size: 23rpx;
color: $color-text-muted;
}
.negative {
color: $color-danger;
}
.more {
display: flex;
justify-content: center;
margin: 20rpx $page-padding-x 0;
padding: 18rpx 0;
border-radius: $radius-md;
background: $color-card;
font-size: 24rpx;
color: $color-primary;
}
.state-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 40rpx;
}
.state-text {
font-size: 28rpx;
color: $color-text-muted;
}
</style>