From 4138164bd80b0890e4c228f9b614fddcb4033287 Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Fri, 14 Aug 2026 20:41:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=A6=E5=8D=95=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/bill/index.less | 150 +++++++++++++++++++++++- src/pages/bill/index.tsx | 233 ++++++++++++++++++++++++++++++++------ src/services/bill.ts | 10 +- src/utils/download.ts | 110 ++++++++++++++++++ 4 files changed, 464 insertions(+), 39 deletions(-) create mode 100644 src/utils/download.ts diff --git a/src/pages/bill/index.less b/src/pages/bill/index.less index 73b1a48..4d6f54c 100644 --- a/src/pages/bill/index.less +++ b/src/pages/bill/index.less @@ -4,6 +4,11 @@ padding: 20rpx 24rpx 60rpx; box-sizing: border-box; + // 多选模式为底部导出栏留出空间 + &--select { + padding-bottom: 160rpx; + } + // ===== 待支付汇总 ===== .bill-summary { display: flex; @@ -37,7 +42,36 @@ } } - // ===== 状态筛选 ===== + // ===== 状态筛选 + 导出入口 ===== + .bill-toolbar { + display: flex; + align-items: center; + margin-bottom: 20rpx; + + .status-scroll { + flex: 1; + min-width: 0; + white-space: nowrap; + margin-bottom: 0; + } + + &__export { + flex-shrink: 0; + margin-left: 16rpx; + padding: 12rpx 28rpx; + border-radius: 999rpx; + background: #fff; + border: 1rpx solid #ee0a24; + color: #ee0a24; + font-size: 26rpx; + + &.active { + background: #ee0a24; + color: #fff; + } + } + } + .status-scroll { white-space: nowrap; margin-bottom: 20rpx; @@ -85,6 +119,16 @@ padding: 24rpx; margin-bottom: 16rpx; + &--select { + display: flex; + align-items: center; + } + + &__content { + flex: 1; + min-width: 0; + } + &__header { display: flex; align-items: center; @@ -155,4 +199,108 @@ color: #07c160; } } + + // ===== 勾选圆圈 ===== + .bill-check { + width: 36rpx; + height: 36rpx; + border-radius: 50%; + border: 2rpx solid #c8c9cc; + margin-right: 20rpx; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + + &.on { + background: #ee0a24; + border-color: #ee0a24; + } + } + + // ===== 导出操作栏 ===== + .export-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 99; + display: flex; + align-items: center; + padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom)); + background: #fff; + box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06); + + &__count { + flex: 1; + min-width: 0; + font-size: 26rpx; + color: #646566; + } + + &__all { + padding: 12rpx 24rpx; + margin-right: 16rpx; + border: 1rpx solid #dcdee0; + border-radius: 999rpx; + font-size: 26rpx; + color: #323233; + } + + &__btn { + padding: 14rpx 40rpx; + border-radius: 999rpx; + background: #ee0a24; + color: #fff; + font-size: 28rpx; + font-weight: 500; + + &.disabled { + opacity: 0.5; + } + } + } + + // ===== 分类选择弹层 ===== + .category-popup { + padding: 32rpx 32rpx 24rpx; + + &__title { + font-size: 32rpx; + font-weight: 600; + display: block; + text-align: center; + } + + &__desc { + margin-top: 8rpx; + font-size: 22rpx; + color: #969799; + display: block; + text-align: center; + } + + &__list { + max-height: 560rpx; + margin-top: 24rpx; + } + + &__item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24rpx 8rpx; + border-bottom: 1rpx solid #f2f3f5; + + &:last-child { + border-bottom: none; + } + } + + &__name { + font-size: 28rpx; + color: #323233; + } + } } diff --git a/src/pages/bill/index.tsx b/src/pages/bill/index.tsx index efbac89..4a7bcb9 100644 --- a/src/pages/bill/index.tsx +++ b/src/pages/bill/index.tsx @@ -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([]) + const [showCategory, setShowCategory] = useState(false) + const [categories, setCategories] = useState([]) + 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 ( - + {/* ========== 待支付汇总(门店口径,含审核中) ========== */} {loggedIn && summary && summary.unpaid_count > 0 && ( @@ -97,18 +193,25 @@ export default function BillListPage() { )} - {/* ========== 状态筛选 ========== */} - - {STATUS_FILTERS.map(item => ( - handleStatusTap(item.value)} - > - {item.label} + {/* ========== 状态筛选 + 导出入口 ========== */} + + + {STATUS_FILTERS.map(item => ( + handleStatusTap(item.value)} + > + {item.label} + + ))} + + {loggedIn && bills.length > 0 && ( + + {selectMode ? '取消' : '导出'} - ))} - + )} + {/* ========== 账单列表 ========== */} {!loggedIn ? ( @@ -122,36 +225,92 @@ export default function BillListPage() { ) ) : ( - bills.map(bill => ( - goDetail(bill.id)}> - - {bill.bill_no} - - {bill.pay_state_name} - - - - - 账单日期 {bill.bill_date} - {bill.purchase && ( - 采购单 {bill.purchase.purchase_no} - )} - - ¥{bill.total_amount} - - - 应结算 {bill.settlement_date} - {bill.pay_state === 2 && bill.paid_at && ( - 已于 {bill.paid_at} 支付 + bills.map(bill => { + const checked = selectMode && selectedIds.includes(bill.id) + return ( + handleItemTap(bill)} + > + {selectMode && ( + + {checked && } + )} + + + {bill.bill_no} + + {bill.pay_state_name} + + + + + 账单日期 {bill.bill_date} + {bill.purchase && ( + 采购单 {bill.purchase.purchase_no} + )} + + ¥{bill.total_amount} + + + 应结算 {bill.settlement_date} + {bill.pay_state === 2 && bill.paid_at && ( + 已于 {bill.paid_at} 支付 + )} + + - - )) + ) + }) )} {loggedIn && finished && bills.length > 0 && ( 没有更多了 )} + + {/* ========== 导出操作栏 ========== */} + {selectMode && ( + + 已选 {selectedIds.length} 张 + + {selectedIds.length === bills.length && bills.length > 0 ? '取消全选' : '全选'} + + + 导出 Excel + + + )} + + {/* ========== 分类选择弹层 ========== */} + setShowCategory(false)} + > + + 选择商品分类 + + 仅过滤商品明细行,配送费与附加金额仍全额汇总 + + + {categories.map(c => ( + handleExport(c.id)}> + {c.name} + + + ))} + + + ) } diff --git a/src/services/bill.ts b/src/services/bill.ts index 835b80a..b81e6a5 100644 --- a/src/services/bill.ts +++ b/src/services/bill.ts @@ -1,4 +1,4 @@ -import { get } from '@/utils/request' +import { BASE_URL, get } from '@/utils/request' import type { PaginatedData } from '@/types/api' /** 账单支付进度:0 待支付 / 1 审核中 / 2 已支付(由后端推导,展示以此为准) */ @@ -115,3 +115,11 @@ export function getBillListApi(params: BillListParams = {}) { export function getBillDetailApi(id: number) { return get(`/mini/bill/${id}`) } + +/** + * 账单合并导出文件地址:GET /mini/bill/export + * 返回 xlsx 文件流(非 JSON,需带 token 下载);categoryId 仅过滤商品明细,费用仍全额汇总 + */ +export function getBillExportUrl(ids: number[], categoryId = 0): string { + return `${BASE_URL}/mini/bill/export?ids=${ids.join(',')}&category_id=${categoryId}` +} diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000..9683d03 --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,110 @@ +import Taro from '@tarojs/taro' +import { getToken } from '@/utils/request' + +/** + * 导出文件下载(双端) + * 后端导出接口成功返回 xlsx 文件流,业务失败时仍以文件流形式返回 JSON + * (blob.type 为 application/json),两端都需探测并解析 msg 提示 + */ + +/** 从 Content-Disposition 解析文件名(优先 RFC 5987 filename*=utf-8''...) */ +export function parseExportFilename(disposition?: string | null): string { + if (!disposition) return '' + const star = disposition.match(/filename\*=utf-8''([^;]+)/i) + if (star?.[1]) { + try { + return decodeURIComponent(star[1]) + } catch { + // 编码异常时退化为普通 filename + } + } + const plain = disposition.match(/filename="?([^";]+)"?/i) + return plain?.[1] ?? '' +} + +/** 解析 JSON 文本中的业务错误信息 */ +function parseJsonError(text: string): Error { + try { + const json = JSON.parse(text) + return new Error(json.msg || '导出失败,请稍后重试') + } catch { + return new Error('导出失败,请稍后重试') + } +} + +/** H5:fetch 带鉴权拉取 blob → 触发保存 */ +async function downloadFileH5(url: string, fallbackName: string): Promise { + const token = getToken() + const res = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!res.ok) { + throw new Error(`下载失败(${res.status})`) + } + const blob = await res.blob() + + // 业务失败:返回的是 JSON blob + if (blob.type.includes('application/json')) { + throw parseJsonError(await blob.text()) + } + + const filename = + parseExportFilename(res.headers.get('Content-Disposition')) || fallbackName + const objectUrl = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = objectUrl + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(objectUrl) +} + +/** 读取小程序本地文件内容(utf8) */ +function readTempFile(filePath: string, length?: number): Promise { + return new Promise((resolve, reject) => { + Taro.getFileSystemManager().readFile({ + filePath, + encoding: 'utf8', + ...(length !== undefined ? { position: 0, length } : {}), + success: r => resolve(r.data as string), + fail: reject, + }) + }) +} + +/** 小程序:downloadFile 下载后 openDocument 打开;业务失败时下载"成功"但内容是 JSON */ +async function downloadFileWeapp(url: string): Promise { + const token = getToken() + const { tempFilePath } = await Taro.downloadFile({ + url, + header: token ? { Authorization: `Bearer ${token}` } : {}, + }) + + // 探测前 200 字节:以 '{' 开头即为 JSON 错误响应 + const head = await readTempFile(tempFilePath, 200) + if (head.trimStart().startsWith('{')) { + throw parseJsonError(await readTempFile(tempFilePath)) + } + + await Taro.openDocument({ + filePath: tempFilePath, + fileType: 'xlsx', + showMenu: true, + fail: () => { + Taro.showToast({ title: '文件已下载,打开失败', icon: 'none' }) + }, + } as Parameters[0]) +} + +/** + * 带鉴权下载导出文件 + * - H5:保存到浏览器下载目录,返回保存的文件名 + * - 小程序:直接调起系统文档预览(showMenu 支持转发/保存) + */ +export async function downloadExportFile(url: string, fallbackName: string): Promise { + if (process.env.TARO_ENV === 'h5') { + return downloadFileH5(url, fallbackName) + } + return downloadFileWeapp(url) +}