582 lines
17 KiB
Vue
582 lines
17 KiB
Vue
<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>
|