更新
This commit is contained in:
1 parent
5718a00e31
commit
1850ac640e
226 files changed
+37772
-12071
No files matched your search
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="loading && !product" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<block v-else-if="product">
|
||||
<view class="hero">
|
||||
<view class="hero-top">
|
||||
<text class="hero-name">{{ product.product_name }}</text>
|
||||
<u-tag v-if="!isOnSale" text="停用/草稿" type="info" size="mini" />
|
||||
</view>
|
||||
<text class="hero-code">{{ product.product_code || '无编码' }}</text>
|
||||
<view class="hero-grid">
|
||||
<view class="hero-cell">
|
||||
<text class="cell-label">总库存</text>
|
||||
<text class="cell-value">{{ qtyText(product.total_stock) }} {{ product.base_unit || '' }}</text>
|
||||
</view>
|
||||
<view class="hero-cell">
|
||||
<text class="cell-label">库存状态</text>
|
||||
<text class="cell-value">{{ safetyStateLabel(product.safety_state) }}</text>
|
||||
</view>
|
||||
<view class="hero-cell">
|
||||
<text class="cell-label">售价</text>
|
||||
<text class="cell-value">¥{{ moneyText(product.sale_price) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">商品资料</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">分类</text>
|
||||
<text class="line-value">{{ product.category_name || '未分类' }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">品牌 / 规格</text>
|
||||
<text class="line-value">{{ join([product.brand, product.spec]) }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">型号</text>
|
||||
<text class="line-value">{{ dash(product.model) }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">单位</text>
|
||||
<text class="line-value">
|
||||
{{ dash(product.base_unit) }}
|
||||
<text v-if="product.aux_unit"> / 辅 {{ product.aux_unit }} × {{ qtyText(product.aux_ratio || 1) }}</text>
|
||||
</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">条码</text>
|
||||
<text class="line-value">{{ dash(product.barcode) }}</text>
|
||||
</view>
|
||||
<view v-if="product.remark" class="remark">
|
||||
<text>{{ product.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">价格与管控</text>
|
||||
<text class="block-tip">手机端只读</text>
|
||||
</view>
|
||||
<view class="grid3">
|
||||
<view v-for="p in priceCells" :key="p.label" class="g-cell">
|
||||
<text class="cell-label">{{ p.label }}</text>
|
||||
<text class="cell-value">{{ p.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">分仓库存({{ stocks.length }} 行)</text>
|
||||
<text class="block-tip">货值 ¥{{ moneyText(stockAmount) }}</text>
|
||||
</view>
|
||||
<view v-if="!stocks.length" class="empty-line">
|
||||
<text>暂无在库库存</text>
|
||||
</view>
|
||||
<view v-for="(s, i) in stocks" :key="i" class="line">
|
||||
<view class="line-label grow">
|
||||
<text>{{ s.warehouse_name || `仓库#${s.warehouse_id}` }}</text>
|
||||
<text v-if="s.batch_no" class="sub">批次 {{ s.batch_no }}</text>
|
||||
</view>
|
||||
<text class="line-value">{{ qtyText(s.quantity) }} · ¥{{ moneyText(s.cost_price) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-space"></view>
|
||||
|
||||
<view class="actions">
|
||||
<view class="act" @tap="go('/pages/erp/stock/ledger')">
|
||||
<text>出入库流水</text>
|
||||
</view>
|
||||
<view class="act" @tap="go('/pages/erp/stock/batch')">
|
||||
<text>批次台账</text>
|
||||
</view>
|
||||
<view class="act primary" @tap="go('/pages/erp/document/index')">
|
||||
<text>相关单据</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 商品详情(只读):资料 / 价格 / 库存管控 / 分仓结存。
|
||||
* 建档与改价一律回 Web 端,这里把「这个货现在有多少、成本多少、卖多少」讲清楚,
|
||||
* 现场接单或盘库时一眼能判断能不能发。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import {
|
||||
getProduct,
|
||||
moneyText,
|
||||
qtyText,
|
||||
safetyStateLabel
|
||||
} from '@/api/erp.js'
|
||||
|
||||
const product = ref(null)
|
||||
const loading = ref(false)
|
||||
const productId = ref(0)
|
||||
|
||||
const stocks = computed(() => product.value?.stocks || [])
|
||||
const stockAmount = computed(() => stocks.value.reduce((sum, s) => sum + Number(s.amount || 0), 0))
|
||||
const isOnSale = computed(() => Number(product.value?.status) === 1 && Number(product.value?.is_draft) !== 1)
|
||||
|
||||
const priceCells = computed(() => {
|
||||
const p = product.value || {}
|
||||
return [
|
||||
{ label: '采购价', value: `¥${moneyText(p.purchase_price)}` },
|
||||
{ label: '销售价', value: `¥${moneyText(p.sale_price)}` },
|
||||
{ label: '批发价', value: `¥${moneyText(p.wholesale_price)}` },
|
||||
{ label: '会员价', value: `¥${moneyText(p.member_price)}` },
|
||||
{ label: '最低售价', value: `¥${moneyText(p.min_price)}` },
|
||||
{ label: '税率', value: `${Number(p.tax_rate || 0)}%` },
|
||||
{ label: '安全库存', value: qtyText(p.safety_qty) },
|
||||
{ label: '最高库存', value: qtyText(p.max_qty) },
|
||||
{ label: '保质期', value: Number(p.shelf_life_days) > 0 ? `${p.shelf_life_days} 天` : '不管理' },
|
||||
{ label: '批次管理', value: Number(p.enable_batch) === 1 ? '启用' : '关闭' }
|
||||
]
|
||||
})
|
||||
|
||||
onLoad((query = {}) => {
|
||||
productId.value = Number(query.id) || 0
|
||||
load()
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!productId.value) {
|
||||
uni.showToast({ title: '缺少商品 ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
product.value = await getProduct(productId.value)
|
||||
if (product.value?.product_name) uni.setNavigationBarTitle({ title: product.value.product_name })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '商品加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function go(base) {
|
||||
uni.navigateTo({ url: `${base}?product_id=${productId.value}` })
|
||||
}
|
||||
|
||||
function dash(v) {
|
||||
const s = String(v ?? '').trim()
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
function join(arr) {
|
||||
const s = (arr || []).filter(Boolean).join(' / ')
|
||||
return s || '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin: 0 $page-padding-x 24rpx;
|
||||
padding: 32rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: $hero-gradient;
|
||||
color: #ffffff;
|
||||
box-shadow: $shadow-float;
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
flex: 1;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero-code {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: flex;
|
||||
margin-top: 28rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.hero-cell {
|
||||
flex: 1;
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 24rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
@include block-head;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.block-tip {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 14rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
font-size: 26rpx;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.line-label {
|
||||
color: $color-text-secondary;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.line-value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: $color-text;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.grid3 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.g-cell {
|
||||
width: 33.33%;
|
||||
padding: 12rpx 0;
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
|
||||
.remark {
|
||||
margin-top: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.empty-line {
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.footer-space {
|
||||
height: calc(150rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx $page-padding-x calc(18rpx + env(safe-area-inset-bottom));
|
||||
background: $color-card;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.06);
|
||||
@include dialog-responsive;
|
||||
}
|
||||
|
||||
.act {
|
||||
flex: 1;
|
||||
height: 76rpx;
|
||||
line-height: 76rpx;
|
||||
text-align: center;
|
||||
border-radius: $radius-full;
|
||||
background: $color-bg-page;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&.primary {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 140rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="filter-bar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
placeholder="搜索商品名称 / 编码 / 条码"
|
||||
confirm-type="search"
|
||||
@confirm="refresh"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
<view class="chip-row">
|
||||
<view class="chip" :class="{ active: filterKey === 'category' }" @tap="openPicker('category')">
|
||||
<text>{{ categoryName || '全部分类' }}</text>
|
||||
<FaIcon name="chevron-right" color="#94a3b8" :size="12" />
|
||||
</view>
|
||||
<view class="chip" :class="{ active: onlyEnabled }" @tap="toggleEnabled">
|
||||
<text>仅在售</text>
|
||||
</view>
|
||||
<view class="chip count">
|
||||
<text>共 {{ total }} 个</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="search" text="没有匹配的商品" />
|
||||
<text class="state-tip">商品建档请在 Web 后台「进销存 - 商品档案」完成</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="row in list" :key="row.id" class="p-card" @tap="openDetail(row)">
|
||||
<view class="p-top">
|
||||
<text class="p-name">{{ row.product_name }}</text>
|
||||
<u-tag
|
||||
v-if="row.safety_state !== 'normal'"
|
||||
:text="safetyStateLabel(row.safety_state)"
|
||||
:type="row.safety_state === 'low' ? 'warning' : 'primary'"
|
||||
size="mini"
|
||||
/>
|
||||
<u-tag v-else-if="!isOnSale(row)" text="停用" type="info" size="mini" />
|
||||
</view>
|
||||
<text class="p-sub">{{ [row.product_code, row.spec, row.category_name].filter(Boolean).join(' · ') || '—' }}</text>
|
||||
<view class="p-grid">
|
||||
<view class="p-cell">
|
||||
<text class="cell-label">库存</text>
|
||||
<text class="cell-value">{{ qtyText(row.total_stock) }} {{ row.base_unit || '' }}</text>
|
||||
</view>
|
||||
<view class="p-cell">
|
||||
<text class="cell-label">售价</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.sale_price) }}</text>
|
||||
</view>
|
||||
<view class="p-cell">
|
||||
<text class="cell-label">采购价</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.purchase_price) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<ErpPicker
|
||||
:show="picker.show"
|
||||
title="选择分类"
|
||||
placeholder="搜索分类"
|
||||
:options="categoryOptions"
|
||||
:model-value="categoryId"
|
||||
@update:show="picker.show = false"
|
||||
@select="onCategory"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 商品档案(移动端只读)。
|
||||
* 建档、改价、启停都在 Web 后台做,手机端只负责「查得到、看得清、点得进」,
|
||||
* 库存与价格取的正是 Web 端同一批接口字段,两端不会出现两套数。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import ErpPicker from '@/components/erp/ErpPicker.vue'
|
||||
import {
|
||||
fetchCategories,
|
||||
fetchProducts,
|
||||
moneyText,
|
||||
qtyText,
|
||||
safetyStateLabel
|
||||
} from '@/api/erp.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
const categoryId = ref('')
|
||||
const onlyEnabled = ref(false)
|
||||
const categories = ref([])
|
||||
|
||||
const picker = ref({ show: false })
|
||||
const filterKey = ref('')
|
||||
|
||||
const categoryName = computed(() => {
|
||||
const hit = categories.value.find((c) => String(c.id) === String(categoryId.value))
|
||||
return hit ? hit.name : ''
|
||||
})
|
||||
|
||||
const categoryOptions = computed(() =>
|
||||
categories.value.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
sub: c.level_text || '',
|
||||
extra: `${c.product_count || 0} 个`
|
||||
}))
|
||||
)
|
||||
|
||||
onShow(() => {
|
||||
refresh()
|
||||
loadCategories()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
loadMore()
|
||||
})
|
||||
|
||||
async function loadCategories() {
|
||||
if (categories.value.length) return
|
||||
try {
|
||||
const { list: rows } = await fetchCategories()
|
||||
const nameOf = {}
|
||||
rows.forEach((c) => {
|
||||
nameOf[c.id] = c.category_name
|
||||
})
|
||||
categories.value = rows.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.category_name,
|
||||
level_text: c.parent_id && nameOf[c.parent_id] ? `上级:${nameOf[c.parent_id]}` : '',
|
||||
product_count: c.product_count || 0
|
||||
}))
|
||||
} catch (e) {
|
||||
// 分类拉不到不影响商品列表,只是筛选栏少一项
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchProducts({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: keyword.value.trim(),
|
||||
categoryId: categoryId.value,
|
||||
onlyEnabled: onlyEnabled.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
onlyEnabled.value = !onlyEnabled.value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openPicker(key) {
|
||||
filterKey.value = key
|
||||
picker.value.show = true
|
||||
}
|
||||
|
||||
function onCategory(opt) {
|
||||
categoryId.value = String(opt.value) === String(categoryId.value) ? '' : opt.value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function isOnSale(row) {
|
||||
return Number(row.status) === 1 && Number(row.is_draft) !== 1
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
uni.navigateTo({ url: `/pages/erp/basic/product-detail?id=${row.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: $color-bg-page;
|
||||
padding: 20rpx $page-padding-x 8rpx;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&.count {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.p-card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.p-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.p-name {
|
||||
flex: 1;
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.p-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.p-grid {
|
||||
display: flex;
|
||||
margin-top: 18rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.p-cell {
|
||||
flex: 1;
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 27rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text,
|
||||
.state-tip {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="filter-bar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
placeholder="搜索仓库名称"
|
||||
confirm-type="search"
|
||||
@confirm="load"
|
||||
/>
|
||||
</view>
|
||||
<view class="sum-row">
|
||||
<text class="sum-item">仓库 {{ list.length }} 个</text>
|
||||
<text class="sum-item">在库货值 ¥{{ moneyText(totalValue) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="list" text="没有仓库" />
|
||||
<text class="state-tip">先在 Web 后台「进销存 - 仓库档案」建仓库</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="row in list" :key="row.id" class="w-card" @tap="openStock(row)">
|
||||
<view class="w-top">
|
||||
<view class="w-icon">
|
||||
<FaIcon name="warehouse" color="#2563eb" :size="18" />
|
||||
</view>
|
||||
<view class="w-main">
|
||||
<view class="w-name-row">
|
||||
<text class="w-name">{{ row.warehouse_name }}</text>
|
||||
<u-tag v-if="Number(row.is_default) === 1" text="默认" type="primary" size="mini" />
|
||||
<u-tag v-if="Number(row.status) !== 1" text="停用" type="info" size="mini" />
|
||||
</view>
|
||||
<text class="w-sub">{{ [row.warehouse_code, row.manager, row.phone].filter(Boolean).join(' · ') || '—' }}</text>
|
||||
</view>
|
||||
<FaIcon name="chevron-right" color="#cbd5e1" :size="15" />
|
||||
</view>
|
||||
<view class="w-grid">
|
||||
<view class="w-cell">
|
||||
<text class="cell-label">在库品种</text>
|
||||
<text class="cell-value">{{ row.stock_kinds || 0 }}</text>
|
||||
</view>
|
||||
<view class="w-cell">
|
||||
<text class="cell-label">货值</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.stock_value) }}</text>
|
||||
</view>
|
||||
<view class="w-cell">
|
||||
<text class="cell-label">地址</text>
|
||||
<text class="cell-value ellipsis">{{ row.address || '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 仓库档案(移动端只读):一屏看清每个仓压了多少货,点进去直接查该仓库存。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import { fetchWarehouses, moneyText } from '@/api/erp.js'
|
||||
|
||||
const list = ref([])
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const totalValue = computed(() => list.value.reduce((sum, w) => sum + Number(w.stock_value || 0), 0))
|
||||
|
||||
onShow(() => {
|
||||
load()
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await fetchWarehouses({ keyword: keyword.value.trim() })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '仓库加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openStock(row) {
|
||||
uni.navigateTo({ url: `/pages/erp/stock/index?warehouse_id=${row.id}&warehouse_name=${encodeURIComponent(row.warehouse_name || '')}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: $color-bg-page;
|
||||
padding: 20rpx $page-padding-x 12rpx;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
|
||||
.sum-row {
|
||||
display: flex;
|
||||
gap: 28rpx;
|
||||
padding: 16rpx 8rpx 4rpx;
|
||||
}
|
||||
|
||||
.sum-item {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.w-card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.w-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.w-icon {
|
||||
@include icon-box(64rpx);
|
||||
}
|
||||
|
||||
.w-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.w-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.w-name {
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.w-sub {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.w-grid {
|
||||
display: flex;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.w-cell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:nth-child(3) {
|
||||
flex: 1.4;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text,
|
||||
.state-tip {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="loading && !dash" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<block v-else-if="dash">
|
||||
<!-- 本月经营 -->
|
||||
<view class="hero-card">
|
||||
<view class="hero-main">
|
||||
<text class="hero-label">本月销售金额</text>
|
||||
<text class="hero-amount">¥{{ moneyText(month.sales_amount) }}</text>
|
||||
<text class="hero-sub">
|
||||
销售 {{ month.sales_count || 0 }} 单 · 采购 {{ month.purchase_count || 0 }} 单 · 退货 ¥{{ moneyText(month.return_amount) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="hero-side">
|
||||
<view class="hero-line">
|
||||
<text class="hero-line-label">本月毛利</text>
|
||||
<text class="hero-line-value">{{ moneyText(month.profit) }}</text>
|
||||
</view>
|
||||
<view class="hero-line">
|
||||
<text class="hero-line-label">毛利率</text>
|
||||
<text class="hero-line-value">{{ moneyText(month.profit_rate) }}%</text>
|
||||
</view>
|
||||
<view class="hero-line">
|
||||
<text class="hero-line-label">采购金额</text>
|
||||
<text class="hero-line-value">{{ moneyText(month.purchase_amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 指标格子 -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-cell" @tap="go('/pages/erp/document/index?doc_type=sales_out')">
|
||||
<text class="kpi-value">{{ moneyText(today.sales_amount) }}</text>
|
||||
<text class="kpi-label">今日销售({{ today.sales_count || 0 }} 单)</text>
|
||||
</view>
|
||||
<view class="kpi-cell" @tap="go('/pages/erp/stock/index')">
|
||||
<text class="kpi-value">{{ moneyText(stock.value) }}</text>
|
||||
<text class="kpi-label">库存货值({{ qtyText(stock.qty) }} 件)</text>
|
||||
</view>
|
||||
<view class="kpi-cell" @tap="go('/pages/erp/finance/bill?bill_type=1')">
|
||||
<text class="kpi-value danger">{{ moneyText(bill.receivable) }}</text>
|
||||
<text class="kpi-label">未收应收</text>
|
||||
</view>
|
||||
<view class="kpi-cell" @tap="go('/pages/erp/finance/bill?bill_type=2')">
|
||||
<text class="kpi-value">{{ moneyText(bill.payable) }}</text>
|
||||
<text class="kpi-label">未付应付</text>
|
||||
</view>
|
||||
<view class="kpi-cell" @tap="go('/pages/erp/document/index?status=2')">
|
||||
<text class="kpi-value primary">{{ todo.approval_pending || 0 }}</text>
|
||||
<text class="kpi-label">待我审批</text>
|
||||
</view>
|
||||
<view class="kpi-cell" @tap="go('/pages/erp/document/index?status=0')">
|
||||
<text class="kpi-value">{{ todo.draft_docs || 0 }}</text>
|
||||
<text class="kpi-label">草稿单据</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 库存预警 -->
|
||||
<view class="card warn-card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">库存预警</text>
|
||||
<view class="block-more" @tap="go('/pages/erp/stock/warning')">
|
||||
<text>查看全部</text>
|
||||
<FaIcon name="chevron-right" color="#94a3b8" :size="14" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="warn-row">
|
||||
<view class="warn-item" @tap="go('/pages/erp/stock/warning?tab=low')">
|
||||
<text class="warn-value warning">{{ warning.low || 0 }}</text>
|
||||
<text class="warn-label">低于安全库存</text>
|
||||
</view>
|
||||
<view class="warn-item" @tap="go('/pages/erp/stock/warning?tab=out')">
|
||||
<text class="warn-value danger">{{ warning.out || 0 }}</text>
|
||||
<text class="warn-label">缺货</text>
|
||||
</view>
|
||||
<view class="warn-item" @tap="go('/pages/erp/stock/warning?tab=over')">
|
||||
<text class="warn-value primary">{{ warning.over || 0 }}</text>
|
||||
<text class="warn-label">库存积压</text>
|
||||
</view>
|
||||
<view class="warn-item" @tap="go('/pages/erp/stock/batch?expire_state=expiring')">
|
||||
<text class="warn-value warning">{{ warning.expiring || 0 }}</text>
|
||||
<text class="warn-label">效期临近</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 近 6 个月购销趋势 -->
|
||||
<view class="card trend-card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">近 6 个月购销趋势</text>
|
||||
<view class="legend">
|
||||
<view class="legend-item">
|
||||
<view class="legend-dot buy"></view>
|
||||
<text>采购</text>
|
||||
</view>
|
||||
<view class="legend-item">
|
||||
<view class="legend-dot sale"></view>
|
||||
<text>销售</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-for="row in trend" :key="row.month" class="trend-row">
|
||||
<text class="trend-month">{{ row.month }}</text>
|
||||
<view class="trend-bars">
|
||||
<view class="bar-track">
|
||||
<view class="bar buy" :style="{ width: barWidth(row.buy) }"></view>
|
||||
</view>
|
||||
<view class="bar-track">
|
||||
<view class="bar sale" :style="{ width: barWidth(row.sale) }"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="trend-value">¥{{ moneyText(row.sale) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 常用入口 -->
|
||||
<view class="card entry-card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">常用功能</text>
|
||||
</view>
|
||||
<view class="entry-grid">
|
||||
<view v-for="item in entries" :key="item.route" class="entry-item" @tap="go(item.route)">
|
||||
<view class="entry-icon">
|
||||
<FaIcon :name="item.icon" color="#2563eb" :size="18" />
|
||||
</view>
|
||||
<text class="entry-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view v-else class="state-box">
|
||||
<u-empty mode="network" text="看板加载失败" />
|
||||
<view class="retry" @tap="load">重新加载</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import { fetchErpDashboard, moneyText, qtyText } from '@/api/erp.js'
|
||||
|
||||
const dash = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const month = computed(() => (dash.value || {}).month || {})
|
||||
const today = computed(() => (dash.value || {}).today || {})
|
||||
const stock = computed(() => (dash.value || {}).stock || {})
|
||||
const bill = computed(() => (dash.value || {}).partnerBill || {})
|
||||
const todo = computed(() => (dash.value || {}).todo || {})
|
||||
const warning = computed(() => (dash.value || {}).warning || {})
|
||||
const trend = computed(() => (dash.value || {}).trend || [])
|
||||
|
||||
const maxTrend = computed(() => {
|
||||
let max = 0
|
||||
trend.value.forEach(row => {
|
||||
max = Math.max(max, Number(row.buy || 0), Number(row.sale || 0))
|
||||
})
|
||||
return max || 1
|
||||
})
|
||||
|
||||
const entries = [
|
||||
{ label: '开单', icon: 'plus', route: '/pages/erp/document/edit' },
|
||||
{ label: '单据', icon: 'clipboard-list', route: '/pages/erp/document/index' },
|
||||
{ label: '商品', icon: 'boxes-stacked', route: '/pages/erp/product/index' },
|
||||
{ label: '库存', icon: 'warehouse', route: '/pages/erp/stock/index' },
|
||||
{ label: '客商', icon: 'users', route: '/pages/erp/partner/index' },
|
||||
{ label: '收付款', icon: 'wallet', route: '/pages/erp/finance/fund' },
|
||||
{ label: '报表', icon: 'chart-column', route: '/pages/erp/report/index' },
|
||||
{ label: '流水', icon: 'arrow-right-arrow-left', route: '/pages/erp/stock/ledger' }
|
||||
]
|
||||
|
||||
onShow(() => {
|
||||
load()
|
||||
})
|
||||
|
||||
function barWidth(value) {
|
||||
const pct = (Number(value || 0) / maxTrend.value) * 100
|
||||
return `${Math.max(pct, 2).toFixed(1)}%`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
dash.value = await fetchErpDashboard()
|
||||
} catch (e) {
|
||||
dash.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function go(route) {
|
||||
uni.navigateTo({ url: route })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding: 24rpx $page-padding-x 60rpx;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 160rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.retry {
|
||||
margin-top: 24rpx;
|
||||
padding: 12rpx 36rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 26rpx;
|
||||
color: $color-primary;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
@include block-head;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.block-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: $hero-gradient;
|
||||
color: #ffffff;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-amount {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 48rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hero-line {
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.hero-line-label {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hero-line-value {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.kpi-cell {
|
||||
width: calc((100% - 40rpx) / 3);
|
||||
box-sizing: border-box;
|
||||
@include card;
|
||||
padding: 24rpx 16rpx;
|
||||
text-align: center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
|
||||
&.danger {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.warn-row {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.warn-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20rpx 0;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-page;
|
||||
}
|
||||
|
||||
.warn-value {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&.warning {
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.warn-label {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 4rpx;
|
||||
|
||||
&.buy {
|
||||
background: #93c5fd;
|
||||
}
|
||||
|
||||
&.sale {
|
||||
background: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 14rpx 0;
|
||||
}
|
||||
|
||||
.trend-month {
|
||||
width: 110rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
height: 14rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-bg-page;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
border-radius: $radius-full;
|
||||
|
||||
&.buy {
|
||||
background: #93c5fd;
|
||||
}
|
||||
|
||||
&.sale {
|
||||
background: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
width: 150rpx;
|
||||
text-align: right;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
width: 25%;
|
||||
padding: 18rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
margin: 0 auto 10rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-primary-bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="loading && !doc" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<block v-else-if="doc">
|
||||
<!-- 概要 -->
|
||||
<view class="head-card">
|
||||
<view class="head-top">
|
||||
<text class="head-type">{{ doc.doc_type_label || doc.doc_type }}</text>
|
||||
<u-tag :text="doc.status_label || docStatusLabel(doc.status)" :type="docStatusType(doc.status)" size="mini" />
|
||||
</view>
|
||||
<text class="head-amount">¥{{ moneyText(doc.actual_amount) }}</text>
|
||||
<text class="head-no">{{ doc.doc_no }}</text>
|
||||
<view class="head-grid">
|
||||
<view class="head-cell">
|
||||
<text class="cell-label">业务日期</text>
|
||||
<text class="cell-value">{{ doc.biz_date }}</text>
|
||||
</view>
|
||||
<view class="head-cell">
|
||||
<text class="cell-label">数量合计</text>
|
||||
<text class="cell-value">{{ qtyText(doc.total_qty) }}</text>
|
||||
</view>
|
||||
<view class="head-cell">
|
||||
<text class="cell-label">优惠金额</text>
|
||||
<text class="cell-value">¥{{ moneyText(doc.discount_amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 往来与仓储 -->
|
||||
<view class="card">
|
||||
<view class="line">
|
||||
<text class="line-label">{{ partnerLabel }}</text>
|
||||
<text class="line-value">{{ doc.partner_name || '—' }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">仓库</text>
|
||||
<text class="line-value">{{ doc.warehouse_name || '—' }}</text>
|
||||
</view>
|
||||
<view v-if="doc.to_warehouse_name" class="line">
|
||||
<text class="line-label">调入仓库</text>
|
||||
<text class="line-value">{{ doc.to_warehouse_name }}</text>
|
||||
</view>
|
||||
<view v-if="doc.source_doc_no" class="line" @tap="goSource">
|
||||
<text class="line-label">来源单据</text>
|
||||
<view class="line-value link">
|
||||
<text>{{ doc.source_doc_no }}</text>
|
||||
<FaIcon name="chevron-right" color="#2563eb" :size="14" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">制单</text>
|
||||
<text class="line-value">{{ doc.create_user_name || '—' }} · {{ doc.create_time_text }}</text>
|
||||
</view>
|
||||
<view v-if="doc.auditor_name" class="line">
|
||||
<text class="line-label">审核</text>
|
||||
<text class="line-value">{{ doc.auditor_name }} · {{ doc.audit_time_text }}</text>
|
||||
</view>
|
||||
<view v-if="doc.remark" class="remark">
|
||||
<text>备注:{{ doc.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 明细 -->
|
||||
<view class="card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">商品明细({{ items.length }} 行)</text>
|
||||
<text class="block-tip">点行可看实时库存</text>
|
||||
</view>
|
||||
<view v-for="(item, index) in items" :key="item.id || index" class="item-row">
|
||||
<view class="item-head">
|
||||
<text class="item-name">{{ item.product_name }}</text>
|
||||
<text class="item-amount">¥{{ moneyText(item.amount) }}</text>
|
||||
</view>
|
||||
<view class="item-meta">
|
||||
<text v-if="item.spec">{{ item.spec }}</text>
|
||||
<text>{{ qtyText(item.quantity) }} {{ item.unit }} × ¥{{ moneyText(item.unit_price) }}</text>
|
||||
<text v-if="item.batch_no">批次 {{ item.batch_no }}</text>
|
||||
<text>结存 {{ qtyText(item.stock_qty) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!items.length" class="item-empty">
|
||||
<text>暂无明细</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 审批实例 -->
|
||||
<view v-if="instance" class="card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">审批进度</text>
|
||||
<u-tag :text="instance.status_label || ''" type="primary" size="mini" />
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">流程</text>
|
||||
<text class="line-value">{{ instance.flow_name || '—' }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">当前节点</text>
|
||||
<text class="line-value">{{ instance.current_node || '已结束' }}</text>
|
||||
</view>
|
||||
<view v-for="task in instance.tasks || []" :key="task.task_id" class="task-row">
|
||||
<view class="task-dot" :class="taskClass(task)"></view>
|
||||
<view class="task-body">
|
||||
<view class="task-line1">
|
||||
<text class="task-node">{{ task.node_name }}</text>
|
||||
<text class="task-status">{{ task.status_label }}</text>
|
||||
</view>
|
||||
<text class="task-sub">
|
||||
{{ task.approver_name }}{{ task.finish_time ? ' · ' + dateTimeText(task.finish_time) : '' }}
|
||||
</text>
|
||||
<text v-if="task.comment" class="task-comment">{{ task.comment }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<view v-if="doc" class="actions">
|
||||
<block v-if="isDraft">
|
||||
<view class="act ghost" @tap="goEdit">编辑</view>
|
||||
<view class="act ghost" @tap="onDelete">删除</view>
|
||||
<view class="act" :class="{ disabled: busy }" @tap="onSubmit">提交审批</view>
|
||||
<view class="act primary" :class="{ disabled: busy }" @tap="onApprove">审核生效</view>
|
||||
</block>
|
||||
<block v-else-if="doc.status === DOC_STATUS.APPROVING">
|
||||
<view class="act ghost" @tap="onCancel">撤回作废</view>
|
||||
</block>
|
||||
<block v-else-if="doc.status === DOC_STATUS.APPROVED">
|
||||
<view v-if="nextTypes.length" class="act ghost" @tap="onCancel">作废</view>
|
||||
<view
|
||||
v-for="next in nextTypes"
|
||||
:key="next.value"
|
||||
class="act primary"
|
||||
@tap="goDownstream(next.value)"
|
||||
>
|
||||
生成{{ next.label }}
|
||||
</view>
|
||||
</block>
|
||||
<block v-else>
|
||||
<view class="act ghost" @tap="goEdit">再次编辑</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import {
|
||||
DOC_STATUS,
|
||||
cancelDoc,
|
||||
dateTimeText,
|
||||
deleteDoc,
|
||||
docStatusLabel,
|
||||
docStatusType,
|
||||
approveDoc,
|
||||
fetchDocTypes,
|
||||
getDoc,
|
||||
moneyText,
|
||||
qtyText,
|
||||
submitDoc
|
||||
} from '@/api/erp.js'
|
||||
|
||||
/** 已生效单据可继续生成的下游单据(与后端来源单类型白名单一致) */
|
||||
const NEXT_MAP = {
|
||||
purchase_order: ['purchase_in'],
|
||||
sales_order: ['sales_out'],
|
||||
purchase_in: ['purchase_return'],
|
||||
sales_out: ['sales_return', 'other_out']
|
||||
}
|
||||
|
||||
const id = ref(0)
|
||||
const doc = ref(null)
|
||||
const loading = ref(false)
|
||||
const busy = ref(false)
|
||||
const docTypes = ref([])
|
||||
|
||||
const items = computed(() => (doc.value || {}).items || [])
|
||||
const instance = computed(() => (doc.value || {}).instance || null)
|
||||
const isDraft = computed(() => {
|
||||
const s = (doc.value || {}).status
|
||||
return s === DOC_STATUS.DRAFT || s === DOC_STATUS.REJECTED
|
||||
})
|
||||
const partnerLabel = computed(() => {
|
||||
const type = (doc.value || {}).partner_type
|
||||
if (type === 'supplier') return '供应商'
|
||||
if (type === 'customer') return '客户'
|
||||
return '往来单位'
|
||||
})
|
||||
const nextTypes = computed(() => {
|
||||
const types = NEXT_MAP[(doc.value || {}).doc_type] || []
|
||||
return types.map(value => {
|
||||
const hit = docTypes.value.find(item => item.value === value)
|
||||
return { value, label: hit ? hit.label : value }
|
||||
})
|
||||
})
|
||||
|
||||
onLoad(query => {
|
||||
id.value = Number((query || {}).id || 0)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
load()
|
||||
loadTypes()
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
doc.value = await getDoc(id.value)
|
||||
} catch (e) {
|
||||
doc.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTypes() {
|
||||
try {
|
||||
docTypes.value = await fetchDocTypes()
|
||||
} catch (e) {
|
||||
docTypes.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function taskClass(task) {
|
||||
const label = String(task.status_label || '')
|
||||
if (label.includes('通过') || label.includes('完成')) return 'done'
|
||||
if (label.includes('驳回')) return 'rejected'
|
||||
if (label.includes('待') || label.includes('进行')) return 'pending'
|
||||
return 'waiting'
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
uni.navigateTo({ url: `/pages/erp/document/edit?id=${id.value}` })
|
||||
}
|
||||
|
||||
function goSource() {
|
||||
const sourceId = (doc.value || {}).source_doc_id
|
||||
if (!sourceId) return
|
||||
uni.navigateTo({ url: `/pages/erp/document/detail?id=${sourceId}` })
|
||||
}
|
||||
|
||||
function goDownstream(docType) {
|
||||
uni.navigateTo({ url: `/pages/erp/document/edit?doc_type=${docType}&source_doc_id=${id.value}` })
|
||||
}
|
||||
|
||||
async function onApprove() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await approveDoc(id.value)
|
||||
uni.showToast({ title: '已审核生效', icon: 'success' })
|
||||
await load()
|
||||
} catch (e) {
|
||||
// 错误提示已由请求层统一弹出
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const instanceId = await submitDoc(id.value)
|
||||
uni.showToast({ title: instanceId ? '已提交审批' : '已提交', icon: 'success' })
|
||||
await load()
|
||||
} catch (e) {
|
||||
// 未绑定审批流等业务失败已在请求层提示,可退回用「审核生效」
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
const confirmed = await confirm('作废后单据不再参与库存与报表,确定继续?')
|
||||
if (!confirmed) return
|
||||
await cancelDoc(id.value)
|
||||
uni.showToast({ title: '已作废', icon: 'success' })
|
||||
await load()
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
const confirmed = await confirm('删除该草稿单据?')
|
||||
if (!confirmed) return
|
||||
await deleteDoc(id.value)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
|
||||
function confirm(content) {
|
||||
return new Promise(resolve => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content,
|
||||
success: res => resolve(!!res.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding: 24rpx $page-padding-x calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 160rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.head-card {
|
||||
padding: 32rpx 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: $hero-gradient;
|
||||
color: #ffffff;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.head-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.head-type {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.head-amount {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 48rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.head-no {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 23rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.head-grid {
|
||||
display: flex;
|
||||
margin-top: 24rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.head-cell {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
@include block-head;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.block-tip {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 14rpx 0;
|
||||
}
|
||||
|
||||
.line-label {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line-value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.line-value.link {
|
||||
color: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.remark {
|
||||
margin-top: 12rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-amount {
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.item-empty {
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 0;
|
||||
}
|
||||
|
||||
.task-dot {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
margin-top: 10rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: $color-border;
|
||||
|
||||
&.done {
|
||||
background: $color-success;
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
background: $color-danger;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: $color-warning;
|
||||
}
|
||||
}
|
||||
|
||||
.task-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-node {
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.task-sub {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.task-comment {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: $radius-sm;
|
||||
background: $color-bg-page;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx $page-padding-x calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: $color-card;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.06);
|
||||
@include dialog-responsive;
|
||||
}
|
||||
|
||||
.act {
|
||||
flex: 1;
|
||||
height: 78rpx;
|
||||
line-height: 78rpx;
|
||||
text-align: center;
|
||||
border-radius: $radius-full;
|
||||
font-size: 26rpx;
|
||||
background: $color-bg-page;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&.ghost {
|
||||
background: $color-bg-page;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="filter-bar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
placeholder="搜索单号 / 往来单位 / 备注"
|
||||
confirm-type="search"
|
||||
@confirm="refresh"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
|
||||
<view class="chip-row">
|
||||
<view class="chip" @tap="typePicker = true">
|
||||
<FaIcon name="filter" color="#2563eb" :size="14" />
|
||||
<text>{{ currentTypeLabel }}</text>
|
||||
</view>
|
||||
<view class="chip" @tap="openWarehousePicker">
|
||||
<FaIcon name="warehouse" color="#2563eb" :size="14" />
|
||||
<text>{{ currentWarehouseLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="tab-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="tab-list">
|
||||
<view
|
||||
v-for="tab in statusTabs"
|
||||
:key="String(tab.value)"
|
||||
class="tab-item"
|
||||
:class="{ active: currentStatus === tab.value }"
|
||||
@tap="onTabChange(tab.value)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="list" text="暂无单据" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="row in list" :key="row.id" class="doc-card" @tap="openDetail(row)">
|
||||
<view class="card-top">
|
||||
<view class="card-title">
|
||||
<text class="card-type">{{ row.doc_type_label || row.doc_type }}</text>
|
||||
<text class="card-no">{{ row.doc_no }}</text>
|
||||
</view>
|
||||
<u-tag :text="row.status_label || docStatusLabel(row.status)" :type="docStatusType(row.status)" size="mini" />
|
||||
</view>
|
||||
<view class="card-partner">
|
||||
<FaIcon :name="row.partner_type === 'supplier' ? 'truck' : 'user'" color="#94a3b8" :size="14" />
|
||||
<text>{{ row.partner_name || row.warehouse_name || '内部单据' }}</text>
|
||||
</view>
|
||||
<view class="card-amounts">
|
||||
<view class="amount-item">
|
||||
<text class="amount-label">金额</text>
|
||||
<text class="amount-value">¥{{ moneyText(row.actual_amount) }}</text>
|
||||
</view>
|
||||
<view class="amount-item">
|
||||
<text class="amount-label">数量</text>
|
||||
<text class="amount-value">{{ qtyText(row.total_qty) }}</text>
|
||||
</view>
|
||||
<view class="amount-item">
|
||||
<text class="amount-label">业务日期</text>
|
||||
<text class="amount-value">{{ row.biz_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="row.remark" class="card-remark">
|
||||
<text>{{ row.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="fab" @tap="openCreate">
|
||||
<FaIcon name="pen" color="#ffffff" :size="18" />
|
||||
<text>新建单据</text>
|
||||
</view>
|
||||
|
||||
<ErpPicker
|
||||
v-model:show="typePicker"
|
||||
title="单据类型"
|
||||
placeholder="搜索类型"
|
||||
:options="typeOptions"
|
||||
:model-value="docType"
|
||||
@select="onTypeSelect"
|
||||
/>
|
||||
<ErpPicker
|
||||
v-model:show="warehousePicker"
|
||||
title="仓库"
|
||||
placeholder="搜索仓库名"
|
||||
:options="warehouseOptions"
|
||||
:model-value="warehouseId"
|
||||
@select="onWarehouseSelect"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow, onReachBottom } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import ErpPicker from '@/components/erp/ErpPicker.vue'
|
||||
import {
|
||||
DOC_STATUS_OPTIONS,
|
||||
docStatusLabel,
|
||||
docStatusType,
|
||||
fetchDocTypes,
|
||||
fetchDocs,
|
||||
fetchWarehouses,
|
||||
moneyText,
|
||||
qtyText
|
||||
} from '@/api/erp.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const statusTabs = [{ value: '', label: '全部' }, ...DOC_STATUS_OPTIONS]
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
const currentStatus = ref('')
|
||||
const docType = ref('')
|
||||
const warehouseId = ref('')
|
||||
const docTypes = ref([])
|
||||
const warehouses = ref([])
|
||||
|
||||
const typePicker = ref(false)
|
||||
const warehousePicker = ref(false)
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ value: '', label: '全部类型', sub: '不限单据类型' },
|
||||
...docTypes.value.map(item => ({ value: item.value, label: item.label, sub: describeType(item) }))
|
||||
])
|
||||
|
||||
const warehouseOptions = computed(() => [
|
||||
{ value: '', label: '全部仓库' },
|
||||
...warehouses.value.map(item => ({
|
||||
value: item.id,
|
||||
label: item.warehouse_name,
|
||||
sub: `${item.stock_kinds || 0} 个品种 · 货值 ¥${moneyText(item.stock_value)}`
|
||||
}))
|
||||
])
|
||||
|
||||
const currentTypeLabel = computed(() => {
|
||||
const hit = docTypes.value.find(item => item.value === docType.value)
|
||||
return hit ? hit.label : '全部类型'
|
||||
})
|
||||
|
||||
const currentWarehouseLabel = computed(() => {
|
||||
const hit = warehouses.value.find(item => String(item.id) === String(warehouseId.value))
|
||||
return hit ? hit.warehouse_name : '全部仓库'
|
||||
})
|
||||
|
||||
onLoad((query = {}) => {
|
||||
if (query.doc_type) docType.value = query.doc_type
|
||||
if (query.status !== undefined && query.status !== '') currentStatus.value = Number(query.status)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
refresh()
|
||||
loadDictionaries()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
loadMore()
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchDocs({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
docType: docType.value,
|
||||
status: currentStatus.value,
|
||||
keyword: keyword.value.trim(),
|
||||
warehouseId: warehouseId.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDictionaries() {
|
||||
try {
|
||||
const [types, houses] = await Promise.all([fetchDocTypes(), fetchWarehouses({ onlyEnabled: true })])
|
||||
docTypes.value = types
|
||||
warehouses.value = houses
|
||||
} catch (e) {
|
||||
// 字典失败不影响列表,筛选降级为「全部」
|
||||
}
|
||||
}
|
||||
|
||||
function describeType(item) {
|
||||
const parts = []
|
||||
parts.push(item.partner_type === 'supplier' ? '供应商' : item.partner_type === 'customer' ? '客户' : '内部')
|
||||
parts.push(item.affects_stock ? '动库存' : '不动库存')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onTabChange(value) {
|
||||
if (currentStatus.value === value) return
|
||||
currentStatus.value = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onTypeSelect(opt) {
|
||||
docType.value = opt.value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onWarehouseSelect(opt) {
|
||||
warehouseId.value = opt.value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openWarehousePicker() {
|
||||
warehousePicker.value = true
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
uni.navigateTo({ url: `/pages/erp/document/detail?id=${row.id}` })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
const query = docType.value ? `?doc_type=${docType.value}` : ''
|
||||
uni.navigateTo({ url: `/pages/erp/document/edit${query}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: $color-bg-page;
|
||||
padding: 20rpx $page-padding-x 8rpx;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-card;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
max-width: 46%;
|
||||
|
||||
text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-scroll {
|
||||
margin-top: 16rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
padding: 4rpx 0 12rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 10rpx 28rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 24rpx;
|
||||
padding: 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-type {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.card-no {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.card-partner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 16rpx;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.card-amounts {
|
||||
display: flex;
|
||||
margin-top: 18rpx;
|
||||
padding-top: 18rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.amount-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-remark {
|
||||
margin-top: 14rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 140rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
height: 88rpx;
|
||||
padding: 0 48rpx;
|
||||
border-radius: $radius-full;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.4);
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<view v-if="bill" class="page">
|
||||
<view class="hero">
|
||||
<view class="hero-top">
|
||||
<text class="hero-partner">{{ bill.partner_name }}</text>
|
||||
<u-tag :text="bill.status_label" :type="statusType" size="mini" />
|
||||
</view>
|
||||
<text class="hero-label">{{ billTypeText }}金额</text>
|
||||
<text class="hero-amount">¥{{ moneyText(bill.amount) }}</text>
|
||||
<view class="hero-grid">
|
||||
<view class="hero-cell">
|
||||
<text class="hero-cell-label">已核销</text>
|
||||
<text class="hero-cell-value">¥{{ moneyText(bill.settled_amount) }}</text>
|
||||
</view>
|
||||
<view class="hero-cell">
|
||||
<text class="hero-cell-label">未{{ bill.bill_type === 1 ? '收' : '付' }}</text>
|
||||
<text class="hero-cell-value strong">¥{{ moneyText(balance) }}</text>
|
||||
</view>
|
||||
<view class="hero-cell">
|
||||
<text class="hero-cell-label">{{ progressText }}</text>
|
||||
<text class="hero-cell-value">{{ progressPercent }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="blk">
|
||||
<view class="blk-head">
|
||||
<text class="blk-title">账单信息</text>
|
||||
</view>
|
||||
<view class="kv">
|
||||
<text class="kv-label">账单编号</text>
|
||||
<text class="kv-value">{{ bill.id }}</text>
|
||||
</view>
|
||||
<view class="kv">
|
||||
<text class="kv-label">来源单据</text>
|
||||
<text class="kv-value link" @tap="openDoc">{{ bill.source_doc_no || '手工账单' }}</text>
|
||||
</view>
|
||||
<view class="kv">
|
||||
<text class="kv-label">账单日期</text>
|
||||
<text class="kv-value">{{ bill.bill_date || '—' }}</text>
|
||||
</view>
|
||||
<view class="kv">
|
||||
<text class="kv-label">账期到期</text>
|
||||
<text class="kv-value">
|
||||
{{ bill.due_date || '未设账期' }}
|
||||
<text v-if="bill.overdue_days > 0" class="kv-warn">(已逾期 {{ bill.overdue_days }} 天)</text>
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="bill.remark" class="kv">
|
||||
<text class="kv-label">备注</text>
|
||||
<text class="kv-value">{{ bill.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="blk">
|
||||
<view class="blk-head">
|
||||
<text class="blk-title">核销记录</text>
|
||||
<text class="blk-count">{{ settles.length }} 笔</text>
|
||||
</view>
|
||||
<view v-if="!settles.length" class="blk-empty">
|
||||
<text>尚未核销,登记{{ bill.bill_type === 1 ? '收款' : '付款' }}后自动冲抵</text>
|
||||
</view>
|
||||
<view v-for="s in settles" :key="s.id" class="st-row" @tap="openFund(s)">
|
||||
<view class="st-icon">
|
||||
<FaIcon
|
||||
:name="fundOf(s) && fundOf(s).fund_type <= 2 ? 'money-bill-transfer' : 'wallet'"
|
||||
color="#2563eb"
|
||||
:size="15"
|
||||
/>
|
||||
</view>
|
||||
<view class="st-main">
|
||||
<text class="st-title">{{ (fundOf(s) || {}).fund_no || s.partner_bill_no || '核销记录' }}</text>
|
||||
<text class="st-sub">{{ dayText(s.create_time) }}<text v-if="fundOf(s)"> · {{ fundMethodText(s) }}</text></text>
|
||||
</view>
|
||||
<text class="st-amount">¥{{ moneyText(s.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="docItems.length" class="blk">
|
||||
<view class="blk-head">
|
||||
<text class="blk-title">来源单据明细</text>
|
||||
<text class="blk-count">{{ docItems.length }} 行</text>
|
||||
</view>
|
||||
<view v-for="it in docItems" :key="it.id" class="di-row">
|
||||
<view class="di-main">
|
||||
<text class="di-name">{{ it.product_name }}</text>
|
||||
<text class="di-sub">{{ [it.product_code, it.spec].filter(Boolean).join(' · ') }}</text>
|
||||
</view>
|
||||
<view class="di-right">
|
||||
<text class="di-amount">¥{{ moneyText(it.amount) }}</text>
|
||||
<text class="di-qty">{{ qtyText(it.quantity) }} {{ it.unit || '' }} × ¥{{ moneyText(it.unit_price) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="balance > 0.005" class="footer">
|
||||
<u-button
|
||||
type="primary"
|
||||
color="#2563eb"
|
||||
shape="circle"
|
||||
:text="`登记${bill.bill_type === 1 ? '收款' : '付款'}冲抵`"
|
||||
@click="goSettle"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="page">
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else class="state-box">
|
||||
<u-empty text="账单不存在或已删除" mode="data" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 往来账单详情:核销进度、逐笔核销来源、关联的收付款单与原始单据明细。
|
||||
* funds 是后端按核销记录聚合出来的收付款单字典,核销行靠 fund_bill_id 反查单号与方式。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import { billStatusType, dayText, fundMethodLabel, getBill, moneyText, qtyText } from '@/api/erp.js'
|
||||
|
||||
const bill = ref(null)
|
||||
const settles = ref([])
|
||||
const funds = ref([])
|
||||
const docItems = ref([])
|
||||
const balance = ref(0)
|
||||
const loading = ref(true)
|
||||
|
||||
const billTypeText = computed(() => (bill.value && bill.value.bill_type === 1 ? '应收' : '应付'))
|
||||
const statusType = computed(() => billStatusType((bill.value || {}).status))
|
||||
const progressPercent = computed(() => {
|
||||
const amount = Number((bill.value || {}).amount || 0)
|
||||
if (!amount) return 0
|
||||
return Math.min(100, Math.round((Number(bill.value.settled_amount || 0) / amount) * 100))
|
||||
})
|
||||
const progressText = computed(() => (progressPercent.value >= 100 ? '已结清' : '已核销比例'))
|
||||
|
||||
onLoad(async (query = {}) => {
|
||||
const id = Number(query.id || 0)
|
||||
try {
|
||||
if (id) {
|
||||
const data = await getBill(id)
|
||||
if (data) {
|
||||
bill.value = data.bill
|
||||
settles.value = data.settles || []
|
||||
funds.value = data.funds || []
|
||||
docItems.value = data.docItems || []
|
||||
balance.value = Number(data.bill.balance || 0)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
uni.setNavigationBarTitle({ title: billTypeText.value + '详情' })
|
||||
})
|
||||
|
||||
function fundOf(s) {
|
||||
return funds.value.find((f) => Number(f.id) === Number(s.fund_bill_id))
|
||||
}
|
||||
|
||||
function fundMethodText(s) {
|
||||
const f = fundOf(s)
|
||||
return f ? fundMethodLabel(f.pay_method) : ''
|
||||
}
|
||||
|
||||
function openDoc() {
|
||||
if (!bill.value.source_doc_id) return
|
||||
uni.navigateTo({ url: `/pages/erp/document/detail?id=${bill.value.source_doc_id}` })
|
||||
}
|
||||
|
||||
function openFund(s) {
|
||||
const f = fundOf(s)
|
||||
if (!f) return
|
||||
uni.navigateTo({ url: `/pages/erp/finance/fund-edit?id=${f.id}` })
|
||||
}
|
||||
|
||||
function goSettle() {
|
||||
const q = `fund_type=${bill.value.bill_type}&partner_id=${bill.value.partner_id}&partner_name=${encodeURIComponent(
|
||||
bill.value.partner_name || ''
|
||||
)}&bill_id=${bill.value.id}`
|
||||
uni.navigateTo({ url: `/pages/erp/finance/fund-edit?${q}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin: 24rpx $page-padding-x 0;
|
||||
padding: 32rpx 32rpx 28rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: $hero-gradient;
|
||||
color: #ffffff;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.hero-partner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
display: block;
|
||||
margin-top: 22rpx;
|
||||
font-size: 22rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-amount {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 52rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: flex;
|
||||
margin-top: 24rpx;
|
||||
padding-top: 20rpx;
|
||||
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;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.blk {
|
||||
@include card;
|
||||
margin: 20rpx $page-padding-x 0;
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.blk-head {
|
||||
@include block-head;
|
||||
}
|
||||
|
||||
.blk-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.blk-count {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.blk-empty {
|
||||
padding: 20rpx 0 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 14rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.kv-label {
|
||||
width: 150rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.kv-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
word-break: break-all;
|
||||
|
||||
&.link {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-warn {
|
||||
color: $color-danger;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.st-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.st-icon {
|
||||
@include icon-box(60rpx);
|
||||
}
|
||||
|
||||
.st-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.st-title {
|
||||
display: block;
|
||||
font-size: 27rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.st-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.st-amount {
|
||||
flex-shrink: 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.di-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.di-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.di-name {
|
||||
display: block;
|
||||
font-size: 27rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.di-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.di-right {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
|
||||
.di-amount {
|
||||
display: block;
|
||||
font-size: 27rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.di-qty {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 20rpx $page-padding-x calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-float;
|
||||
@include dialog-responsive;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 200rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,524 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="bt in BILL_TYPES"
|
||||
:key="bt.value"
|
||||
class="tab"
|
||||
:class="{ active: billType === bt.value }"
|
||||
@tap="switchType(bt.value)"
|
||||
>
|
||||
<text class="tab-label">{{ bt.label }}</text>
|
||||
<text class="tab-sum">未{{ bt.value === 1 ? '收' : '付' }} ¥{{ moneyText(openOf(bt.value)) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="filter-bar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
placeholder="搜索客商 / 单据号"
|
||||
confirm-type="search"
|
||||
@confirm="refresh"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
<view class="chip-row">
|
||||
<view
|
||||
v-for="opt in STATE_OPTIONS"
|
||||
:key="opt.value || 'all'"
|
||||
class="chip"
|
||||
:class="{ active: settleState === opt.value }"
|
||||
@tap="onState(opt.value)"
|
||||
>
|
||||
<text>{{ opt.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<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 v-if="dateFrom || dateTo" class="date-clear" @tap="clearDates">重置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="sum-bar">
|
||||
<text class="sum-item">共 {{ total }} 张账单</text>
|
||||
<text v-if="partnerName" class="sum-item" @tap="clearPartner">已筛「{{ partnerName }}」×</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="search" :text="`没有${billTypeLabel}账单`" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="row in list" :key="row.id" class="f-card" @tap="openBill(row)">
|
||||
<view class="f-top">
|
||||
<text class="f-partner">{{ row.partner_name }}</text>
|
||||
<u-tag :text="row.status_label" :type="row.status === 2 ? 'success' : row.status === 1 ? 'primary' : 'warning'" size="mini" />
|
||||
</view>
|
||||
<text class="f-sub">{{ row.source_doc_no || '手工账单' }} · {{ row.bill_date }}</text>
|
||||
<view class="f-amounts">
|
||||
<view class="f-cell">
|
||||
<text class="cell-label">{{ billType === 1 ? '应收' : '应付' }}</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.amount) }}</text>
|
||||
</view>
|
||||
<view class="f-cell">
|
||||
<text class="cell-label">已核销</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.settled_amount) }}</text>
|
||||
</view>
|
||||
<view class="f-cell">
|
||||
<text class="cell-label">{{ billType === 1 ? '未收' : '未付' }}</text>
|
||||
<text class="cell-value open">¥{{ moneyText(row.balance) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="f-foot">
|
||||
<text class="foot-due">{{ row.due_date ? `到期 ${row.due_date}` : '未设账期' }}</text>
|
||||
<text v-if="row.overdue_days > 0" class="foot-over">逾期 {{ row.overdue_days }} 天</text>
|
||||
<text v-else-if="row.status !== 2" class="foot-ok">账期内</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="fab" @tap="goFund">
|
||||
<FaIcon name="money-bill-wave" color="#ffffff" :size="18" />
|
||||
<text>{{ billType === 1 ? '登记收款' : '登记付款' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 应收应付账单列表。
|
||||
* 「未收/未付合计」用的是接口随列表返回的全量汇总,不是本页累加,翻页也不会跳数。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import { BILL_TYPES, SETTLE_STATES, fetchBills, moneyText } from '@/api/erp.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const STATE_OPTIONS = [{ value: '', label: '全部' }, ...SETTLE_STATES]
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const billType = ref(1)
|
||||
const partnerId = ref('')
|
||||
const partnerName = ref('')
|
||||
const keyword = ref('')
|
||||
const settleState = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const summary = ref({ openReceivable: 0, openPayable: 0 })
|
||||
|
||||
const billTypeLabel = computed(() => (billType.value === 1 ? '应收' : '应付'))
|
||||
|
||||
onLoad((query = {}) => {
|
||||
if (query.bill_type) billType.value = Number(query.bill_type) === 2 ? 2 : 1
|
||||
if (query.partner_id) {
|
||||
partnerId.value = String(query.partner_id)
|
||||
partnerName.value = decodeURIComponent(query.partner_name || '')
|
||||
}
|
||||
if (query.settle_state) settleState.value = String(query.settle_state)
|
||||
})
|
||||
|
||||
onShow(() => refresh())
|
||||
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
loadMore()
|
||||
})
|
||||
|
||||
function openOf(type) {
|
||||
return type === 1 ? summary.value.openReceivable : summary.value.openPayable
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchBills({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
billType: billType.value,
|
||||
partnerId: partnerId.value,
|
||||
partnerType: billType.value === 1 ? 'customer' : 'supplier',
|
||||
settleState: settleState.value,
|
||||
keyword: keyword.value.trim(),
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
summary.value = data.summary || summary.value
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function switchType(value) {
|
||||
if (billType.value === value) return
|
||||
billType.value = value
|
||||
// 应收只看客户、应付只看供应商,跨类型带的客商筛选必须一起清掉
|
||||
partnerId.value = ''
|
||||
partnerName.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearPartner() {
|
||||
partnerId.value = ''
|
||||
partnerName.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onState(value) {
|
||||
if (settleState.value === value) return
|
||||
settleState.value = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
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
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearDates() {
|
||||
dateFrom.value = ''
|
||||
dateTo.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openBill(row) {
|
||||
uni.navigateTo({ url: `/pages/erp/finance/bill-detail?id=${row.id}` })
|
||||
}
|
||||
|
||||
function goFund() {
|
||||
const q = `fund_type=${billType.value}${partnerId.value ? `&partner_id=${partnerId.value}&partner_name=${encodeURIComponent(partnerName.value)}` : ''}`
|
||||
uni.navigateTo({ url: `/pages/erp/finance/fund-edit?${q}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 22rpx 0 18rpx;
|
||||
position: relative;
|
||||
|
||||
.tab-label {
|
||||
font-size: 27rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.tab-sum {
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.tab-label {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-sum {
|
||||
color: $color-primary-dark;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 60rpx;
|
||||
height: 6rpx;
|
||||
margin-left: -30rpx;
|
||||
border-radius: 3rpx;
|
||||
background: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
padding: 20rpx $page-padding-x 8rpx;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-card;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.date-sep {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.date-clear {
|
||||
font-size: 23rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.sum-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 6rpx $page-padding-x 16rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.sum-item {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.f-card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.f-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.f-partner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.f-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.f-amounts {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.f-cell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.open {
|
||||
font-weight: 600;
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.f-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.foot-due {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.foot-over {
|
||||
color: $color-danger;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.foot-ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
height: 84rpx;
|
||||
padding: 0 44rpx;
|
||||
border-radius: $radius-full;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-size: 27rpx;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.4);
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,782 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="fundNo" class="no-bar">
|
||||
<text class="no-text">单号 {{ fundNo }}</text>
|
||||
<u-tag :text="fundStatusLabel(status)" :type="fundStatusType(status)" size="mini" />
|
||||
</view>
|
||||
|
||||
<!-- 单据信息 -->
|
||||
<view class="form-card">
|
||||
<view class="field">
|
||||
<text class="field-label">款项类型 <text class="required">*</text></text>
|
||||
<view v-if="editable" class="type-grid">
|
||||
<view
|
||||
v-for="ft in FUND_TYPES"
|
||||
:key="ft.value"
|
||||
class="type-cell"
|
||||
:class="{ active: form.fundType === ft.value }"
|
||||
@tap="onType(ft.value)"
|
||||
>
|
||||
<text>{{ ft.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="field-value">{{ typeLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">业务日期 <text class="required">*</text></text>
|
||||
<picker v-if="editable" mode="date" :value="form.bizDate" @change="onDate">
|
||||
<view class="picker-value">
|
||||
<text>{{ form.bizDate }}</text>
|
||||
<FaIcon name="calendar-days" color="#909399" :size="16" />
|
||||
</view>
|
||||
</picker>
|
||||
<text v-else class="field-value">{{ form.bizDate }}</text>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">
|
||||
往来单位<text v-if="needPartner" class="required">*</text>
|
||||
</text>
|
||||
<view v-if="editable" class="picker-value" @tap="openPartner">
|
||||
<text :class="{ placeholder: !form.partnerName }">{{ form.partnerName || chooseText }}</text>
|
||||
<FaIcon name="chevron-right" color="#909399" :size="16" />
|
||||
</view>
|
||||
<text v-else class="field-value">{{ form.partnerName || '—' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">金额 <text class="required">*</text></text>
|
||||
<view class="amount-box">
|
||||
<text class="amount-sign" :class="income ? 'sign-in' : 'sign-out'">{{ income ? '+' : '-' }}</text>
|
||||
<input
|
||||
v-if="editable"
|
||||
v-model="form.amount"
|
||||
class="amount-input"
|
||||
type="digit"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<text v-else class="field-value">¥{{ moneyText(form.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">结算方式</text>
|
||||
<view v-if="editable" class="chip-line">
|
||||
<view
|
||||
v-for="m in FUND_METHODS"
|
||||
:key="m.value"
|
||||
class="mini-chip"
|
||||
:class="{ active: form.payMethod === m.value }"
|
||||
@tap="form.payMethod = m.value"
|
||||
>
|
||||
<text>{{ m.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="field-value">{{ fundMethodLabel(form.payMethod) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="editable || form.payAccount" class="field">
|
||||
<text class="field-label">收付账户</text>
|
||||
<u-input
|
||||
v-if="editable"
|
||||
v-model="form.payAccount"
|
||||
placeholder="账号 / 商户主体(选填)"
|
||||
:maxlength="100"
|
||||
border="none"
|
||||
/>
|
||||
<text v-else class="field-value">{{ form.payAccount }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="editable || form.remark" class="field">
|
||||
<text class="field-label">备注</text>
|
||||
<u-textarea
|
||||
v-if="editable"
|
||||
v-model="form.remark"
|
||||
placeholder="付款事由、凭证号等(选填)"
|
||||
:maxlength="200"
|
||||
height="80"
|
||||
border="none"
|
||||
/>
|
||||
<text v-else class="field-value">{{ form.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 核销:收款/付款 + 已选往来单位时才出现 -->
|
||||
<view v-if="showSettle" class="settle-card">
|
||||
<view class="settle-head">
|
||||
<text class="settle-title">核销往来账</text>
|
||||
<text class="settle-sum">已选 ¥{{ moneyText(pickedSum) }} / {{ moneyText(form.amount) }}</text>
|
||||
</view>
|
||||
<text class="settle-tip">
|
||||
{{ bills.length
|
||||
? '不勾选则确认时按到期日从早到晚自动冲抵'
|
||||
: needPartner && !form.partnerId
|
||||
? '选择往来单位后显示其未结账单'
|
||||
: '该往来单位没有未结账单,确认后只记资金流水' }}
|
||||
</text>
|
||||
|
||||
<view v-if="billsLoading" class="settle-loading">
|
||||
<text>正在拉取待核销账单...</text>
|
||||
</view>
|
||||
<view v-for="b in bills" :key="b.id" class="bill-row">
|
||||
<view class="bill-check" :class="{ on: isPicked(b.id) }" @tap="toggleBill(b)">
|
||||
<FaIcon v-if="isPicked(b.id)" name="check" color="#ffffff" :size="12" />
|
||||
</view>
|
||||
<view class="bill-main">
|
||||
<text class="bill-no">{{ b.source_doc_no || `账单#${b.id}` }}</text>
|
||||
<text class="bill-sub">
|
||||
{{ dayText(b.bill_date) }}{{ b.due_date ? ` · 到期 ${dayText(b.due_date)}` : '' }} · 未结 ¥{{ moneyText(b.balance) }}
|
||||
</text>
|
||||
</view>
|
||||
<input
|
||||
v-if="isPicked(b.id)"
|
||||
v-model="picked[b.id]"
|
||||
class="bill-input"
|
||||
type="digit"
|
||||
:placeholder="String(b.balance)"
|
||||
/>
|
||||
<text v-else class="bill-balance">¥{{ moneyText(b.balance) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="bills.length" class="settle-foot">
|
||||
<text class="foot-link" @tap="pickAll">全部冲抵</text>
|
||||
<text v-if="pickedCount" class="foot-link" @tap="clearPicks">清空({{ pickedCount }})</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer">
|
||||
<template v-if="editable">
|
||||
<view class="btn-line">
|
||||
<u-button
|
||||
type="primary"
|
||||
color="#2563eb"
|
||||
shape="circle"
|
||||
:loading="saving"
|
||||
loading-text="保存中..."
|
||||
text="保存草稿"
|
||||
@click="onSave(false)"
|
||||
/>
|
||||
</view>
|
||||
<view class="btn-line gap">
|
||||
<u-button
|
||||
type="primary"
|
||||
color="#16a34a"
|
||||
shape="circle"
|
||||
:loading="saving"
|
||||
loading-text="确认中..."
|
||||
:text="`确认${typeLabel}`"
|
||||
@click="onSave(true)"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="fundId" class="del-line" @tap="onDelete">
|
||||
<text>删除草稿</text>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view v-if="status === 1" class="btn-line">
|
||||
<u-button type="error" plain shape="circle" text="作废此单" @click="onVoid" />
|
||||
</view>
|
||||
<view v-if="settles.length" class="view-settles">
|
||||
<text class="vs-title">核销记录</text>
|
||||
<view v-for="s in settles" :key="s.id" class="vs-row">
|
||||
<text class="vs-no">{{ s.partner_bill_no || `账单#${s.partner_bill_id}` }}</text>
|
||||
<text class="vs-amount">¥{{ moneyText(s.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<ErpPicker
|
||||
:show="picker.show"
|
||||
:title="partnerTitle"
|
||||
placeholder="搜索客商"
|
||||
:options="picker.options"
|
||||
:model-value="form.partnerId"
|
||||
remote
|
||||
:loading="picker.loading"
|
||||
:tip="picker.tip"
|
||||
@update:show="picker.show = false"
|
||||
@search="loadPartners"
|
||||
@select="onPartner"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 收付款开单 / 查看。
|
||||
* 草稿只是登记,确认那一刻后端才写核销明细并回写往来账余额;
|
||||
* 已确认的单不可改,只能作废(余额同步回滚),所以 view 模式复用同一张表单只读渲染。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import ErpPicker from '@/components/erp/ErpPicker.vue'
|
||||
import {
|
||||
FUND_METHODS,
|
||||
FUND_TYPES,
|
||||
confirmFund,
|
||||
dayText,
|
||||
deleteFund,
|
||||
fetchOpenBills,
|
||||
fetchPartners,
|
||||
fundMethodLabel,
|
||||
fundStatusLabel,
|
||||
fundStatusType,
|
||||
getFund,
|
||||
moneyText,
|
||||
saveFund,
|
||||
voidFund
|
||||
} from '@/api/erp.js'
|
||||
|
||||
function today() {
|
||||
const d = new Date()
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||
}
|
||||
|
||||
const fundId = ref(0)
|
||||
const fundNo = ref('')
|
||||
const status = ref(0)
|
||||
const saving = ref(false)
|
||||
const bills = ref([])
|
||||
const billsLoading = ref(false)
|
||||
const settles = ref([])
|
||||
const picked = reactive({})
|
||||
|
||||
const form = reactive({
|
||||
fundType: 1,
|
||||
bizDate: today(),
|
||||
partnerId: 0,
|
||||
partnerName: '',
|
||||
amount: '',
|
||||
payMethod: 'cash',
|
||||
payAccount: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const picker = reactive({ show: false, options: [], loading: false, tip: '' })
|
||||
|
||||
const editable = computed(() => !fundId.value || status.value === 0)
|
||||
const typeLabel = computed(() => (FUND_TYPES.find((t) => Number(t.value) === Number(form.fundType)) || {}).label || '收付款')
|
||||
const needPartner = computed(() => Number(form.fundType) === 1 || Number(form.fundType) === 2)
|
||||
const income = computed(() => Number(form.fundType) === 1 || Number(form.fundType) === 3)
|
||||
const showSettle = computed(() => editable.value && needPartner.value)
|
||||
const partnerTitle = computed(() => (Number(form.fundType) === 2 ? '选择供应商' : '选择客户'))
|
||||
const chooseText = computed(() => (Number(form.fundType) === 2 ? '选择供应商' : '选择客户'))
|
||||
|
||||
const pickedCount = computed(() => Object.keys(picked).filter((k) => isPicked(k)).length)
|
||||
const pickedSum = computed(() =>
|
||||
Object.keys(picked).reduce((sum, k) => (isPicked(k) ? sum + (Number(picked[k]) || 0) : sum), 0)
|
||||
)
|
||||
|
||||
onLoad(async (query = {}) => {
|
||||
if (query.fund_type) form.fundType = Number(query.fund_type)
|
||||
if (query.biz_date) form.bizDate = String(query.biz_date)
|
||||
if (query.partner_id) {
|
||||
form.partnerId = Number(query.partner_id)
|
||||
form.partnerName = decodeURIComponent(query.partner_name || '')
|
||||
}
|
||||
if (query.id) {
|
||||
await loadFund(Number(query.id))
|
||||
} else if (needPartner.value && form.partnerId) {
|
||||
// 从账单详情跳过来时把这张账单预勾选,金额默认取未结额
|
||||
await loadBills(Number(query.bill_id || 0))
|
||||
}
|
||||
})
|
||||
|
||||
async function loadFund(id) {
|
||||
const data = await getFund(id)
|
||||
if (!data || !data.fund) {
|
||||
uni.showToast({ title: '收付款单不存在', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 600)
|
||||
return
|
||||
}
|
||||
const f = data.fund
|
||||
fundId.value = f.id
|
||||
fundNo.value = f.fund_no
|
||||
status.value = Number(f.status || 0)
|
||||
form.fundType = Number(f.fund_type)
|
||||
form.bizDate = f.biz_date || today()
|
||||
form.partnerId = Number(f.partner_id || 0)
|
||||
form.partnerName = f.partner_name || ''
|
||||
form.amount = String(f.amount ?? '')
|
||||
form.payMethod = f.pay_method || 'cash'
|
||||
form.payAccount = f.pay_account || ''
|
||||
form.remark = f.remark || ''
|
||||
settles.value = data.settles || []
|
||||
uni.setNavigationBarTitle({ title: fundStatusLabel(status.value) === '草稿' ? '编辑收付款' : '收付款详情' })
|
||||
}
|
||||
|
||||
async function onType(value) {
|
||||
if (Number(form.fundType) === Number(value)) return
|
||||
form.fundType = Number(value)
|
||||
// 收款只核客户应收、付款只核供应商应付,切换后原往来单位与勾选一并作废
|
||||
form.partnerId = 0
|
||||
form.partnerName = ''
|
||||
clearPicks()
|
||||
bills.value = []
|
||||
}
|
||||
|
||||
function onDate(e) {
|
||||
form.bizDate = e.detail.value
|
||||
}
|
||||
|
||||
function openPartner() {
|
||||
picker.show = true
|
||||
loadPartners('')
|
||||
}
|
||||
|
||||
async function loadPartners(keyword) {
|
||||
picker.loading = true
|
||||
try {
|
||||
const partnerType = Number(form.fundType) === 2 ? 'supplier' : 'customer'
|
||||
const { list, total } = await fetchPartners({ partnerType, keyword, onlyEnabled: true, pageSize: 50 })
|
||||
picker.options = list.map((p) => ({
|
||||
value: p.partner_id,
|
||||
label: p.partner_name,
|
||||
sub: [p.contact_person, p.contact_phone].filter(Boolean).join(' · '),
|
||||
extra: Number(p.open_amount) > 0 ? `未结 ¥${moneyText(p.open_amount)}` : ''
|
||||
}))
|
||||
picker.tip = total > list.length ? `共 ${total} 家,输入关键字继续找` : ''
|
||||
} finally {
|
||||
picker.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPartner(opt) {
|
||||
picker.show = false
|
||||
form.partnerId = Number(opt.value)
|
||||
form.partnerName = opt.label
|
||||
clearPicks()
|
||||
await loadBills(0)
|
||||
}
|
||||
|
||||
async function loadBills(presetBillId) {
|
||||
if (!needPartner.value || !form.partnerId) return
|
||||
billsLoading.value = true
|
||||
try {
|
||||
bills.value = await fetchOpenBills(form.fundType, form.partnerId)
|
||||
if (presetBillId) {
|
||||
const hit = bills.value.find((b) => Number(b.id) === Number(presetBillId))
|
||||
if (hit) {
|
||||
picked[hit.id] = String(hit.balance)
|
||||
if (!Number(form.amount)) form.amount = String(hit.balance)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
billsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isPicked(id) {
|
||||
return picked[id] !== undefined
|
||||
}
|
||||
|
||||
function toggleBill(b) {
|
||||
if (isPicked(b.id)) delete picked[b.id]
|
||||
else picked[b.id] = String(b.balance)
|
||||
}
|
||||
|
||||
function pickAll() {
|
||||
bills.value.forEach((b) => {
|
||||
picked[b.id] = String(b.balance)
|
||||
})
|
||||
const sum = bills.value.reduce((acc, b) => acc + Number(b.balance || 0), 0)
|
||||
if (!Number(form.amount)) form.amount = String(Math.round(sum * 100) / 100)
|
||||
}
|
||||
|
||||
function clearPicks() {
|
||||
Object.keys(picked).forEach((k) => delete picked[k])
|
||||
}
|
||||
|
||||
function checkForm() {
|
||||
if (!(Number(form.amount) > 0)) return '金额必须大于 0'
|
||||
if (needPartner.value && !form.partnerId) return `请选择${Number(form.fundType) === 2 ? '供应商' : '客户'}`
|
||||
if (pickedSum.value > Number(form.amount) + 0.005) {
|
||||
return `核销合计 ¥${moneyText(pickedSum.value)} 不能大于收付金额`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function onSave(confirm) {
|
||||
const msg = checkForm()
|
||||
if (msg) {
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await saveFund({
|
||||
id: fundId.value,
|
||||
fundType: form.fundType,
|
||||
bizDate: form.bizDate,
|
||||
partnerId: form.partnerId,
|
||||
amount: Number(form.amount),
|
||||
payMethod: form.payMethod,
|
||||
payAccount: form.payAccount,
|
||||
remark: form.remark
|
||||
})
|
||||
const id = res.id || fundId.value
|
||||
if (confirm) {
|
||||
const lines = Object.keys(picked)
|
||||
.filter((k) => isPicked(k) && Number(picked[k]) > 0)
|
||||
.map((k) => ({ partnerBillId: Number(k), amount: Number(picked[k]) }))
|
||||
await confirmFund(id, lines)
|
||||
uni.showToast({ title: `已确认${typeLabel.value}`, icon: 'success' })
|
||||
} else {
|
||||
fundId.value = id
|
||||
uni.showToast({ title: '草稿已保存', icon: 'success' })
|
||||
}
|
||||
setTimeout(() => back(), 600)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onVoid() {
|
||||
uni.showModal({
|
||||
title: '作废收付款单',
|
||||
content: '作废后已核销的往来账余额会同步回滚,确定继续?',
|
||||
confirmColor: '#f56c6c',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await voidFund(fundId.value)
|
||||
uni.showToast({ title: '已作废', icon: 'success' })
|
||||
setTimeout(() => back(), 600)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
uni.showModal({
|
||||
title: '删除草稿',
|
||||
content: `确定删除「${fundNo.value || typeLabel.value}」这张草稿?`,
|
||||
confirmColor: '#f56c6c',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await deleteFund(fundId.value)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => back(), 600)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 列表页跳来的栈可能是账单详情,优先回到它的来源列表 */
|
||||
function back() {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) uni.navigateBack()
|
||||
else uni.redirectTo({ url: '/pages/erp/finance/fund' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding: 24rpx $page-padding-x 40rpx;
|
||||
}
|
||||
|
||||
.no-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8rpx 20rpx;
|
||||
|
||||
.no-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.form-card {
|
||||
@include card;
|
||||
padding: 8rpx 28rpx;
|
||||
}
|
||||
|
||||
.field {
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 14rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.required {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
|
||||
.placeholder {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.type-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.type-cell {
|
||||
flex: 1;
|
||||
min-width: 140rpx;
|
||||
height: 68rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $radius-md;
|
||||
background: #f1f5f9;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&.active {
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary-dark;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.amount-sign {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&.sign-in {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.sign-out {
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
flex: 1;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.chip-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mini-chip {
|
||||
padding: 10rpx 26rpx;
|
||||
border-radius: $radius-full;
|
||||
background: #f1f5f9;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&.active {
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary-dark;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.settle-card {
|
||||
@include card;
|
||||
margin-top: 24rpx;
|
||||
padding: 26rpx 28rpx;
|
||||
}
|
||||
|
||||
.settle-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.settle-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.settle-sum {
|
||||
font-size: 23rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.settle-tip {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settle-loading {
|
||||
padding: 24rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.bill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.bill-check {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
flex-shrink: 0;
|
||||
border-radius: $radius-sm;
|
||||
border: 2rpx solid #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.on {
|
||||
background: $color-primary;
|
||||
border-color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.bill-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.bill-no {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bill-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.bill-input {
|
||||
width: 160rpx;
|
||||
flex-shrink: 0;
|
||||
height: 60rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: $radius-sm;
|
||||
background: #f1f5f9;
|
||||
font-size: 25rpx;
|
||||
color: $color-text;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bill-balance {
|
||||
flex-shrink: 0;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.settle-foot {
|
||||
display: flex;
|
||||
gap: 32rpx;
|
||||
padding-top: 18rpx;
|
||||
}
|
||||
|
||||
.foot-link {
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.btn-line {
|
||||
&.gap {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.del-line {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 26rpx 0 6rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.view-settles {
|
||||
margin-top: 32rpx;
|
||||
@include card;
|
||||
padding: 24rpx 28rpx;
|
||||
|
||||
.vs-title {
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
|
||||
.vs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.vs-no {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vs-amount {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,520 @@
|
||||
<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="ft in TYPE_TABS"
|
||||
:key="ft.value || 'all'"
|
||||
class="tab"
|
||||
:class="{ active: fundType === ft.value }"
|
||||
@tap="switchType(ft.value)"
|
||||
>
|
||||
<text>{{ ft.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="filter-bar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
placeholder="搜索单号 / 往来单位"
|
||||
confirm-type="search"
|
||||
@confirm="refresh"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
<view class="chip-row">
|
||||
<view
|
||||
v-for="opt in STATUS_OPTIONS"
|
||||
:key="String(opt.value)"
|
||||
class="chip"
|
||||
:class="{ active: status === opt.value }"
|
||||
@tap="onStatus(opt.value)"
|
||||
>
|
||||
<text>{{ opt.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<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 v-if="dateFrom || dateTo" class="date-clear" @tap="clearDates">重置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="sum-bar">
|
||||
<text class="sum-item">共 {{ total }} 张单据</text>
|
||||
<text class="sum-item">本页 {{ sumText }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="search" text="没有收付款单" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="row in list" :key="row.id" class="d-card" @tap="openFund(row)">
|
||||
<view class="d-icon" :class="incomeOf(row.fund_type) ? 'icon-in' : 'icon-out'">
|
||||
<FaIcon
|
||||
:name="incomeOf(row.fund_type) ? 'arrow-down-long' : 'arrow-up-long'"
|
||||
:color="incomeOf(row.fund_type) ? '#16a34a' : '#ef4444'"
|
||||
:size="15"
|
||||
/>
|
||||
</view>
|
||||
<view class="d-main">
|
||||
<view class="d-line">
|
||||
<text class="d-partner">{{ row.partner_name || typeLabelOf(row.fund_type) }}</text>
|
||||
<u-tag :text="fundStatusLabel(row.status)" :type="fundStatusType(row.status)" size="mini" />
|
||||
</view>
|
||||
<text class="d-sub">
|
||||
{{ row.fund_no }} · {{ typeLabelOf(row.fund_type) }} · {{ fundMethodLabel(row.pay_method) }}
|
||||
</text>
|
||||
<text v-if="row.status === 0" class="d-draft">草稿未核销,确认后才冲抵往来账</text>
|
||||
</view>
|
||||
<view class="d-right">
|
||||
<text class="d-amount" :class="incomeOf(row.fund_type) ? 'amt-in' : 'amt-out'">
|
||||
{{ incomeOf(row.fund_type) ? '+' : '-' }}¥{{ moneyText(row.amount) }}
|
||||
</text>
|
||||
<text class="d-date">{{ row.biz_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="fab" @tap="createFund">
|
||||
<FaIcon name="plus" color="#ffffff" :size="18" />
|
||||
<text>登记收付款</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 收付款单列表。四类单据共用一页,靠 fund_type 分流。
|
||||
* 草稿只登记不核销,确认后后端才写核销明细并回写往来账余额。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import { FUND_STATUS_OPTIONS, FUND_TYPES, fetchFunds, fundMethodLabel, fundStatusLabel, fundStatusType, moneyText } from '@/api/erp.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const TYPE_TABS = [{ value: '', label: '全部' }, ...FUND_TYPES]
|
||||
const STATUS_OPTIONS = [{ value: '', label: '全部状态' }, ...FUND_STATUS_OPTIONS]
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const fundType = ref('')
|
||||
const status = ref('')
|
||||
const keyword = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
/** 从客商页带过来时只看这一家的收付款 */
|
||||
const partnerScope = ref('')
|
||||
|
||||
const sumText = computed(() => {
|
||||
const sum = list.value.reduce((acc, r) => acc + (Number(r.amount) || 0), 0)
|
||||
return `合计 ¥${moneyText(sum)}`
|
||||
})
|
||||
|
||||
onLoad((query = {}) => {
|
||||
if (query.fund_type) fundType.value = String(query.fund_type)
|
||||
if (query.status) status.value = String(query.status)
|
||||
if (query.partner_id) partnerScope.value = String(query.partner_id)
|
||||
})
|
||||
|
||||
onShow(() => refresh())
|
||||
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
loadMore()
|
||||
})
|
||||
|
||||
function incomeOf(type) {
|
||||
return Number(type) === 1 || Number(type) === 3
|
||||
}
|
||||
|
||||
function typeLabelOf(type) {
|
||||
return (FUND_TYPES.find((t) => Number(t.value) === Number(type)) || {}).label || '收付款'
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchFunds({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
fundType: fundType.value,
|
||||
status: status.value,
|
||||
partnerId: partnerScope.value,
|
||||
keyword: keyword.value.trim(),
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function switchType(value) {
|
||||
if (fundType.value === value) return
|
||||
fundType.value = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onStatus(value) {
|
||||
if (status.value === value) return
|
||||
status.value = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
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
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearDates() {
|
||||
dateFrom.value = ''
|
||||
dateTo.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openFund(row) {
|
||||
uni.navigateTo({ url: `/pages/erp/finance/fund-edit?id=${row.id}` })
|
||||
}
|
||||
|
||||
function createFund() {
|
||||
uni.navigateTo({ url: `/pages/erp/finance/fund-edit${fundType.value ? `?fund_type=${fundType.value}` : ''}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
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 28rpx 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 8rpx;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-card;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.date-sep {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.date-clear {
|
||||
font-size: 23rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.sum-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 6rpx $page-padding-x 16rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.sum-item {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.d-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.d-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: $radius-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.icon-in {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
}
|
||||
|
||||
&.icon-out {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.d-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.d-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.d-partner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.d-sub {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.d-draft {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 21rpx;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.d-right {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.d-amount {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&.amt-in {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.amt-out {
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
|
||||
.d-date {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
height: 84rpx;
|
||||
padding: 0 44rpx;
|
||||
border-radius: $radius-full;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-size: 27rpx;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.4);
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="loading && !profile" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<block v-else-if="profile">
|
||||
<view class="hero">
|
||||
<view class="hero-top">
|
||||
<text class="hero-name">{{ name }}</text>
|
||||
<u-tag :text="partnerTypeLabel(partnerType)" type="primary" size="mini" />
|
||||
</view>
|
||||
<text class="hero-sub">{{ [profile.contact_person, profile.contact_phone].filter(Boolean).join(' · ') || '未填联系人' }}</text>
|
||||
<view class="hero-grid">
|
||||
<view class="hero-cell">
|
||||
<text class="cell-label">{{ isSupplier ? '未付应付' : '未收应收' }}</text>
|
||||
<text class="cell-value owe">¥{{ moneyText(openAmount) }}</text>
|
||||
</view>
|
||||
<view class="hero-cell">
|
||||
<text class="cell-label">{{ isSupplier ? '累计应付' : '累计应收' }}</text>
|
||||
<text class="cell-value">¥{{ moneyText(totalAmount) }}</text>
|
||||
</view>
|
||||
<view class="hero-cell">
|
||||
<text class="cell-label">账期</text>
|
||||
<text class="cell-value">{{ Number(profile.payment_days) > 0 ? profile.payment_days + ' 天' : '现结' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">基础资料</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">等级 / 类型</text>
|
||||
<text class="line-value">{{ join([profile.supplier_level || profile.customer_level, profile.supplier_type || profile.customer_type]) }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">联系电话</text>
|
||||
<text class="line-value link" @tap="callPhone">{{ dash(profile.contact_phone) }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">邮箱</text>
|
||||
<text class="line-value">{{ dash(profile.contact_email) }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">地址</text>
|
||||
<text class="line-value">{{ dash(profile.address) }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">开票抬头</text>
|
||||
<text class="line-value">{{ join([profile.invoice_title, profile.tax_number]) }}</text>
|
||||
</view>
|
||||
<view class="line">
|
||||
<text class="line-label">收款账户</text>
|
||||
<text class="line-value">{{ join([profile.bank_name, profile.bank_account]) }}</text>
|
||||
</view>
|
||||
<view v-if="Number(profile.credit_limit) > 0" class="line">
|
||||
<text class="line-label">信用额度</text>
|
||||
<text class="line-value">¥{{ moneyText(profile.credit_limit) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="block-head">
|
||||
<text class="block-title">未结账单({{ bills.length }} 张)</text>
|
||||
<text class="block-tip" @tap="goAllBills">全部 {{ billTotal }} 张</text>
|
||||
</view>
|
||||
<view v-if="!bills.length" class="empty-line">
|
||||
<text>没有未结账单</text>
|
||||
</view>
|
||||
<view v-for="b in bills" :key="b.id" class="line tap" @tap="goBill(b)">
|
||||
<view class="line-label grow">
|
||||
<text>{{ b.source_doc_no || `账单#${b.id}` }}</text>
|
||||
<text class="sub">{{ b.bill_date }}{{ b.due_date ? ' · 到期 ' + b.due_date : '' }}</text>
|
||||
</view>
|
||||
<view class="line-value right">
|
||||
<text class="bal">¥{{ moneyText(b.balance) }}</text>
|
||||
<text v-if="b.overdue_days > 0" class="over">逾期 {{ b.overdue_days }} 天</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-space"></view>
|
||||
|
||||
<view class="actions">
|
||||
<view class="act" @tap="goDocs">
|
||||
<text>历史单据</text>
|
||||
</view>
|
||||
<view class="act primary" @tap="goFund">
|
||||
<text>{{ isSupplier ? '去付款' : '去收款' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 客商详情:档案 + 未结账单 + 一键收付款。
|
||||
* 余额口径直接取往来账(bill)与对账汇总,和 Web 端「应收应付」页一致,
|
||||
* 端上不另算一套,避免业务在手机上看到的欠款跟财务对不上。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import {
|
||||
fetchBills,
|
||||
fetchPartnerSummary,
|
||||
getPartner,
|
||||
moneyText,
|
||||
partnerTypeLabel
|
||||
} from '@/api/erp.js'
|
||||
|
||||
const profile = ref(null)
|
||||
const bills = ref([])
|
||||
const billTotal = ref(0)
|
||||
const summary = ref({})
|
||||
const loading = ref(false)
|
||||
const partnerId = ref(0)
|
||||
const partnerType = ref('customer')
|
||||
|
||||
const isSupplier = computed(() => partnerType.value === 'supplier')
|
||||
const name = computed(() => {
|
||||
const p = profile.value || {}
|
||||
return p.supplier_name || p.customer_name || p.name || '未命名客商'
|
||||
})
|
||||
const openAmount = computed(() => {
|
||||
const s = summary.value || {}
|
||||
return Number(isSupplier.value ? s.pay_open : s.recv_open) || 0
|
||||
})
|
||||
const totalAmount = computed(() => {
|
||||
const s = summary.value || {}
|
||||
return Number(isSupplier.value ? s.pay_total : s.recv_total) || 0
|
||||
})
|
||||
|
||||
onLoad(async (query = {}) => {
|
||||
partnerId.value = Number(query.id) || 0
|
||||
partnerType.value = String(query.partner_type) === 'supplier' ? 'supplier' : 'customer'
|
||||
uni.setNavigationBarTitle({ title: partnerTypeLabel(partnerType.value) })
|
||||
await load()
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!partnerId.value) {
|
||||
uni.showToast({ title: '缺少客商 ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const [p, b, rows] = await Promise.all([
|
||||
getPartner(partnerId.value, partnerType.value),
|
||||
fetchBills({ partnerId: partnerId.value, partnerType: partnerType.value, settleState: 'open', pageSize: 10 }),
|
||||
fetchPartnerSummary(partnerType.value)
|
||||
])
|
||||
profile.value = p
|
||||
bills.value = b.list || []
|
||||
billTotal.value = b.total || 0
|
||||
// 对账汇总按 partner_type:id 归并,这里只取当前这一家
|
||||
summary.value = rows.find((r) => Number(r.partner_id) === partnerId.value) || {}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '客商信息加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goFund() {
|
||||
const fundType = isSupplier.value ? 2 : 1
|
||||
uni.navigateTo({
|
||||
url: `/pages/erp/finance/fund-edit?fund_type=${fundType}&partner_id=${partnerId.value}&partner_name=${encodeURIComponent(name.value)}`
|
||||
})
|
||||
}
|
||||
|
||||
function goDocs() {
|
||||
uni.navigateTo({ url: `/pages/erp/document/index?partner_id=${partnerId.value}` })
|
||||
}
|
||||
|
||||
function goAllBills() {
|
||||
const billType = isSupplier.value ? 2 : 1
|
||||
uni.navigateTo({ url: `/pages/erp/finance/bill?bill_type=${billType}&partner_id=${partnerId.value}` })
|
||||
}
|
||||
|
||||
function goBill(bill) {
|
||||
uni.navigateTo({ url: `/pages/erp/finance/bill-detail?id=${bill.id}` })
|
||||
}
|
||||
|
||||
function callPhone() {
|
||||
const phone = String(profile.value?.contact_phone || '').trim()
|
||||
if (!phone) return
|
||||
uni.makePhoneCall({ phoneNumber: phone, fail: () => {} })
|
||||
}
|
||||
|
||||
function dash(v) {
|
||||
const s = String(v ?? '').trim()
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
function join(arr) {
|
||||
const s = (arr || []).filter(Boolean).join(' / ')
|
||||
return s || '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin: 0 $page-padding-x 24rpx;
|
||||
padding: 32rpx;
|
||||
border-radius: $radius-lg;
|
||||
background: $hero-gradient;
|
||||
color: #ffffff;
|
||||
box-shadow: $shadow-float;
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
flex: 1;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: flex;
|
||||
margin-top: 28rpx;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.hero-cell {
|
||||
flex: 1;
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
|
||||
&.owe {
|
||||
color: #fecaca;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 24rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
@include block-head;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.block-tip {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid $color-divider;
|
||||
font-size: 26rpx;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.tap:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.line-label {
|
||||
color: $color-text-secondary;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.line-value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: $color-text;
|
||||
word-break: break-all;
|
||||
|
||||
&.link {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.bal {
|
||||
font-size: 27rpx;
|
||||
font-weight: 500;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.over {
|
||||
font-size: 21rpx;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.empty-line {
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.footer-space {
|
||||
height: calc(150rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx $page-padding-x calc(18rpx + env(safe-area-inset-bottom));
|
||||
background: $color-card;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.06);
|
||||
@include dialog-responsive;
|
||||
}
|
||||
|
||||
.act {
|
||||
flex: 1;
|
||||
height: 76rpx;
|
||||
line-height: 76rpx;
|
||||
text-align: center;
|
||||
border-radius: $radius-full;
|
||||
background: $color-bg-page;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&.primary {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 140rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="filter-bar">
|
||||
<view class="type-row">
|
||||
<view
|
||||
v-for="t in PARTNER_TYPES"
|
||||
:key="t.value"
|
||||
class="type-item"
|
||||
:class="{ active: partnerType === t.value }"
|
||||
@tap="switchType(t.value)"
|
||||
>
|
||||
<text>{{ t.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
:placeholder="`搜索${partnerTypeLabel(partnerType)}名称`"
|
||||
confirm-type="search"
|
||||
@confirm="refresh"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
<view class="sum-row">
|
||||
<text class="sum-item">共 {{ total }} 家</text>
|
||||
<text class="sum-item">未结合计 ¥{{ moneyText(openTotal) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="search" :text="`没有匹配的${partnerTypeLabel(partnerType)}`" />
|
||||
<text class="state-tip">客商建档请在 Web 后台 CRM / 进销存里完成</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="row in list" :key="row.partner_id" class="p-card" @tap="openDetail(row)">
|
||||
<view class="p-top">
|
||||
<text class="p-name">{{ row.partner_name }}</text>
|
||||
<text class="p-open" :class="{ owe: Number(row.open_amount) > 0 }">
|
||||
{{ Number(row.open_amount) > 0 ? `未结 ¥${moneyText(row.open_amount)}` : '已结清' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="p-meta">
|
||||
<text v-if="row.contact_person">{{ row.contact_person }} {{ row.contact_phone || '' }}</text>
|
||||
<text v-if="row.level">等级 {{ row.level }}</text>
|
||||
<text v-if="Number(row.payment_days) > 0">账期 {{ row.payment_days }} 天</text>
|
||||
<text v-if="Number(row.credit_limit) > 0">信用额 ¥{{ moneyText(row.credit_limit) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="fab" @tap="goBills">
|
||||
<FaIcon name="receipt" color="#ffffff" :size="20" />
|
||||
<text>{{ partnerType === 'supplier' ? '应付账单' : '应收账单' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 客商列表(供应商 / 客户同一个页,靠 partner_type 分流)。
|
||||
* 与后端 partner/list 一致:只列正式启用、有归属的对象,公海客户不参与开单也不在这里出现。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import { PARTNER_TYPES, fetchPartners, moneyText, partnerTypeLabel } from '@/api/erp.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
const partnerType = ref('customer')
|
||||
|
||||
const openTotal = computed(() => list.value.reduce((sum, r) => sum + Number(r.open_amount || 0), 0))
|
||||
|
||||
onLoad((query = {}) => {
|
||||
if (query.partner_type) partnerType.value = String(query.partner_type) === 'supplier' ? 'supplier' : 'customer'
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
loadMore()
|
||||
})
|
||||
|
||||
function switchType(value) {
|
||||
if (partnerType.value === value) return
|
||||
partnerType.value = value
|
||||
list.value = []
|
||||
refresh()
|
||||
uni.setNavigationBarTitle({ title: partnerTypeLabel(value) })
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchPartners({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
partnerType: partnerType.value,
|
||||
keyword: keyword.value.trim()
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/erp/partner/detail?id=${row.partner_id}&partner_type=${partnerType.value}`
|
||||
})
|
||||
}
|
||||
|
||||
function goBills() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/erp/finance/bill?bill_type=${partnerType.value === 'supplier' ? 2 : 1}`
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: $color-bg-page;
|
||||
padding: 20rpx $page-padding-x 8rpx;
|
||||
}
|
||||
|
||||
.type-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.type-item {
|
||||
padding: 10rpx 32rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.sum-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 8rpx 4rpx;
|
||||
}
|
||||
|
||||
.sum-item {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.p-card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.p-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.p-name {
|
||||
flex: 1;
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.p-open {
|
||||
flex-shrink: 0;
|
||||
font-size: 25rpx;
|
||||
color: $color-text-muted;
|
||||
|
||||
&.owe {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.p-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx 24rpx;
|
||||
margin-top: 10rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text,
|
||||
.state-tip {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
height: 84rpx;
|
||||
padding: 0 44rpx;
|
||||
border-radius: $radius-full;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-size: 27rpx;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.4);
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="filter-bar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
placeholder="搜索商品名称 / 编码 / 批次"
|
||||
confirm-type="search"
|
||||
@confirm="refresh"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
<view class="chip-row">
|
||||
<view class="chip" :class="{ active: warehouseId }" @tap="openWarehouse">
|
||||
<text>{{ warehouseName || '全部仓库' }}</text>
|
||||
</view>
|
||||
<view class="chip" :class="{ active: onlyPositive }" @tap="toggleOnly">
|
||||
<text>仅有库存</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chip-row">
|
||||
<view
|
||||
v-for="opt in EXPIRE_STATE_OPTIONS"
|
||||
:key="opt.value || 'all'"
|
||||
class="chip mini"
|
||||
:class="{ active: expireState === opt.value }"
|
||||
@tap="onState(opt.value)"
|
||||
>
|
||||
<text>{{ opt.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="sum-bar">
|
||||
<text class="sum-item">共 {{ total }} 个批次</text>
|
||||
<text class="sum-item">本页货值 ¥{{ moneyText(sumAmount) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="search" text="没有符合条件的批次" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="row in list" :key="batchKey(row)" class="b-card" @tap="openLedger(row)">
|
||||
<view class="b-top">
|
||||
<text class="b-name">{{ row.product_name }}</text>
|
||||
<u-tag
|
||||
v-if="expireLabel(row)"
|
||||
:text="expireLabel(row)"
|
||||
:type="row.expired ? 'error' : 'warning'"
|
||||
size="mini"
|
||||
/>
|
||||
</view>
|
||||
<view class="b-batch">
|
||||
<FaIcon name="tags" color="#2563eb" :size="12" />
|
||||
<text class="b-no">{{ row.batch_no }}</text>
|
||||
<text class="b-wh">{{ row.warehouse_name || '未分仓' }}</text>
|
||||
</view>
|
||||
<text class="b-sub">{{ [row.product_code, row.spec].filter(Boolean).join(' · ') }}</text>
|
||||
<view class="b-grid">
|
||||
<view class="b-cell">
|
||||
<text class="cell-label">数量</text>
|
||||
<text class="cell-value strong">{{ qtyText(row.quantity) }} {{ row.unit || '' }}</text>
|
||||
</view>
|
||||
<view class="b-cell">
|
||||
<text class="cell-label">成本</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.cost_price) }}</text>
|
||||
</view>
|
||||
<view class="b-cell">
|
||||
<text class="cell-label">货值</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.amount) }}</text>
|
||||
</view>
|
||||
<view class="b-cell">
|
||||
<text class="cell-label">到期</text>
|
||||
<text class="cell-value">{{ row.expire_date || '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="b-hint">
|
||||
<FaIcon name="clock-rotate-left" color="#94a3b8" :size="12" />
|
||||
<text>看这个批次的收发流水</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<ErpPicker
|
||||
:show="picker.show"
|
||||
title="选择仓库"
|
||||
placeholder="搜索仓库"
|
||||
:options="warehouseOptions"
|
||||
:model-value="warehouseId"
|
||||
@update:show="picker.show = false"
|
||||
@select="onWarehouse"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 批次与效期台账。批次只从入库流水回溯到期日,未登记效期的批次到期列留空不算异常。
|
||||
* 点行跳到该商品+该批次的收发流水,做效期倒查不用再手动筛。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import ErpPicker from '@/components/erp/ErpPicker.vue'
|
||||
import { EXPIRE_STATE_OPTIONS, fetchBatches, fetchWarehouses, moneyText, qtyText } from '@/api/erp.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
const warehouseId = ref('')
|
||||
const warehouseName = ref('')
|
||||
const expireState = ref('')
|
||||
const onlyPositive = ref(true)
|
||||
const warehouses = ref([])
|
||||
const picker = ref({ show: false })
|
||||
|
||||
const warehouseOptions = computed(() => [
|
||||
{ value: '', label: '全部仓库' },
|
||||
...warehouses.value.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.warehouse_name,
|
||||
sub: w.warehouse_code || ''
|
||||
}))
|
||||
])
|
||||
|
||||
onLoad((query = {}) => {
|
||||
if (query.warehouse_id) {
|
||||
warehouseId.value = String(query.warehouse_id)
|
||||
warehouseName.value = decodeURIComponent(query.warehouse_name || '')
|
||||
}
|
||||
if (query.expire_state) expireState.value = String(query.expire_state)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
refresh()
|
||||
loadWarehouses()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
loadMore()
|
||||
})
|
||||
|
||||
async function loadWarehouses() {
|
||||
if (warehouses.value.length) return
|
||||
try {
|
||||
warehouses.value = await fetchWarehouses({ onlyEnabled: true })
|
||||
} catch (e) {
|
||||
// 仓库字典拉不到只是不能按仓筛
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchBatches({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: keyword.value.trim(),
|
||||
warehouseId: warehouseId.value,
|
||||
expireState: expireState.value,
|
||||
onlyPositive: onlyPositive.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const sumAmount = computed(() => list.value.reduce((sum, r) => sum + (Number(r.amount) || 0), 0))
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function toggleOnly() {
|
||||
onlyPositive.value = !onlyPositive.value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onState(value) {
|
||||
if (expireState.value === value) return
|
||||
expireState.value = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openWarehouse() {
|
||||
picker.value.show = true
|
||||
}
|
||||
|
||||
function onWarehouse(opt) {
|
||||
warehouseId.value = String(opt.value ?? '')
|
||||
warehouseName.value = warehouseId.value ? opt.label : ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function expireLabel(row) {
|
||||
if (row.expired) return '已过期'
|
||||
const d = row.expire_in_days
|
||||
if (d === null || d === undefined || String(d) === '') return ''
|
||||
return d <= 30 ? `${d} 天后到期` : ''
|
||||
}
|
||||
|
||||
function batchKey(row) {
|
||||
return `${row.product_id}-${row.warehouse_id}-${row.batch_no}`
|
||||
}
|
||||
|
||||
function openLedger(row) {
|
||||
const q = `product_id=${row.product_id}&warehouse_id=${row.warehouse_id || ''}&batch_no=${encodeURIComponent(
|
||||
row.batch_no || ''
|
||||
)}`
|
||||
uni.navigateTo({ url: `/pages/erp/stock/ledger?${q}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: $color-bg-page;
|
||||
padding: 20rpx $page-padding-x 8rpx;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.mini {
|
||||
padding: 8rpx 18rpx;
|
||||
font-size: 22rpx;
|
||||
box-shadow: none;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.sum-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 6rpx $page-padding-x 16rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.sum-item {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.b-card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.b-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.b-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b-batch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.b-no {
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: $radius-sm;
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary-dark;
|
||||
font-size: 23rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.b-wh {
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.b-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.b-grid {
|
||||
display: flex;
|
||||
gap: 10rpx;
|
||||
margin-top: 18rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.b-cell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
color: $color-primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.b-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 16rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="filter-bar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
placeholder="搜索商品名称 / 编码 / 批次"
|
||||
confirm-type="search"
|
||||
@confirm="refresh"
|
||||
/>
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||
</view>
|
||||
<view class="chip-row">
|
||||
<view class="chip" @tap="openPicker('warehouse')">
|
||||
<text>{{ warehouseName || '全部仓库' }}</text>
|
||||
<FaIcon name="chevron-right" color="#94a3b8" :size="12" />
|
||||
</view>
|
||||
<view class="chip" :class="{ active: groupByProduct }" @tap="toggleGroup">
|
||||
<text>{{ groupByProduct ? '按商品汇总' : '分仓分批次' }}</text>
|
||||
</view>
|
||||
<view class="chip" :class="{ active: onlyStock }" @tap="toggleOnlyStock">
|
||||
<text>有货</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="state-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="state-list">
|
||||
<view
|
||||
v-for="opt in SAFETY_STATE_OPTIONS"
|
||||
:key="opt.value || 'all'"
|
||||
class="state-item"
|
||||
:class="{ active: safetyState === opt.value }"
|
||||
@tap="onState(opt.value)"
|
||||
>
|
||||
<text>{{ opt.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="sum-bar">
|
||||
<text class="sum-item">{{ total }} 条结存</text>
|
||||
<text class="sum-item">库存货值 ¥{{ moneyText(sumAmount) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="search" text="没有符合条件的库存" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="(row, i) in list" :key="rowKey(row, i)" class="s-card" @tap="openProduct(row)">
|
||||
<view class="s-top">
|
||||
<text class="s-name">{{ row.product_name }}</text>
|
||||
<u-tag
|
||||
v-if="row.safety_state && row.safety_state !== 'normal'"
|
||||
:text="safetyStateLabel(row.safety_state)"
|
||||
:type="row.safety_state === 'low' ? 'warning' : 'primary'"
|
||||
size="mini"
|
||||
/>
|
||||
</view>
|
||||
<text class="s-sub">
|
||||
{{ [row.warehouse_name, row.batch_no ? '批次 ' + row.batch_no : ''].filter(Boolean).join(' · ') || '未分仓' }}
|
||||
<text v-if="row.category_name"> · {{ row.category_name }}</text>
|
||||
</text>
|
||||
<view class="s-grid">
|
||||
<view class="s-cell">
|
||||
<text class="cell-label">数量</text>
|
||||
<text class="cell-value strong">{{ qtyText(row.quantity) }} {{ row.unit || '' }}</text>
|
||||
</view>
|
||||
<view class="s-cell">
|
||||
<text class="cell-label">成本价</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.cost_price) }}</text>
|
||||
</view>
|
||||
<view class="s-cell">
|
||||
<text class="cell-label">货值</text>
|
||||
<text class="cell-value">¥{{ moneyText(row.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="Number(row.safety_qty) > 0 || Number(row.max_qty) > 0" class="s-limit">
|
||||
<text v-if="Number(row.safety_qty) > 0">安全 {{ qtyText(row.safety_qty) }}</text>
|
||||
<text v-if="Number(row.max_qty) > 0">最高 {{ qtyText(row.max_qty) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<ErpPicker
|
||||
:show="picker.show"
|
||||
title="选择仓库"
|
||||
placeholder="搜索仓库"
|
||||
:options="warehouseOptions"
|
||||
:model-value="warehouseId"
|
||||
@update:show="picker.show = false"
|
||||
@select="onWarehouse"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 库存结存查询。
|
||||
* 默认「分仓分批次」逐行看,切到「按商品汇总」看总量;
|
||||
* 货值 = 数量 × 移动加权成本,与报表中心收发存汇总同源。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import ErpPicker from '@/components/erp/ErpPicker.vue'
|
||||
import {
|
||||
SAFETY_STATE_OPTIONS,
|
||||
fetchStocks,
|
||||
fetchWarehouses,
|
||||
moneyText,
|
||||
qtyText,
|
||||
safetyStateLabel
|
||||
} from '@/api/erp.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const sumAmount = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const keyword = ref('')
|
||||
const warehouseId = ref('')
|
||||
const warehouseName = ref('')
|
||||
const safetyState = ref('')
|
||||
const groupByProduct = ref(false)
|
||||
const onlyStock = ref(true)
|
||||
const warehouses = ref([])
|
||||
/** 从商品详情跳过来时锁定单个商品,端上把 id 存下来随查询一起下发 */
|
||||
const productId = ref('')
|
||||
|
||||
const picker = ref({ show: false })
|
||||
|
||||
const warehouseOptions = computed(() => [
|
||||
{ value: '', label: '全部仓库' },
|
||||
...warehouses.value.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.warehouse_name,
|
||||
sub: w.warehouse_code || '',
|
||||
extra: `${w.stock_kinds || 0} 种`
|
||||
}))
|
||||
])
|
||||
|
||||
onLoad((query = {}) => {
|
||||
if (query.warehouse_id) {
|
||||
warehouseId.value = String(query.warehouse_id)
|
||||
warehouseName.value = decodeURIComponent(query.warehouse_name || '')
|
||||
}
|
||||
if (query.product_id) productId.value = String(query.product_id)
|
||||
if (query.group === 'product') groupByProduct.value = true
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
refresh()
|
||||
loadWarehouses()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
loadMore()
|
||||
})
|
||||
|
||||
async function loadWarehouses() {
|
||||
if (warehouses.value.length) return
|
||||
try {
|
||||
warehouses.value = await fetchWarehouses({ onlyEnabled: true })
|
||||
} catch (e) {
|
||||
// 仓库列表拉不到不影响查询,只是不能按仓筛
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchStocks({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: keyword.value.trim(),
|
||||
warehouseId: warehouseId.value,
|
||||
productId: productId.value,
|
||||
groupBy: groupByProduct.value ? 'product' : '',
|
||||
safetyState: safetyState.value,
|
||||
onlyStock: onlyStock.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
sumAmount.value = data.sumAmount || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function toggleGroup() {
|
||||
groupByProduct.value = !groupByProduct.value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function toggleOnlyStock() {
|
||||
onlyStock.value = !onlyStock.value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onState(value) {
|
||||
if (safetyState.value === value) return
|
||||
safetyState.value = value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
picker.value.show = true
|
||||
}
|
||||
|
||||
function onWarehouse(opt) {
|
||||
warehouseId.value = String(opt.value ?? '')
|
||||
warehouseName.value = warehouseId.value ? opt.label : ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function rowKey(row, i) {
|
||||
return `${row.product_id}-${row.warehouse_id || 0}-${row.batch_no || ''}-${i}`
|
||||
}
|
||||
|
||||
function openProduct(row) {
|
||||
if (!row.product_id) return
|
||||
uni.navigateTo({ url: `/pages/erp/basic/product-detail?id=${row.product_id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: $color-bg-page;
|
||||
padding: 20rpx $page-padding-x 8rpx;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.state-scroll {
|
||||
margin-top: 16rpx;
|
||||
white-space: nowrap;
|
||||
/* scroll-view 不设 border-box,左右内边距会把最后几个标签顶出屏幕 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.state-list {
|
||||
display: inline-flex;
|
||||
gap: 14rpx;
|
||||
padding-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.state-item {
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
|
||||
&.active {
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary-dark;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.sum-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0 $page-padding-x 16rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.sum-item {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.s-card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.s-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.s-name {
|
||||
flex: 1;
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.s-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.s-grid {
|
||||
display: flex;
|
||||
margin-top: 18rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.s-cell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
color: $color-primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.s-limit {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="filter-bar">
|
||||
<view class="search-box">
|
||||
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||
<input
|
||||
v-model="docNo"
|
||||
class="search-input"
|
||||
placeholder="搜索单据号"
|
||||
confirm-type="search"
|
||||
@confirm="refresh"
|
||||
/>
|
||||
<text v-if="docNo" class="search-clear" @tap="clearDocNo">清空</text>
|
||||
</view>
|
||||
<view class="chip-row">
|
||||
<view class="chip" :class="{ active: warehouseId }" @tap="openPicker('warehouse')">
|
||||
<text>{{ warehouseName || '全部仓库' }}</text>
|
||||
</view>
|
||||
<view class="chip" :class="{ active: productId }" @tap="openPicker('product')">
|
||||
<text>{{ productName || '全部商品' }}</text>
|
||||
</view>
|
||||
<view class="chip" :class="{ active: docType }" @tap="openPicker('docType')">
|
||||
<text>{{ docType ? docTypeLabel(docType) : '全部类型' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<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 v-if="dateFrom || dateTo" class="date-clear" @tap="clearDates">重置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="sum-bar">
|
||||
<text class="sum-item">共 {{ total }} 条流水</text>
|
||||
<text v-if="filterText" class="sum-item" @tap="clearFilter">已筛「{{ filterText }}」×</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="state-box">
|
||||
<text class="state-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="!list.length" class="state-box">
|
||||
<u-empty mode="search" text="还没有收发存记录" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-for="row in list" :key="row.id" class="l-card" @tap="openDoc(row)">
|
||||
<view class="l-head">
|
||||
<view class="l-dir" :class="row.direction > 0 ? 'dir-in' : 'dir-out'">
|
||||
<FaIcon
|
||||
:name="row.direction > 0 ? 'arrow-down-long' : 'arrow-up-long'"
|
||||
:color="row.direction > 0 ? '#16a34a' : '#ef4444'"
|
||||
:size="13"
|
||||
/>
|
||||
<text>{{ row.direction > 0 ? '入' : '出' }}</text>
|
||||
</view>
|
||||
<text class="l-name">{{ row.product_name || `商品#${row.product_id}` }}</text>
|
||||
<text class="l-qty" :class="row.direction > 0 ? 'qty-in' : 'qty-out'">
|
||||
{{ row.direction > 0 ? '+' : '-' }}{{ qtyText(row.quantity) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="l-sub">
|
||||
{{ [row.warehouse_name || '未分仓', row.spec, row.batch_no ? '批次 ' + row.batch_no : '']
|
||||
.filter(Boolean).join(' · ') }}
|
||||
</text>
|
||||
<view class="l-flow">
|
||||
<text class="flow-label">结存</text>
|
||||
<text class="flow-qty">{{ qtyText(row.before_qty) }}</text>
|
||||
<FaIcon name="arrow-right-long" color="#cbd5e1" :size="13" />
|
||||
<text class="flow-qty after">{{ qtyText(row.after_qty) }}</text>
|
||||
<text v-if="Number(row.unit_cost) > 0" class="flow-cost">
|
||||
成本 ¥{{ moneyText(row.unit_cost) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="l-foot">
|
||||
<u-tag :text="docTypeLabel(row.doc_type)" type="info" size="mini" />
|
||||
<text class="foot-doc">{{ row.doc_no }}</text>
|
||||
<text class="foot-time">{{ row.create_time_text }}</text>
|
||||
</view>
|
||||
<text v-if="row.operator_name" class="l-op">操作人 {{ row.operator_name }}</text>
|
||||
</view>
|
||||
<view v-if="finished" class="load-done">
|
||||
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<ErpPicker
|
||||
:show="picker.show"
|
||||
:title="pickerTitle"
|
||||
placeholder="搜索"
|
||||
:options="picker.options"
|
||||
:model-value="picker.current"
|
||||
:remote="picker.key === 'product'"
|
||||
:loading="picker.loading"
|
||||
:tip="picker.tip"
|
||||
@update:show="closePicker"
|
||||
@search="onProductSearch"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 收发存流水台账。每行是「某商品某仓库某批次」的一次变动,前滚后结存直接可核对。
|
||||
* 商品维度可能上千,筛商品走服务端搜索;单据号点行可回到来源单据详情。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import ErpPicker from '@/components/erp/ErpPicker.vue'
|
||||
import {
|
||||
docTypeLabel,
|
||||
fetchDocTypes,
|
||||
fetchProducts,
|
||||
fetchStockLedger,
|
||||
fetchWarehouses,
|
||||
moneyText,
|
||||
qtyText
|
||||
} from '@/api/erp.js'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
|
||||
const docNo = ref('')
|
||||
const warehouseId = ref('')
|
||||
const warehouseName = ref('')
|
||||
const productId = ref('')
|
||||
const productName = ref('')
|
||||
const docType = ref('')
|
||||
const batchNo = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
|
||||
const warehouses = ref([])
|
||||
const docTypes = ref([])
|
||||
|
||||
const picker = ref({ key: '', show: false, options: [], current: '', loading: false, tip: '' })
|
||||
|
||||
const pickerTitle = computed(() =>
|
||||
({ warehouse: '选择仓库', product: '选择商品', docType: '选择单据类型' })[picker.value.key] || '请选择'
|
||||
)
|
||||
|
||||
/** 从商品/批次页带条件跳进来时,把锁定条件显式画出来,免得用户对着半屏数据找原因 */
|
||||
const filterText = computed(() =>
|
||||
[productName.value, batchNo.value ? `批次 ${batchNo.value}` : ''].filter(Boolean).join(' · ')
|
||||
)
|
||||
|
||||
onLoad((query = {}) => {
|
||||
if (query.product_id) {
|
||||
productId.value = String(query.product_id)
|
||||
productName.value = decodeURIComponent(query.product_name || '')
|
||||
}
|
||||
if (query.warehouse_id) {
|
||||
warehouseId.value = String(query.warehouse_id)
|
||||
warehouseName.value = decodeURIComponent(query.warehouse_name || '')
|
||||
}
|
||||
if (query.doc_type) docType.value = String(query.doc_type)
|
||||
if (query.doc_no) docNo.value = String(query.doc_no)
|
||||
if (query.batch_no) batchNo.value = String(query.batch_no)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
refresh()
|
||||
loadDicts()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
loadMore()
|
||||
})
|
||||
|
||||
async function loadDicts() {
|
||||
if (!warehouses.value.length) {
|
||||
try {
|
||||
warehouses.value = await fetchWarehouses({ onlyEnabled: true })
|
||||
} catch (e) {
|
||||
// 字典拉不到只是不能筛,流水本身仍可查看
|
||||
}
|
||||
}
|
||||
if (!docTypes.value.length) {
|
||||
try {
|
||||
docTypes.value = await fetchDocTypes()
|
||||
} catch (e) {
|
||||
docTypes.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value += 1
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchStockLedger({
|
||||
page: page.value,
|
||||
pageSize: PAGE_SIZE,
|
||||
productId: productId.value,
|
||||
warehouseId: warehouseId.value,
|
||||
docType: docType.value,
|
||||
batchNo: batchNo.value,
|
||||
docNo: docNo.value.trim(),
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value
|
||||
})
|
||||
const rows = data.list || []
|
||||
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||
total.value = data.total || 0
|
||||
finished.value = list.value.length >= total.value
|
||||
} catch (e) {
|
||||
if (page.value > 1) page.value -= 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearDates() {
|
||||
dateFrom.value = ''
|
||||
dateTo.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearDocNo() {
|
||||
docNo.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
productId.value = ''
|
||||
productName.value = ''
|
||||
batchNo.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function openPicker(key) {
|
||||
picker.value.key = key
|
||||
picker.value.show = true
|
||||
picker.value.tip = ''
|
||||
picker.value.loading = false
|
||||
if (key === 'warehouse') {
|
||||
picker.value.current = warehouseId.value
|
||||
picker.value.options = [
|
||||
{ value: '', label: '全部仓库' },
|
||||
...warehouses.value.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.warehouse_name,
|
||||
sub: w.warehouse_code || ''
|
||||
}))
|
||||
]
|
||||
} else if (key === 'docType') {
|
||||
picker.value.current = docType.value
|
||||
picker.value.options = [
|
||||
{ value: '', label: '全部类型' },
|
||||
...docTypes.value.map((t) => ({ value: t.value, label: t.label }))
|
||||
]
|
||||
} else {
|
||||
picker.value.current = productId.value
|
||||
await loadProducts('')
|
||||
}
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
picker.value.show = false
|
||||
picker.value.loading = false
|
||||
}
|
||||
|
||||
async function loadProducts(keyword) {
|
||||
picker.value.loading = true
|
||||
try {
|
||||
const { list: rows, total: sum } = await fetchProducts({ keyword, pageSize: 50 })
|
||||
picker.value.options = rows.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.product_name,
|
||||
sub: [p.product_code, p.spec].filter(Boolean).join(' · '),
|
||||
extra: `${qtyText(p.total_stock)} ${p.base_unit || ''}`
|
||||
}))
|
||||
picker.value.tip = sum > rows.length ? `共 ${sum} 个商品,输入关键字继续找` : ''
|
||||
} finally {
|
||||
picker.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function onProductSearch(keyword) {
|
||||
loadProducts(keyword)
|
||||
}
|
||||
|
||||
function onSelect(opt) {
|
||||
const key = picker.value.key
|
||||
if (key === 'warehouse') {
|
||||
warehouseId.value = String(opt.value ?? '')
|
||||
warehouseName.value = warehouseId.value ? opt.label : ''
|
||||
} else if (key === 'docType') {
|
||||
docType.value = String(opt.value ?? '')
|
||||
} else {
|
||||
productId.value = String(opt.value)
|
||||
productName.value = opt.label
|
||||
}
|
||||
closePicker()
|
||||
refresh()
|
||||
}
|
||||
|
||||
function openDoc(row) {
|
||||
if (!row.doc_id) return
|
||||
uni.navigateTo({ url: `/pages/erp/document/detail?id=${row.doc_id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: $color-bg-page;
|
||||
padding: 20rpx $page-padding-x 8rpx;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-card;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-card;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
box-shadow: $shadow-card;
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-card;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.date-sep {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.date-clear {
|
||||
font-size: 23rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.sum-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0 $page-padding-x 16rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.sum-item {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.l-card {
|
||||
@include card;
|
||||
margin: 0 $page-padding-x 20rpx;
|
||||
padding: 22rpx 28rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.l-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.l-dir {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
width: 56rpx;
|
||||
height: 44rpx;
|
||||
border-radius: $radius-sm;
|
||||
justify-content: center;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.dir-in {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.dir-out {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.l-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.l-qty {
|
||||
flex-shrink: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&.qty-in {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.qty-out {
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
|
||||
.l-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.l-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-top: 16rpx;
|
||||
padding-top: 14rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.flow-label {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.flow-qty {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&.after {
|
||||
font-weight: 600;
|
||||
color: $color-primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-cost {
|
||||
margin-left: auto;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.l-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.foot-doc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 23rpx;
|
||||
color: $color-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foot-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.l-op {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.load-done {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in TABS"
|
||||
:key="tab.key"
|
||||
class="tab"
|
||||
:class="{ active: tabKey === tab.key }"
|
||||
@tap="switchTab(tab.key)"
|
||||
>
|
||||
<text class="tab-label">{{ tab.label }}</text>
|
||||
<text v-if="countOf(tab.key)" class="tab-count">{{ countOf(tab.key) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="state-box">
|
||||
<text class="state-text">正在盘点预警...</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="head-line">
|
||||
<text class="head-text">{{ headText }}</text>
|
||||
<text v-if="!isReplenish" class="head-tip">点商品看档案</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!rows.length" class="state-box">
|
||||
<u-empty :text="emptyText" mode="list" />
|
||||
</view>
|
||||
|
||||
<template v-else-if="isReplenish">
|
||||
<view v-for="row in rows" :key="row.product_id" class="w-card">
|
||||
<view class="w-top">
|
||||
<text class="w-name" @tap="openProduct(row)">{{ row.product_name }}</text>
|
||||
<u-tag text="建议补货" type="primary" size="mini" />
|
||||
</view>
|
||||
<text class="w-sub">{{ [row.product_code, row.spec].filter(Boolean).join(' · ') }}</text>
|
||||
<view class="w-grid">
|
||||
<view class="w-cell">
|
||||
<text class="cell-label">现库存</text>
|
||||
<text class="cell-value">{{ qtyText(row.quantity) }} {{ row.unit || '' }}</text>
|
||||
</view>
|
||||
<view class="w-cell">
|
||||
<text class="cell-label">在途</text>
|
||||
<text class="cell-value">{{ qtyText(row.in_transit) }}</text>
|
||||
</view>
|
||||
<view class="w-cell">
|
||||
<text class="cell-label">近30天销量</text>
|
||||
<text class="cell-value">{{ qtyText(row.sold_30) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="w-sug">
|
||||
<view class="sug-left">
|
||||
<text class="sug-label">建议采购</text>
|
||||
<text class="sug-qty">{{ qtyText(row.suggest_qty) }} {{ row.unit || '' }}</text>
|
||||
</view>
|
||||
<view class="sug-right">
|
||||
<text class="sug-amount">约 ¥{{ moneyText(row.amount) }}</text>
|
||||
<view class="go-btn" @tap="toPurchase(row)">
|
||||
<FaIcon name="cart-plus" color="#ffffff" :size="13" />
|
||||
<text>去开采购单</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<view v-for="(row, i) in rows" :key="`${row.product_id}-${row.batch_no || ''}-${i}`" class="w-card">
|
||||
<view class="w-top">
|
||||
<text class="w-name" @tap="openProduct(row)">{{ row.product_name }}</text>
|
||||
<u-tag :text="row.type_label || tabLabel" :type="tagType" size="mini" />
|
||||
</view>
|
||||
<text class="w-sub">
|
||||
{{ [row.category_name, row.warehouse_name, row.batch_no ? '批次 ' + row.batch_no : '']
|
||||
.filter(Boolean).join(' · ') }}
|
||||
</text>
|
||||
<view class="w-grid">
|
||||
<view class="w-cell">
|
||||
<text class="cell-label">库存</text>
|
||||
<text class="cell-value strong">{{ qtyText(row.quantity) }} {{ row.unit || '' }}</text>
|
||||
</view>
|
||||
<view v-if="tabKey !== 'expiring'" class="w-cell">
|
||||
<text class="cell-label">安全库存</text>
|
||||
<text class="cell-value">{{ qtyText(row.safety_qty) }}</text>
|
||||
</view>
|
||||
<view v-if="tabKey === 'over'" class="w-cell">
|
||||
<text class="cell-label">最高库存</text>
|
||||
<text class="cell-value">{{ qtyText(row.max_qty) }}</text>
|
||||
</view>
|
||||
<view v-if="tabKey === 'low'" class="w-cell">
|
||||
<text class="cell-label">在途</text>
|
||||
<text class="cell-value">{{ qtyText(row.in_transit) }}</text>
|
||||
</view>
|
||||
<view v-if="tabKey === 'expiring'" class="w-cell">
|
||||
<text class="cell-label">到期日</text>
|
||||
<text class="cell-value">{{ row.expire_date || '未登记' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="w-foot">
|
||||
<text v-if="tabKey === 'expiring'" class="foot-warn">
|
||||
{{ expireText(row) }}
|
||||
</text>
|
||||
<text v-else-if="Number(row.suggest_qty) > 0" class="foot-sug">
|
||||
建议{{ row.type === 'over' ? '清仓' : '补货' }} {{ qtyText(row.suggest_qty) }} {{ row.unit || '' }}
|
||||
</text>
|
||||
<view v-if="row.type === 'low' || row.type === 'out'" class="go-mini" @tap="toPurchase(row)">
|
||||
<text>去开采购单</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 库存预警与智能补货。
|
||||
* 四类预警一次取回(同源同一份结存快照),切 tab 只换视图不重复打接口;
|
||||
* 补货建议口径更重(含近30天销量与在途),单独按需拉。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
import { fetchReplenish, fetchStockWarning, moneyText, qtyText } from '@/api/erp.js'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'low', label: '低库存' },
|
||||
{ key: 'out', label: '缺货' },
|
||||
{ key: 'over', label: '积压' },
|
||||
{ key: 'expiring', label: '效期' },
|
||||
{ key: 'replenish', label: '补货' }
|
||||
]
|
||||
|
||||
const HEADS = {
|
||||
low: '低于安全库存,按 1.5 倍安全量给出补货建议',
|
||||
out: '库存已清零,销售出库会被拦截',
|
||||
over: '超出最高库存,占资金且易过期',
|
||||
expiring: '30 天内到期的批次,先出先用',
|
||||
replenish: '综合现库存、在途与近 30 天销量的采购建议'
|
||||
}
|
||||
|
||||
const tabKey = ref('low')
|
||||
const warning = ref({ low: [], out: [], over: [], expiring: [], counts: {} })
|
||||
const replenish = ref([])
|
||||
const loading = ref(true)
|
||||
const replenishLoaded = ref(false)
|
||||
|
||||
const tabLabel = computed(() => (TABS.find((t) => t.key === tabKey.value) || {}).label || '预警')
|
||||
const isReplenish = computed(() => tabKey.value === 'replenish')
|
||||
const rows = computed(() => (isReplenish.value ? replenish.value : warning.value[tabKey.value] || []))
|
||||
const headText = computed(() => HEADS[tabKey.value] || '')
|
||||
const emptyText = computed(() => (isReplenish.value ? '库存充足,暂无补货建议' : '这一类没有预警,库存健康'))
|
||||
const tagType = computed(
|
||||
() => ({ low: 'warning', out: 'error', over: 'primary', expiring: 'warning' })[tabKey.value] || 'warning'
|
||||
)
|
||||
|
||||
onLoad((query = {}) => {
|
||||
if (query.tab && TABS.some((t) => t.key === query.tab)) tabKey.value = query.tab
|
||||
loadWarning()
|
||||
})
|
||||
|
||||
async function loadWarning() {
|
||||
loading.value = true
|
||||
try {
|
||||
warning.value = await fetchStockWarning()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
if (tabKey.value === 'replenish') loadReplenish()
|
||||
}
|
||||
|
||||
async function loadReplenish() {
|
||||
if (replenishLoaded.value) return
|
||||
replenishLoaded.value = true
|
||||
try {
|
||||
replenish.value = (await fetchReplenish()).list || []
|
||||
} catch (e) {
|
||||
replenishLoaded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(key) {
|
||||
if (tabKey.value === key) return
|
||||
tabKey.value = key
|
||||
if (key === 'replenish') loadReplenish()
|
||||
}
|
||||
|
||||
function countOf(key) {
|
||||
if (key === 'replenish') return replenish.value.length
|
||||
return (warning.value.counts || {})[key] || 0
|
||||
}
|
||||
|
||||
function expireText(row) {
|
||||
const d = Number(row.expire_in_days)
|
||||
if (!row.expire_date) return '未登记效期'
|
||||
if (d < 0) return `已过期 ${Math.abs(d)} 天,禁止出库`
|
||||
return `还有 ${d} 天到期`
|
||||
}
|
||||
|
||||
function openProduct(row) {
|
||||
if (!row.product_id) return
|
||||
uni.navigateTo({ url: `/pages/erp/basic/product-detail?id=${row.product_id}` })
|
||||
}
|
||||
|
||||
function toPurchase(row) {
|
||||
const qty = Number(row.suggest_qty) > 0 ? row.suggest_qty : ''
|
||||
uni.navigateTo({
|
||||
url: `/pages/erp/document/edit?doc_type=purchase_order&product_id=${row.product_id}&quantity=${qty}`
|
||||
})
|
||||
}
|
||||
</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: 10;
|
||||
display: flex;
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 24rpx 0 20rpx;
|
||||
position: relative;
|
||||
|
||||
.tab-label {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: $radius-full;
|
||||
background: #f1f5f9;
|
||||
color: $color-text-secondary;
|
||||
font-size: 20rpx;
|
||||
line-height: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.tab-label {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
background: $color-primary;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 48rpx;
|
||||
height: 6rpx;
|
||||
margin-left: -24rpx;
|
||||
border-radius: 3rpx;
|
||||
background: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.head-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 22rpx $page-padding-x 6rpx;
|
||||
}
|
||||
|
||||
.head-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.head-tip {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.w-card {
|
||||
@include card;
|
||||
margin: 20rpx $page-padding-x 0;
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.w-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.w-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.w-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.w-grid {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.w-cell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 25rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
color: $color-primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.foot-sug {
|
||||
font-size: 23rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.foot-warn {
|
||||
font-size: 23rpx;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.go-mini {
|
||||
flex-shrink: 0;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-primary-bg;
|
||||
font-size: 22rpx;
|
||||
color: $color-primary-dark;
|
||||
}
|
||||
|
||||
.w-sug {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-top: 20rpx;
|
||||
padding-top: 18rpx;
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
.sug-left {
|
||||
.sug-label {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.sug-qty {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.sug-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12rpx;
|
||||
|
||||
.sug-amount {
|
||||
font-size: 23rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.go-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: $radius-full;
|
||||
background: linear-gradient(135deg, #60a5fa, #2563eb);
|
||||
color: #ffffff;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user