1113 lines
26 KiB
Vue
1113 lines
26 KiB
Vue
<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>
|