账单导出

This commit is contained in:
liu
2026-08-14 20:41:25 +08:00
parent 03434feb35
commit 4138164bd8
4 changed files with 464 additions and 39 deletions
+196 -37
View File
@@ -1,14 +1,19 @@
import { useCallback, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components'
import { Empty } from '@antmjs/vantui'
import { Empty, Icon, Popup } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { getBillListApi } from '@/services/bill'
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: '全部' },
@@ -16,9 +21,16 @@ const STATUS_FILTERS: Array<{ value: 0 | 1 | undefined; label: string }> = [
{ value: 1, label: '已支付' },
]
/** 导出分类选项(id=0 全部分类) */
interface CategoryOption {
id: number
name: string
}
/**
* 账单列表页
* 采购单完成后由后台按门店生成(只读);头部 summary 为门店口径待支付汇总(含审核中,不受筛选影响)
* 支持多选账单合并导出 Excel(可按一级分类过滤商品明细)
*/
export default function BillListPage() {
const token = useAuthStore(s => s.token)
@@ -31,6 +43,12 @@ export default function BillListPage() {
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 每次随响应刷新) */
@@ -84,8 +102,86 @@ export default function BillListPage() {
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${id}` })
}, [])
/** 进入/退出多选导出模式 */
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],
)
return (
<View className='bill-page'>
<View className={`bill-page ${selectMode ? 'bill-page--select' : ''}`}>
{/* ========== 待支付汇总(门店口径,含审核中) ========== */}
{loggedIn && summary && summary.unpaid_count > 0 && (
<View className='bill-summary'>
@@ -97,18 +193,25 @@ export default function BillListPage() {
</View>
)}
{/* ========== 状态筛选 ========== */}
<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 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>
))}
</ScrollView>
)}
</View>
{/* ========== 账单列表 ========== */}
{!loggedIn ? (
@@ -122,36 +225,92 @@ export default function BillListPage() {
<Empty description='暂无账单' className='bill-empty' />
)
) : (
bills.map(bill => (
<View key={bill.id} className='bill-item' onClick={() => goDetail(bill.id)}>
<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>
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>
</View>
))
)
})
)}
{loggedIn && finished && bills.length > 0 && (
<View className='bill-loading'><Text></Text></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>
)
}