| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581 |
- <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>
|