Files
shanxia2.0/miniprogram/pages/bx/invoice/mine.vue
T
2026-09-12 19:42:20 +08:00

582 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="bx-page">
<view class="topbar" :style="safeAreaStyle">
<view class="topbar-left">
<text class="topbar-back" @click="goBack">←</text>
<text class="topbar-title">我的发票夹</text>
</view>
<view class="topbar-right" />
</view>
<view class="wallet-summary card">
<view class="wallet-summary__row">
<text>总额</text>
<text class="wallet-summary__val">¥{{ money(summary.totalAmount) }}</text>
</view>
<view class="wallet-summary__row">
<text>已报销占用</text>
<text class="wallet-summary__val">¥{{ money(summary.usedAmount) }}</text>
</view>
<view class="wallet-summary__row wallet-summary__row--em">
<text>余额</text>
<text class="wallet-summary__val">¥{{ money(summary.balance) }}</text>
</view>
<view class="wallet-summary__meta">发票 {{ summary.invoiceCount ?? 0 }} 张</view>
</view>
<view class="wallet-actions">
<button class="btn btn-primary btn-block" @click="openUpload">上传发票</button>
</view>
<view class="search-wrap" style="padding-top: 0">
<scroll-view scroll-x class="search-filter-scroll" :show-scrollbar="false">
<view class="search-filter">
<view
class="filter-tag"
:class="{ active: activeTab === 0 }"
@click="onTabChange(0)"
>
发票列表
</view>
<view
class="filter-tag"
:class="{ active: activeTab === 1 }"
@click="onTabChange(1)"
>
额度流水
</view>
</view>
</scroll-view>
</view>
<scroll-view
v-if="activeTab === 0"
class="page-padded list-scroll wallet-list"
scroll-y
:style="{ height: listHeight }"
refresher-enabled
:refresher-triggered="invRefreshing"
@refresherrefresh="onInvoicePullRefresh"
@scrolltolower="onInvoiceLoadMore"
>
<view v-if="!invoices.length && !invLoading" class="empty-state empty-state--embed">
<text>暂无发票</text>
</view>
<view
v-for="(inv, idx) in invoices"
:key="inv.id"
class="expense-card invoice-wallet-card"
>
<text class="invoice-wallet-card__index">{{ idx + 1 }}</text>
<view class="ec-amount-label">金额</view>
<view class="ec-amount">{{ money(inv.amount) }}</view>
<view class="invoice-wallet-card__subs">
<view class="invoice-wallet-card__sub">
<text class="k">备注</text>
<text class="v">{{ inv.remark || '-' }}</text>
</view>
<view class="invoice-wallet-card__sub">
<text class="k">创建时间</text>
<text class="v">{{ inv.createTime || '-' }}</text>
</view>
</view>
<view class="invoice-wallet-card__att-label">附件</view>
<view v-if="invoiceFiles(inv).length" class="att-grid advance-att-grid">
<view
v-for="file in invoiceFiles(inv)"
:key="file.id"
class="att-img advance-att-tile"
@click.stop="openAttachment(file)"
>
<image
v-if="isImage(file.fileType) && file.previewUrl"
:src="file.previewUrl"
mode="aspectFill"
/>
<view
v-else-if="isImage(file.fileType) && file.previewLoading"
class="att-preview-loading"
>
<text>...</text>
</view>
<view v-else class="att-file-icon att-doc-thumb">
<text class="att-doc-thumb__ext">{{ fileExt(file.fileName) }}</text>
<text class="att-doc-thumb__name">{{ file.fileName }}</text>
</view>
</view>
</view>
<text v-else class="invoice-wallet-card__att-empty">暂无附件</text>
</view>
<view v-if="invoices.length" class="list-footer-tip">
{{ invFinished ? '没有更多了' : invLoading ? '加载中...' : '上拉加载更多' }}
</view>
</scroll-view>
<scroll-view
v-else
class="page-padded list-scroll wallet-list"
scroll-y
:style="{ height: listHeight }"
refresher-enabled
:refresher-triggered="ledRefreshing"
@refresherrefresh="onLedgerPullRefresh"
@scrolltolower="onLedgerLoadMore"
>
<view v-if="!ledgers.length && !ledLoading" class="empty-state empty-state--embed">
<text>暂无流水</text>
</view>
<view
v-for="(row, idx) in ledgers"
:key="row.id || idx"
class="expense-card invoice-wallet-card"
>
<text class="invoice-wallet-card__index">{{ idx + 1 }}</text>
<view class="ec-top">
<text class="ec-type">{{ entryTypeLabel(row.entryType || row.entry_type) }}</text>
</view>
<view class="ec-amount-label">金额</view>
<view class="ec-amount">{{ money(row.amount) }}</view>
<view class="invoice-wallet-card__subs">
<view class="invoice-wallet-card__sub">
<text class="k">备注</text>
<text class="v">{{ ledgerRemark(row) }}</text>
</view>
<view class="invoice-wallet-card__sub">
<text class="k">创建时间</text>
<text class="v">{{ row.createTime || row.create_time || '-' }}</text>
</view>
</view>
</view>
<view v-if="ledgers.length" class="list-footer-tip">
{{ ledFinished ? '没有更多了' : ledLoading ? '加载中...' : '上拉加载更多' }}
</view>
</scroll-view>
<!-- 上传发票 -->
<view v-if="showUpload" class="bx-sheet-mask" @click="closeUpload">
<view class="bx-sheet bx-upload-sheet" @click.stop>
<view class="bx-sheet__title">上传发票</view>
<view class="bx-sheet__body dialog-form-body">
<view class="form-item">
<text class="form-label">金额</text>
<view class="form-input-prefix">
<input
class="form-input"
type="digit"
v-model="uploadAmount"
placeholder="票面入账金额"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<input
class="form-input"
v-model="uploadRemark"
placeholder="可选"
/>
</view>
<view class="form-item">
<text class="form-label">发票图片</text>
<text class="form-hint">仅支持图片;PDF 请用管理端。多张时仅上传首张。</text>
<view class="att-grid">
<view
v-for="(path, i) in uploadPaths"
:key="path + i"
class="att-img"
>
<image :src="path" mode="aspectFill" />
<view class="del" @click="removeUploadPath(i)">×</view>
</view>
<view
v-if="uploadPaths.length < 5"
class="att-img att-add"
@click="chooseUploadImages"
>
<text>+</text>
</view>
</view>
</view>
</view>
<view class="bx-sheet__footer" style="display: flex; gap: 10px">
<button class="btn btn-block" style="flex: 1" @click="closeUpload">取消</button>
<button
class="btn btn-primary btn-block"
style="flex: 1"
:disabled="uploading"
@click="confirmUpload"
>
{{ uploading ? '上传中...' : '确定' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { attachmentApi, invoiceApi, invoiceWalletApi } from '@/api/bx'
import { getSafeAreaStyle } from '@/utils/safeArea'
const safeAreaStyle = getSafeAreaStyle()
const PAGE_SIZE = 5
const activeTab = ref(0)
const listHeight = ref('50vh')
const summary = reactive({
totalAmount: 0,
usedAmount: 0,
balance: 0,
invoiceCount: 0
})
const invoices = ref([])
const invLoading = ref(false)
const invRefreshing = ref(false)
const invFinished = ref(false)
const invPage = ref(1)
const ledgers = ref([])
const allLedgers = ref([])
const ledLoading = ref(false)
const ledRefreshing = ref(false)
const ledFinished = ref(false)
const ledPage = ref(1)
const ledgersFetched = ref(false)
const showUpload = ref(false)
const uploadAmount = ref('')
const uploadRemark = ref('')
const uploadPaths = ref([])
const uploading = ref(false)
function goBack() {
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/workbench/workbench' }) })
}
function money(v) {
return Number(v || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
function entryTypeLabel(t) {
const map = {
credit_upload: '上传入账',
debit_delete: '删除冲减',
debit_reimburse: '报销占用',
credit_release: '驳回/撤回退回'
}
return map[t] || t || '-'
}
function ledgerRemark(row) {
const text = (row?.remark ?? '').toString().trim()
return text || '-'
}
function pagedRows(res) {
const d = res?.data
if (!d) return { rows: [], total: 0 }
if (Array.isArray(d)) return { rows: d, total: d.length }
const rows = d.rows ?? d.items ?? d.list ?? []
const total = typeof d.total === 'number' ? d.total : rows.length
return { rows, total }
}
function isImage(fileType) {
return !!(fileType && String(fileType).startsWith('image/'))
}
function fileExt(name) {
const ext = name?.split('.').pop()
return ext ? ext.toUpperCase() : 'FILE'
}
function invoiceFiles(inv) {
return inv?._files || []
}
function normalizeInvoiceFiles(inv) {
const raw = Array.isArray(inv?.attachments) ? inv.attachments : []
if (raw.length) {
return raw.map((a) => ({
id: String(a.id),
fileName: a.fileName || a.file_name || '附件',
fileType: a.fileType || a.file_type || '',
previewUrl: '',
previewLoading: false
}))
}
if (inv?.attachmentId || inv?.attachment_id) {
return [
{
id: String(inv.attachmentId || inv.attachment_id),
fileName: inv.fileName || inv.file_name || '附件',
fileType: inv.fileType || inv.file_type || '',
previewUrl: '',
previewLoading: false
}
]
}
return []
}
function kickInvoicePreviewLoads(rows) {
for (const inv of rows) {
const files = invoiceFiles(inv)
for (const f of files) {
if (!isImage(f.fileType) || f.previewUrl) continue
f.previewLoading = true
attachmentApi
.preview(f.id)
.then((pRes) => {
f.previewUrl = pRes?.data?.url || ''
})
.catch(() => {})
.finally(() => {
f.previewLoading = false
})
}
}
}
async function openAttachment(file) {
if (isImage(file.fileType)) {
let url = file.previewUrl
if (!url) {
try {
const res = await attachmentApi.preview(file.id)
url = res?.data?.url ?? ''
if (url) file.previewUrl = url
} catch (e) {
uni.showToast({ title: '暂无法预览', icon: 'none' })
return
}
}
if (url) {
uni.previewImage({ urls: [url], current: url })
} else {
uni.showToast({ title: '暂无法预览', icon: 'none' })
}
return
}
uni.showToast({ title: '非图片附件请在管理端查看', icon: 'none' })
}
function computeListHeight() {
try {
const sys = uni.getSystemInfoSync()
const h = (sys.windowHeight || 600) - 320
listHeight.value = `${Math.max(h, 280)}px`
} catch (e) {
listHeight.value = '50vh'
}
}
async function loadSummary() {
try {
const sumRes = await invoiceWalletApi.mineSummary()
Object.assign(summary, sumRes?.data || {})
} catch (e) {
/* summary 失败不阻断列表 */
}
}
async function fetchInvoices(isRefresh) {
try {
const res = await invoiceApi.list({ page: invPage.value, pageSize: PAGE_SIZE })
const { rows, total } = pagedRows(res)
for (const inv of rows) {
inv._files = normalizeInvoiceFiles(inv)
}
const start = isRefresh ? 0 : invoices.value.length
if (isRefresh) invoices.value = rows
else invoices.value.push(...rows)
kickInvoicePreviewLoads(invoices.value.slice(start, start + rows.length))
if (invoices.value.length >= total || rows.length < PAGE_SIZE) {
invFinished.value = true
} else {
invFinished.value = false
}
} catch (e) {
invFinished.value = true
}
}
async function onInvoiceLoadMore() {
if (invFinished.value || invLoading.value) return
invLoading.value = true
await fetchInvoices(false)
invLoading.value = false
invPage.value++
}
async function onInvoiceRefresh() {
invPage.value = 1
invFinished.value = false
await Promise.all([loadSummary(), fetchInvoices(true)])
invPage.value = 2
}
async function onInvoicePullRefresh() {
invRefreshing.value = true
await onInvoiceRefresh()
invRefreshing.value = false
}
async function ensureLedgersLoaded() {
if (ledgersFetched.value) return
try {
const ledRes = await invoiceApi.ledgers()
const data = ledRes?.data
allLedgers.value = Array.isArray(data) ? data : pagedRows(ledRes).rows
ledgersFetched.value = true
} catch (e) {
allLedgers.value = []
ledgersFetched.value = true
}
}
async function fetchLedgersPage(isRefresh) {
await ensureLedgersLoaded()
const start = (ledPage.value - 1) * PAGE_SIZE
const slice = allLedgers.value.slice(start, start + PAGE_SIZE)
if (isRefresh) ledgers.value = slice
else ledgers.value.push(...slice)
if (ledgers.value.length >= allLedgers.value.length || slice.length < PAGE_SIZE) {
ledFinished.value = true
} else {
ledFinished.value = false
}
}
async function onLedgerLoadMore() {
if (ledFinished.value || ledLoading.value) return
ledLoading.value = true
await fetchLedgersPage(false)
ledLoading.value = false
ledPage.value++
}
async function onLedgerRefresh() {
ledgersFetched.value = false
allLedgers.value = []
ledPage.value = 1
ledFinished.value = false
await Promise.all([loadSummary(), fetchLedgersPage(true)])
ledPage.value = 2
}
async function onLedgerPullRefresh() {
ledRefreshing.value = true
await onLedgerRefresh()
ledRefreshing.value = false
}
function onTabChange(tab) {
if (activeTab.value === tab) return
activeTab.value = tab
if (tab === 0 && !invoices.value.length && !invFinished.value) {
invLoading.value = true
void (async () => {
await fetchInvoices(true)
invLoading.value = false
invPage.value = 2
})()
}
if (tab === 1 && !ledgersFetched.value) {
ledLoading.value = true
void (async () => {
await fetchLedgersPage(true)
ledLoading.value = false
ledPage.value = 2
})()
}
}
function openUpload() {
uploadAmount.value = ''
uploadRemark.value = ''
uploadPaths.value = []
showUpload.value = true
}
function closeUpload() {
if (uploading.value) return
showUpload.value = false
}
function chooseUploadImages() {
const remain = 5 - uploadPaths.value.length
if (remain <= 0) return
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const paths = res.tempFilePaths || []
uploadPaths.value = uploadPaths.value.concat(paths).slice(0, 5)
}
})
}
function removeUploadPath(i) {
uploadPaths.value.splice(i, 1)
}
async function confirmUpload() {
const amount = Number(uploadAmount.value)
if (!(amount > 0)) {
uni.showToast({ title: '请输入有效金额', icon: 'none' })
return
}
if (!uploadPaths.value.length) {
uni.showToast({ title: '请选择发票文件', icon: 'none' })
return
}
if (uploading.value) return
if (uploadPaths.value.length > 1) {
uni.showToast({ title: '小程序仅上传首张图片', icon: 'none' })
}
uploading.value = true
try {
await invoiceApi.upload({
amount,
remark: uploadRemark.value.trim(),
filePath: uploadPaths.value[0]
})
uni.showToast({ title: '上传成功', icon: 'success' })
showUpload.value = false
uploadAmount.value = ''
uploadRemark.value = ''
uploadPaths.value = []
activeTab.value = 0
await onInvoiceRefresh()
ledgersFetched.value = false
} catch (e) {
// toast in bxUploadInvoice
} finally {
uploading.value = false
}
}
onMounted(() => {
computeListHeight()
void loadSummary()
invLoading.value = true
void (async () => {
await fetchInvoices(true)
invLoading.value = false
invPage.value = 2
})()
})
</script>
<style lang="scss" scoped>
@import '@/styles/bx-theme.scss';
</style>