325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import { useCallback, useRef, useState } from 'react'
|
|
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
|
import { View, Text, ScrollView } from '@tarojs/components'
|
|
import { Empty, Icon, Popup } from '@antmjs/vantui'
|
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
|
import { getBillExportUrl, getBillListApi } from '@/services/bill'
|
|
import { getCategoriesApi } from '@/services/product'
|
|
import { downloadExportFile } from '@/utils/download'
|
|
import type { Bill, BillSummary } from '@/services/bill'
|
|
import './index.less'
|
|
|
|
const PAGE_SIZE = 10
|
|
|
|
/** 单次导出上限(后端限制 1~100 张) */
|
|
const EXPORT_MAX = 100
|
|
|
|
/** 状态筛选(接口 status 为原始支付状态:0 未支付含审核中 / 1 已支付) */
|
|
const STATUS_FILTERS: Array<{ value: 0 | 1 | undefined; label: string }> = [
|
|
{ value: undefined, label: '全部' },
|
|
{ value: 0, label: '未支付' },
|
|
{ value: 1, label: '已支付' },
|
|
]
|
|
|
|
/** 导出分类选项(id=0 全部分类) */
|
|
interface CategoryOption {
|
|
id: number
|
|
name: string
|
|
}
|
|
|
|
/**
|
|
* 账单列表页
|
|
* 采购单完成后由后台按门店生成(只读);底部汇总栏为门店口径待支付汇总(含审核中,不受筛选影响)
|
|
* 支持多选账单合并导出 Excel(可按一级分类过滤商品明细)
|
|
*/
|
|
export default function BillListPage() {
|
|
const token = useAuthStore(s => s.token)
|
|
|
|
const [status, setStatus] = useState<0 | 1 | undefined>(undefined)
|
|
const [bills, setBills] = useState<Bill[]>([])
|
|
const [summary, setSummary] = useState<BillSummary | null>(null)
|
|
const [page, setPage] = useState(1)
|
|
const [loading, setLoading] = useState(false)
|
|
const [finished, setFinished] = useState(false)
|
|
const loadingRef = useRef(false)
|
|
|
|
/** 导出:多选模式 + 已选账单 + 分类弹层 */
|
|
const [selectMode, setSelectMode] = useState(false)
|
|
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
|
const [showCategory, setShowCategory] = useState(false)
|
|
const [categories, setCategories] = useState<CategoryOption[]>([])
|
|
|
|
const loggedIn = !!token
|
|
|
|
/** 拉取账单列表(summary 每次随响应刷新) */
|
|
const loadList = useCallback(
|
|
async (pageNum: number, reset: boolean, statusParam?: 0 | 1) => {
|
|
if (!loggedIn || loadingRef.current) return
|
|
loadingRef.current = true
|
|
setLoading(true)
|
|
try {
|
|
const res = await getBillListApi({ status: statusParam, page: pageNum, pageSize: PAGE_SIZE })
|
|
const { data, total, summary: sum } = res.data
|
|
setBills(prev => (reset ? data : [...prev, ...data]))
|
|
setSummary(sum)
|
|
setPage(pageNum)
|
|
setFinished(pageNum * PAGE_SIZE >= total)
|
|
} catch {
|
|
// 错误已由 request 层 toast
|
|
} finally {
|
|
loadingRef.current = false
|
|
setLoading(false)
|
|
}
|
|
},
|
|
[loggedIn],
|
|
)
|
|
|
|
useDidShow(() => {
|
|
loadList(1, true, status)
|
|
})
|
|
|
|
useReachBottom(() => {
|
|
if (!finished && !loadingRef.current && loggedIn) {
|
|
loadList(page + 1, false, status)
|
|
}
|
|
})
|
|
|
|
/** 切换状态筛选 */
|
|
const handleStatusTap = useCallback(
|
|
(value?: 0 | 1) => {
|
|
setStatus(value)
|
|
setFinished(false)
|
|
loadList(1, true, value)
|
|
},
|
|
[loadList],
|
|
)
|
|
|
|
const goLogin = useCallback(() => {
|
|
Taro.navigateTo({ url: '/pages/login/index' })
|
|
}, [])
|
|
|
|
const goDetail = useCallback((id: number) => {
|
|
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${id}` })
|
|
}, [])
|
|
|
|
/** 合并付款 → 发起付款页 */
|
|
const goPay = useCallback(() => {
|
|
Taro.navigateTo({ url: '/pages/payment/index' })
|
|
}, [])
|
|
|
|
/** 进入/退出多选导出模式 */
|
|
const toggleSelectMode = useCallback(() => {
|
|
setSelectMode(prev => !prev)
|
|
setSelectedIds([])
|
|
}, [])
|
|
|
|
/** 账单点击:多选模式切换勾选,否则进详情 */
|
|
const handleItemTap = useCallback(
|
|
(bill: Bill) => {
|
|
if (!selectMode) {
|
|
goDetail(bill.id)
|
|
return
|
|
}
|
|
setSelectedIds(prev => {
|
|
if (prev.includes(bill.id)) return prev.filter(i => i !== bill.id)
|
|
if (prev.length >= EXPORT_MAX) {
|
|
Taro.showToast({ title: `最多导出 ${EXPORT_MAX} 张`, icon: 'none' })
|
|
return prev
|
|
}
|
|
return [...prev, bill.id]
|
|
})
|
|
},
|
|
[selectMode, goDetail],
|
|
)
|
|
|
|
/** 全选当前已加载账单(受导出上限约束) */
|
|
const handleSelectAll = useCallback(() => {
|
|
setSelectedIds(prev => {
|
|
if (prev.length === bills.length) return []
|
|
if (bills.length > EXPORT_MAX) {
|
|
Taro.showToast({ title: `最多导出 ${EXPORT_MAX} 张,已选前 ${EXPORT_MAX} 张`, icon: 'none' })
|
|
return bills.slice(0, EXPORT_MAX).map(b => b.id)
|
|
}
|
|
return bills.map(b => b.id)
|
|
})
|
|
}, [bills])
|
|
|
|
/** 打开分类选择弹层(首次加载分类树根节点) */
|
|
const handleExportTap = useCallback(async () => {
|
|
if (selectedIds.length === 0) {
|
|
Taro.showToast({ title: '请先勾选要导出的账单', icon: 'none' })
|
|
return
|
|
}
|
|
if (categories.length === 0) {
|
|
try {
|
|
const res = await getCategoriesApi()
|
|
setCategories([
|
|
{ id: 0, name: '全部分类' },
|
|
...res.data.map(c => ({ id: c.id, name: c.name })),
|
|
])
|
|
} catch {
|
|
return // 错误已由 request 层 toast
|
|
}
|
|
}
|
|
setShowCategory(true)
|
|
}, [selectedIds, categories])
|
|
|
|
/** 按所选分类导出合并 Excel */
|
|
const handleExport = useCallback(
|
|
async (categoryId: number) => {
|
|
const isH5 = process.env.TARO_ENV === 'h5'
|
|
Taro.showLoading({ title: '导出中...', mask: true })
|
|
try {
|
|
await downloadExportFile(getBillExportUrl(selectedIds, categoryId), '门店账单.xlsx')
|
|
setShowCategory(false)
|
|
toggleSelectMode()
|
|
if (isH5) {
|
|
Taro.showToast({ title: '导出成功', icon: 'success' })
|
|
}
|
|
} catch (e: any) {
|
|
Taro.showToast({ title: e?.message || '导出失败,请稍后重试', icon: 'none' })
|
|
} finally {
|
|
Taro.hideLoading()
|
|
}
|
|
},
|
|
[selectedIds, toggleSelectMode],
|
|
)
|
|
|
|
/** 底部待支付汇总栏是否可见(多选导出时让位给导出栏) */
|
|
const showPayBar = loggedIn && !selectMode && !!summary && summary.unpaid_count > 0
|
|
|
|
return (
|
|
<View className={`bill-page ${selectMode ? 'bill-page--select' : ''} ${showPayBar ? 'bill-page--pay' : ''}`}>
|
|
{/* ========== 状态筛选 + 导出入口 ========== */}
|
|
<View className='bill-toolbar'>
|
|
<ScrollView scrollX className='status-scroll'>
|
|
{STATUS_FILTERS.map(item => (
|
|
<View
|
|
key={item.label}
|
|
className={`status-chip ${status === item.value ? 'active' : ''}`}
|
|
onClick={() => handleStatusTap(item.value)}
|
|
>
|
|
<Text>{item.label}</Text>
|
|
</View>
|
|
))}
|
|
</ScrollView>
|
|
{loggedIn && bills.length > 0 && (
|
|
<View className={`bill-toolbar__export ${selectMode ? 'active' : ''}`} onClick={toggleSelectMode}>
|
|
<Text>{selectMode ? '取消' : '导出'}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* ========== 账单列表 ========== */}
|
|
{!loggedIn ? (
|
|
<Empty description='登录后查看账单' className='bill-empty'>
|
|
<View className='bill-empty__btn' onClick={goLogin}>去登录</View>
|
|
</Empty>
|
|
) : bills.length === 0 ? (
|
|
loading ? (
|
|
<View className='bill-loading'><Text>加载中...</Text></View>
|
|
) : (
|
|
<Empty description='暂无账单' className='bill-empty' />
|
|
)
|
|
) : (
|
|
bills.map(bill => {
|
|
const checked = selectMode && selectedIds.includes(bill.id)
|
|
return (
|
|
<View
|
|
key={bill.id}
|
|
className={`bill-item ${selectMode ? 'bill-item--select' : ''}`}
|
|
onClick={() => handleItemTap(bill)}
|
|
>
|
|
{selectMode && (
|
|
<View className={`bill-check ${checked ? 'on' : ''}`}>
|
|
{checked && <Icon name='success' size={14} color='#fff' />}
|
|
</View>
|
|
)}
|
|
<View className='bill-item__content'>
|
|
<View className='bill-item__header'>
|
|
<Text className='bill-item__no'>{bill.bill_no}</Text>
|
|
<Text className={`bill-item__status bill-item__status--${bill.pay_state}`}>
|
|
{bill.pay_state_name}
|
|
</Text>
|
|
</View>
|
|
<View className='bill-item__body'>
|
|
<View className='bill-item__meta'>
|
|
<Text className='bill-item__date'>账单日期 {bill.bill_date}</Text>
|
|
{bill.purchase && (
|
|
<Text className='bill-item__purchase'>采购单 {bill.purchase.purchase_no}</Text>
|
|
)}
|
|
</View>
|
|
<Text className='bill-item__amount'>¥{bill.total_amount}</Text>
|
|
</View>
|
|
<View className='bill-item__footer'>
|
|
<Text className='bill-item__settle'>应结算 {bill.settlement_date}</Text>
|
|
{bill.pay_state === 2 && bill.paid_at && (
|
|
<Text className='bill-item__paid'>已于 {bill.paid_at} 支付</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)
|
|
})
|
|
)}
|
|
|
|
{loggedIn && finished && bills.length > 0 && (
|
|
<View className='bill-loading'><Text>没有更多了</Text></View>
|
|
)}
|
|
|
|
{/* ========== 底部待支付汇总栏(门店口径,含审核中) ========== */}
|
|
{showPayBar && summary && (
|
|
<View className='bill-paybar'>
|
|
<View className='bill-paybar__info'>
|
|
<Text className='bill-paybar__label'>待支付账单(含审核中){summary.unpaid_count} 笔</Text>
|
|
<Text className='bill-paybar__amount'>¥{summary.unpaid_amount}</Text>
|
|
</View>
|
|
<View className='bill-paybar__btn' onClick={goPay}>去付款</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* ========== 导出操作栏 ========== */}
|
|
{selectMode && (
|
|
<View className='export-bar'>
|
|
<Text className='export-bar__count'>已选 {selectedIds.length} 张</Text>
|
|
<View className='export-bar__all' onClick={handleSelectAll}>
|
|
{selectedIds.length === bills.length && bills.length > 0 ? '取消全选' : '全选'}
|
|
</View>
|
|
<View
|
|
className={`export-bar__btn ${selectedIds.length === 0 ? 'disabled' : ''}`}
|
|
onClick={handleExportTap}
|
|
>
|
|
导出 Excel
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* ========== 分类选择弹层 ========== */}
|
|
<Popup
|
|
show={showCategory}
|
|
position='bottom'
|
|
round
|
|
closeable
|
|
closeOnClickOverlay
|
|
safeAreaInsetBottom
|
|
onClose={() => setShowCategory(false)}
|
|
>
|
|
<View className='category-popup'>
|
|
<Text className='category-popup__title'>选择商品分类</Text>
|
|
<Text className='category-popup__desc'>
|
|
仅过滤商品明细行,配送费与附加金额仍全额汇总
|
|
</Text>
|
|
<ScrollView scrollY className='category-popup__list'>
|
|
{categories.map(c => (
|
|
<View key={c.id} className='category-popup__item' onClick={() => handleExport(c.id)}>
|
|
<Text className='category-popup__name'>{c.name}</Text>
|
|
<Icon name='arrow' size={16} color='#c8c9cc' />
|
|
</View>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
</Popup>
|
|
</View>
|
|
)
|
|
}
|