商品列表状态保存

This commit is contained in:
2026-11-01 16:02:18 +08:00
parent f0375cdb50
commit 854d98c997
5 changed files with 315 additions and 18 deletions
+142
View File
@@ -64,6 +64,18 @@
} }
} }
.batch-entry {
flex-shrink: 0;
margin-left: 16rpx;
padding: 12rpx 0 12rpx 20rpx;
color: #ee0a24;
font-size: 26rpx;
&.active {
color: #646566;
}
}
.status-scroll { .status-scroll {
white-space: nowrap; white-space: nowrap;
margin-bottom: 20rpx; margin-bottom: 20rpx;
@@ -106,11 +118,36 @@
// ===== 账单单项 ===== // ===== 账单单项 =====
.bill-item { .bill-item {
display: flex;
align-items: center;
background: #fff; background: #fff;
border-radius: 16rpx; border-radius: 16rpx;
padding: 24rpx; padding: 24rpx;
margin-bottom: 16rpx; margin-bottom: 16rpx;
&--selected {
background: #fff8f8;
box-shadow: inset 0 0 0 2rpx #ee0a24;
}
&__checkbox {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36rpx;
height: 36rpx;
margin-right: 20rpx;
border: 2rpx solid #c8c9cc;
border-radius: 50%;
box-sizing: border-box;
&.checked {
border-color: #ee0a24;
background: #ee0a24;
}
}
&__content { &__content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -204,6 +241,53 @@
} }
} }
// ===== 批量导出操作栏 =====
.bill-batchbar {
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);
&__select-all {
display: flex;
align-items: center;
flex-shrink: 0;
color: #323233;
font-size: 26rpx;
.bill-item__checkbox {
margin-right: 12rpx;
}
}
&__count {
flex: 1;
margin: 0 24rpx;
color: #969799;
font-size: 24rpx;
text-align: right;
}
&__button {
flex-shrink: 0;
padding: 16rpx 52rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
&.disabled {
opacity: 0.5;
}
}
}
// ===== 分类选择弹层 ===== // ===== 分类选择弹层 =====
.category-popup { .category-popup {
padding: 32rpx 32rpx 24rpx; padding: 32rpx 32rpx 24rpx;
@@ -245,4 +329,62 @@
color: #323233; color: #323233;
} }
} }
// ===== 微信 H5 下载指引 =====
.wechat-guide {
padding: 36rpx 32rpx 32rpx;
&__title {
display: block;
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #323233;
}
&__desc {
display: block;
margin: 24rpx 0;
font-size: 26rpx;
line-height: 1.6;
color: #646566;
}
&__step {
display: flex;
align-items: flex-start;
margin-top: 18rpx;
}
&__number {
flex-shrink: 0;
width: 36rpx;
height: 36rpx;
margin-top: 3rpx;
border-radius: 50%;
background: #fdecee;
color: #ee0a24;
font-size: 22rpx;
line-height: 36rpx;
text-align: center;
}
&__text {
flex: 1;
margin-left: 16rpx;
font-size: 26rpx;
line-height: 1.6;
color: #323233;
}
&__button {
margin-top: 32rpx;
padding: 20rpx 0;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
text-align: center;
}
}
} }
+156 -15
View File
@@ -3,13 +3,14 @@ import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components' import { View, Text, ScrollView } from '@tarojs/components'
import { Empty, Icon, Popup } from '@antmjs/vantui' import { Empty, Icon, Popup } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore' import useAuthStore from '@/stores/auth/useAuthStore'
import { getBillExportUrl, getBillListApi } from '@/services/bill' import { getBillBatchExportUrl, getBillExportUrl, getBillListApi } from '@/services/bill'
import { getCategoriesApi } from '@/services/product' import { getCategoriesApi } from '@/services/product'
import { downloadExportFile } from '@/utils/download' import { downloadExportFile, isWeChatH5 } from '@/utils/download'
import type { Bill, BillSummary } from '@/services/bill' import type { Bill, BillSummary } from '@/services/bill'
import './index.less' import './index.less'
const PAGE_SIZE = 10 const PAGE_SIZE = 10
const MAX_BATCH_EXPORT = 100
/** 状态筛选(接口 status 为原始支付状态:0 未支付含审核中 / 1 已支付) */ /** 状态筛选(接口 status 为原始支付状态:0 未支付含审核中 / 1 已支付) */
const STATUS_FILTERS: Array<{ value: 0 | 1 | undefined; label: string }> = [ const STATUS_FILTERS: Array<{ value: 0 | 1 | undefined; label: string }> = [
@@ -44,6 +45,11 @@ export default function BillListPage() {
const [exportBill, setExportBill] = useState<Bill | null>(null) const [exportBill, setExportBill] = useState<Bill | null>(null)
const [showCategory, setShowCategory] = useState(false) const [showCategory, setShowCategory] = useState(false)
const [categories, setCategories] = useState<CategoryOption[]>([]) const [categories, setCategories] = useState<CategoryOption[]>([])
const [showWeChatGuide, setShowWeChatGuide] = useState(false)
/** 批量导出:选择模式与已选账单 ID */
const [batchSelecting, setBatchSelecting] = useState(false)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const loggedIn = !!token const loggedIn = !!token
@@ -85,6 +91,7 @@ export default function BillListPage() {
(value?: 0 | 1) => { (value?: 0 | 1) => {
setStatus(value) setStatus(value)
setFinished(false) setFinished(false)
setSelectedIds([])
loadList(1, true, value) loadList(1, true, value)
}, },
[loadList], [loadList],
@@ -132,7 +139,10 @@ export default function BillListPage() {
try { try {
await downloadExportFile(getBillExportUrl(exportBill.id, categoryId), `账单-${exportBill.bill_no}.xlsx`) await downloadExportFile(getBillExportUrl(exportBill.id, categoryId), `账单-${exportBill.bill_no}.xlsx`)
setShowCategory(false) setShowCategory(false)
if (isH5) { if (isWeChatH5()) {
// 微信可能静默忽略 a[download],不能据此提示“导出成功”
setShowWeChatGuide(true)
} else if (isH5) {
Taro.showToast({ title: '导出成功', icon: 'success' }) Taro.showToast({ title: '导出成功', icon: 'success' })
} }
} catch (e: any) { } catch (e: any) {
@@ -144,11 +154,74 @@ export default function BillListPage() {
[exportBill], [exportBill],
) )
/** 进入/退出批量选择模式 */
const toggleBatchSelecting = useCallback(() => {
setBatchSelecting(prev => !prev)
setSelectedIds([])
}, [])
/** 选择或取消选择一张账单 */
const toggleBillSelection = useCallback(
(id: number) => {
if (selectedIds.includes(id)) {
setSelectedIds(selectedIds.filter(selectedId => selectedId !== id))
return
}
if (selectedIds.length >= MAX_BATCH_EXPORT) {
Taro.showToast({ title: '一次最多导出100张账单', icon: 'none' })
return
}
setSelectedIds([...selectedIds, id])
},
[selectedIds],
)
const allSelected = bills.length > 0
&& selectedIds.length === Math.min(bills.length, MAX_BATCH_EXPORT)
&& bills.slice(0, MAX_BATCH_EXPORT).every(bill => selectedIds.includes(bill.id))
/** 全选当前已加载账单(超过 100 张时选择前 100 张) */
const handleSelectAll = useCallback(() => {
if (allSelected) {
setSelectedIds([])
return
}
const ids = bills.slice(0, MAX_BATCH_EXPORT).map(bill => bill.id)
setSelectedIds(ids)
if (bills.length > MAX_BATCH_EXPORT) {
Taro.showToast({ title: '已选择前100张账单', icon: 'none' })
}
}, [allSelected, bills])
/** 批量导出所选账单 */
const handleBatchExport = useCallback(async () => {
if (selectedIds.length === 0) {
Taro.showToast({ title: '请先选择账单', icon: 'none' })
return
}
const isH5 = process.env.TARO_ENV === 'h5'
Taro.showLoading({ title: '导出中...', mask: true })
try {
await downloadExportFile(getBillBatchExportUrl(selectedIds), '门店账单_批量导出.xlsx')
setBatchSelecting(false)
setSelectedIds([])
if (isWeChatH5()) {
setShowWeChatGuide(true)
} else if (isH5) {
Taro.showToast({ title: '导出成功', icon: 'success' })
}
} catch (e: any) {
Taro.showToast({ title: e?.message || '导出失败,请稍后重试', icon: 'none' })
} finally {
Taro.hideLoading()
}
}, [selectedIds])
/** 底部待支付汇总栏是否可见 */ /** 底部待支付汇总栏是否可见 */
const showPayBar = loggedIn && !!summary && summary.unpaid_count > 0 const showPayBar = loggedIn && !!summary && summary.unpaid_count > 0 && !batchSelecting
return ( return (
<View className={`bill-page ${showPayBar ? 'bill-page--pay' : ''}`}> <View className={`bill-page ${showPayBar || batchSelecting ? 'bill-page--pay' : ''}`}>
{/* ========== 状态筛选 ========== */} {/* ========== 状态筛选 ========== */}
<View className='bill-toolbar'> <View className='bill-toolbar'>
<ScrollView scrollX className='status-scroll'> <ScrollView scrollX className='status-scroll'>
@@ -162,6 +235,11 @@ export default function BillListPage() {
</View> </View>
))} ))}
</ScrollView> </ScrollView>
{loggedIn && bills.length > 0 && (
<View className={`batch-entry ${batchSelecting ? 'active' : ''}`} onClick={toggleBatchSelecting}>
{batchSelecting ? '取消' : '批量导出'}
</View>
)}
</View> </View>
{/* ========== 账单列表 ========== */} {/* ========== 账单列表 ========== */}
@@ -177,7 +255,16 @@ export default function BillListPage() {
) )
) : ( ) : (
bills.map(bill => ( bills.map(bill => (
<View key={bill.id} className='bill-item' onClick={() => goDetail(bill.id)}> <View
key={bill.id}
className={`bill-item ${batchSelecting && selectedIds.includes(bill.id) ? 'bill-item--selected' : ''}`}
onClick={() => batchSelecting ? toggleBillSelection(bill.id) : goDetail(bill.id)}
>
{batchSelecting && (
<View className={`bill-item__checkbox ${selectedIds.includes(bill.id) ? 'checked' : ''}`}>
{selectedIds.includes(bill.id) && <Icon name='success' size={14} color='#fff' />}
</View>
)}
<View className='bill-item__content'> <View className='bill-item__content'>
<View className='bill-item__header'> <View className='bill-item__header'>
<Text className='bill-item__no'>{bill.bill_no}</Text> <Text className='bill-item__no'>{bill.bill_no}</Text>
@@ -194,15 +281,17 @@ export default function BillListPage() {
</View> </View>
<View className='bill-item__side'> <View className='bill-item__side'>
<Text className='bill-item__amount'>¥{bill.total_amount}</Text> <Text className='bill-item__amount'>¥{bill.total_amount}</Text>
<View {!batchSelecting && (
className='bill-item__export' <View
onClick={e => { className='bill-item__export'
e.stopPropagation() onClick={e => {
handleExportTap(bill) e.stopPropagation()
}} handleExportTap(bill)
> }}
导出 >
</View> 导出
</View>
)}
</View> </View>
</View> </View>
<View className='bill-item__footer'> <View className='bill-item__footer'>
@@ -231,6 +320,25 @@ export default function BillListPage() {
</View> </View>
)} )}
{/* ========== 批量导出操作栏 ========== */}
{batchSelecting && (
<View className='bill-batchbar'>
<View className='bill-batchbar__select-all' onClick={handleSelectAll}>
<View className={`bill-item__checkbox ${allSelected ? 'checked' : ''}`}>
{allSelected && <Icon name='success' size={14} color='#fff' />}
</View>
<Text>{allSelected ? '清空' : '全选'}</Text>
</View>
<Text className='bill-batchbar__count'>已选 {selectedIds.length}/100</Text>
<View
className={`bill-batchbar__button ${selectedIds.length === 0 ? 'disabled' : ''}`}
onClick={handleBatchExport}
>
导出
</View>
</View>
)}
{/* ========== 分类选择弹层 ========== */} {/* ========== 分类选择弹层 ========== */}
<Popup <Popup
show={showCategory} show={showCategory}
@@ -256,6 +364,39 @@ export default function BillListPage() {
</ScrollView> </ScrollView>
</View> </View>
</Popup> </Popup>
{/* ========== 微信 H5 下载兜底指引 ========== */}
<Popup
show={showWeChatGuide}
position='bottom'
round
closeable
closeOnClickOverlay
safeAreaInsetBottom
onClose={() => setShowWeChatGuide(false)}
>
<View className='wechat-guide'>
<Text className='wechat-guide__title'>账单下载提示</Text>
<Text className='wechat-guide__desc'>
已尝试开始下载。若文件未下载或无法打开,请使用系统浏览器重新导出:
</Text>
<View className='wechat-guide__step'>
<Text className='wechat-guide__number'>1</Text>
<Text className='wechat-guide__text'>点击本页右上角的「···」</Text>
</View>
<View className='wechat-guide__step'>
<Text className='wechat-guide__number'>2</Text>
<Text className='wechat-guide__text'>选择「在浏览器打开」(iPhone 可选择 Safari)</Text>
</View>
<View className='wechat-guide__step'>
<Text className='wechat-guide__number'>3</Text>
<Text className='wechat-guide__text'>在浏览器中登录后,重新导出账单</Text>
</View>
<View className='wechat-guide__button' onClick={() => setShowWeChatGuide(false)}>
我知道了
</View>
</View>
</Popup>
</View> </View>
) )
} }
+6
View File
@@ -129,3 +129,9 @@ export function getBillDetailApi(id: number) {
export function getBillExportUrl(id: number, categoryId = 0): string { export function getBillExportUrl(id: number, categoryId = 0): string {
return `${BASE_URL}/mini/bill/${id}/export?category_id=${categoryId}` return `${BASE_URL}/mini/bill/${id}/export?category_id=${categoryId}`
} }
/** 批量导出账单文件地址:GET /mini/bill/batch-export,最多 100 张 */
export function getBillBatchExportUrl(ids: number[]): string {
const query = ids.map(id => `ids[]=${id}`).join('&')
return `${BASE_URL}/mini/bill/batch-export?${query}`
}
+9 -1
View File
@@ -32,6 +32,13 @@ function parseJsonError(text: string): Error {
} }
} }
/** 是否为微信内置浏览器(小程序 web-view 的 UA 同样包含 MicroMessenger) */
export function isWeChatH5(): boolean {
return process.env.TARO_ENV === 'h5'
&& typeof navigator !== 'undefined'
&& /MicroMessenger/i.test(navigator.userAgent)
}
/** H5:fetch 带鉴权拉取 blob → <a download> 触发保存 */ /** H5:fetch 带鉴权拉取 blob → <a download> 触发保存 */
async function downloadFileH5(url: string, fallbackName: string): Promise<void> { async function downloadFileH5(url: string, fallbackName: string): Promise<void> {
const token = getToken() const token = getToken()
@@ -57,7 +64,8 @@ async function downloadFileH5(url: string, fallbackName: string): Promise<void>
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
URL.revokeObjectURL(objectUrl) // 立即回收会让部分微信 WebView 尚未读取 Blob 就失去下载地址
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} }
/** 读取小程序本地文件内容(utf8) */ /** 读取小程序本地文件内容(utf8) */
+2 -2
View File
@@ -10,8 +10,8 @@ const LOGIN_PATH = '/pages/login/index'
/** 默认请求超时(ms) */ /** 默认请求超时(ms) */
const DEFAULT_TIMEOUT = 15000 const DEFAULT_TIMEOUT = 15000
/** 接口根地址(uploadFile 等原生请求同样使用) */ /** 接口根地址(uploadFile 等原生请求同样使用) */
export const BASE_URL = "http://localhost:8000" // export const BASE_URL = "http://localhost:8000"
// export const BASE_URL = "https://purchase.henanklkj.com/index.php" export const BASE_URL = "https://purchase.henanklkj.com/index.php"
/** /**
* HTTP 状态码 → 错误提示映射 * HTTP 状态码 → 错误提示映射