feat(miniprogram): migrate invoice wallet mine and admin from bx-mobile.

Reuse invoice-wallets/invoices APIs with summary, upload, ledgers, and admin proxy upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-11 23:37:40 +08:00
1 parent 4c24f58b63
commit 1bd0503409
5 files changed
+1491 -7

No files matched your search

+11 -3
View File
@@ -1,4 +1,4 @@
import { bxRequest, bxUploadFile } from '@/utils/bxRequest'
import { bxRequest, bxUploadFile, bxUploadInvoice } from '@/utils/bxRequest'
export const advanceApi = {
list: (params) => bxRequest({ url: '/advances/', method: 'GET', params }),
@@ -34,11 +34,19 @@ export const expenseItemApi = {
export const invoiceWalletApi = {
mineSummary: (params) =>
bxRequest({ url: '/invoice-wallets/mine/summary', method: 'GET', params })
bxRequest({ url: '/invoice-wallets/mine/summary', method: 'GET', params }),
list: (params) => bxRequest({ url: '/invoice-wallets', method: 'GET', params })
}
export const invoiceApi = {
list: (params) => bxRequest({ url: '/invoices', method: 'GET', params })
list: (params) => bxRequest({ url: '/invoices', method: 'GET', params }),
/**
* 上传发票(multipart)。uni-app 用 uni.uploadFile,单次一张图。
* @param {{ amount: number|string, remark?: string, filePath: string, targetUserId?: string|number }} opts
*/
upload: (opts) => bxUploadInvoice(opts),
delete: (id) => bxRequest({ url: `/invoices/${id}`, method: 'DELETE' }),
ledgers: (params) => bxRequest({ url: '/invoice-ledgers', method: 'GET', params })
}
/** 附件:申请页图片上传所需最小集合 */
+607 -2
View File
@@ -1,7 +1,612 @@
<template>
<bx-placeholder-page default-title="发票夹管理" />
<view class="bx-page">
<view class="topbar">
<view class="topbar-left">
<view class="topbar-back" @click="onBack">
<text class="topbar-back-icon">‹</text>
</view>
</view>
<text class="topbar-title">{{
detailUser ? detailUser.ownerUserName || '发票夹详情' : '发票夹管理'
}}</text>
<view class="topbar-right" />
</view>
<!-- 员工额度列表 -->
<template v-if="!detailUser">
<view class="search-wrap">
<view class="search-box">
<text class="search-box__icon">⌕</text>
<input
class="search-input"
v-model="keyword"
placeholder="搜索员工姓名"
confirm-type="search"
@confirm="flushSearch"
/>
<view v-if="keyword.trim()" class="search-box__clear" @click="clearKeyword">×</view>
</view>
</view>
<scroll-view
class="page-padded list-scroll wallet-list"
scroll-y
:style="{ height: listHeight }"
refresher-enabled
:refresher-triggered="walletRefreshing"
@refresherrefresh="onWalletPullRefresh"
@scrolltolower="onWalletLoadMore"
>
<view v-if="!wallets.length && !walletLoading" class="empty-state empty-state--embed">
<text>暂无数据</text>
</view>
<view
v-for="(row, idx) in wallets"
:key="row.ownerUserId"
class="expense-card invoice-wallet-card"
@click="openDetail(row)"
>
<text class="invoice-wallet-card__index">{{ idx + 1 }}</text>
<view class="ec-top">
<text class="ec-type">{{ row.ownerUserName || row.ownerUserId }}</text>
</view>
<view class="ec-amount-label">余额</view>
<view class="ec-amount">{{ money(row.balance) }}</view>
<view class="invoice-wallet-card__subs">
<view class="invoice-wallet-card__sub">
<text class="k">总额</text>
<text class="v">¥{{ money(row.totalAmount) }}</text>
</view>
<view class="invoice-wallet-card__sub">
<text class="k">已用</text>
<text class="v">¥{{ money(row.usedAmount) }}</text>
</view>
<view class="invoice-wallet-card__sub">
<text class="k">发票</text>
<text class="v">{{ row.invoiceCount ?? 0 }} 张</text>
</view>
</view>
</view>
<view v-if="wallets.length" class="list-footer-tip">
{{ walletFinished ? '没有更多了' : walletLoading ? '加载中...' : '上拉加载更多' }}
</view>
</scroll-view>
</template>
<!-- 某人详情 -->
<template v-else>
<view class="wallet-summary card">
<view class="wallet-summary__row">
<text>总额</text>
<text class="wallet-summary__val">¥{{ money(detailSummary.totalAmount) }}</text>
</view>
<view class="wallet-summary__row">
<text>已用</text>
<text class="wallet-summary__val">¥{{ money(detailSummary.usedAmount) }}</text>
</view>
<view class="wallet-summary__row wallet-summary__row--em">
<text>余额</text>
<text class="wallet-summary__val">¥{{ money(detailSummary.balance) }}</text>
</view>
<view class="wallet-summary__meta">发票 {{ detailSummary.invoiceCount ?? 0 }} 张</view>
</view>
<view class="wallet-actions">
<button class="btn btn-primary btn-block" @click="openUpload">代传发票</button>
</view>
<scroll-view
class="page-padded list-scroll wallet-list"
scroll-y
:style="{ height: detailListHeight }"
refresher-enabled
:refresher-triggered="invRefreshing"
@refresherrefresh="onInvoicePullRefresh"
@scrolltolower="onInvoiceLoadMore"
>
<view
v-if="!detailInvoices.length && !invLoading"
class="empty-state empty-state--embed"
>
<text>暂无发票</text>
</view>
<view
v-for="(inv, idx) in detailInvoices"
:key="inv.id"
class="expense-card invoice-wallet-card invoice-wallet-card--static"
>
<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 class="ec-bottom invoice-admin-card__bottom" @click.stop>
<view class="op-actions">
<button class="btn btn-sm btn-danger" @click="onDelete(inv)">删除发票</button>
</view>
</view>
</view>
<view v-if="detailInvoices.length" class="list-footer-tip">
{{ invFinished ? '没有更多了' : invLoading ? '加载中...' : '上拉加载更多' }}
</view>
</scroll-view>
</template>
<!-- 代传发票 -->
<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 BxPlaceholderPage from '@/components/bx-ui/bx-placeholder-page.vue'
import { reactive, ref, onMounted } from 'vue'
import { attachmentApi, invoiceApi, invoiceWalletApi } from '@/api/bx'
const PAGE_SIZE = 5
const listHeight = ref('60vh')
const detailListHeight = ref('50vh')
const keyword = ref('')
const wallets = ref([])
const walletLoading = ref(false)
const walletRefreshing = ref(false)
const walletFinished = ref(false)
const walletPage = ref(1)
const detailUser = ref(null)
const detailSummary = reactive({
totalAmount: 0,
usedAmount: 0,
balance: 0,
invoiceCount: 0
})
const detailInvoices = ref([])
const invLoading = ref(false)
const invRefreshing = ref(false)
const invFinished = ref(false)
const invPage = ref(1)
const showUpload = ref(false)
const uploadAmount = ref('')
const uploadRemark = ref('')
const uploadPaths = ref([])
const uploading = ref(false)
function money(v) {
return Number(v || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
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 computeHeights() {
try {
const sys = uni.getSystemInfoSync()
const wh = sys.windowHeight || 600
listHeight.value = `${Math.max(wh - 160, 320)}px`
detailListHeight.value = `${Math.max(wh - 300, 280)}px`
} catch (e) {
listHeight.value = '60vh'
detailListHeight.value = '50vh'
}
}
async function fetchWallets(isRefresh) {
try {
const res = await invoiceWalletApi.list({
page: walletPage.value,
pageSize: PAGE_SIZE,
keyword: keyword.value.trim() || undefined
})
const { rows, total } = pagedRows(res)
if (isRefresh) wallets.value = rows
else wallets.value.push(...rows)
if (wallets.value.length >= total || rows.length < PAGE_SIZE) {
walletFinished.value = true
} else {
walletFinished.value = false
}
} catch (e) {
walletFinished.value = true
}
}
async function onWalletLoadMore() {
if (walletFinished.value || walletLoading.value) return
walletLoading.value = true
await fetchWallets(false)
walletLoading.value = false
walletPage.value++
}
async function onWalletRefresh() {
walletPage.value = 1
walletFinished.value = false
await fetchWallets(true)
walletPage.value = 2
}
async function onWalletPullRefresh() {
walletRefreshing.value = true
await onWalletRefresh()
walletRefreshing.value = false
}
function resetWalletList() {
wallets.value = []
walletPage.value = 1
walletFinished.value = false
walletLoading.value = true
void (async () => {
await fetchWallets(true)
walletLoading.value = false
walletPage.value = 2
})()
}
function clearKeyword() {
keyword.value = ''
resetWalletList()
}
function flushSearch() {
resetWalletList()
}
async function loadDetailSummary(userId) {
try {
const sumRes = await invoiceWalletApi.mineSummary({ userId })
Object.assign(detailSummary, sumRes?.data || {})
} catch (e) {
/* summary 失败不阻断列表 */
}
}
async function fetchInvoices(isRefresh) {
if (!detailUser.value) return
try {
const res = await invoiceApi.list({
page: invPage.value,
pageSize: PAGE_SIZE,
userId: detailUser.value.ownerUserId
})
const { rows, total } = pagedRows(res)
for (const inv of rows) {
inv._files = normalizeInvoiceFiles(inv)
}
const start = isRefresh ? 0 : detailInvoices.value.length
if (isRefresh) detailInvoices.value = rows
else detailInvoices.value.push(...rows)
kickInvoicePreviewLoads(detailInvoices.value.slice(start, start + rows.length))
if (detailInvoices.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() {
if (!detailUser.value) {
invRefreshing.value = false
return
}
invPage.value = 1
invFinished.value = false
await Promise.all([
loadDetailSummary(detailUser.value.ownerUserId),
fetchInvoices(true)
])
invPage.value = 2
}
async function onInvoicePullRefresh() {
invRefreshing.value = true
await onInvoiceRefresh()
invRefreshing.value = false
}
async function openDetail(row) {
detailInvoices.value = []
invPage.value = 1
invFinished.value = false
invLoading.value = false
Object.assign(detailSummary, {
totalAmount: row.totalAmount ?? 0,
usedAmount: row.usedAmount ?? 0,
balance: row.balance ?? 0,
invoiceCount: row.invoiceCount ?? 0
})
detailUser.value = row
await loadDetailSummary(row.ownerUserId)
invLoading.value = true
await fetchInvoices(true)
invLoading.value = false
invPage.value = 2
}
function onBack() {
if (detailUser.value) {
detailUser.value = null
detailInvoices.value = []
resetWalletList()
return
}
uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
}
function onDelete(inv) {
uni.showModal({
title: '删除发票',
content: '删除将冲减总额,且余额不可为负。确定删除?',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
try {
await invoiceApi.delete(inv.id)
uni.showToast({ title: '已删除', icon: 'success' })
await onInvoiceRefresh()
} catch (e) {
// toast in bxRequest
}
}
})
}
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() {
if (!detailUser.value) return
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],
targetUserId: detailUser.value.ownerUserId
})
uni.showToast({ title: '上传成功', icon: 'success' })
showUpload.value = false
uploadAmount.value = ''
uploadRemark.value = ''
uploadPaths.value = []
await onInvoiceRefresh()
} catch (e) {
// toast in bxUploadInvoice
} finally {
uploading.value = false
}
}
onMounted(() => {
computeHeights()
walletLoading.value = true
void (async () => {
await fetchWallets(true)
walletLoading.value = false
walletPage.value = 2
})()
})
</script>
<style lang="scss" scoped>
@import '@/styles/bx-theme.scss';
</style>
+574 -2
View File
@@ -1,7 +1,579 @@
<template>
<bx-placeholder-page default-title="我的发票夹" />
<view class="bx-page">
<view class="topbar">
<view class="topbar-left">
<view class="topbar-back" @click="goBack">
<text class="topbar-back-icon">‹</text>
</view>
</view>
<text class="topbar-title">我的发票夹</text>
<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 search-filter--scroll" :show-scrollbar="false">
<view
class="filter-tag"
:class="{ active: activeTab === 0 }"
@click="onTabChange(0)"
>
发票列表
</view>
<view
class="filter-tag"
:class="{ active: activeTab === 1 }"
@click="onTabChange(1)"
>
额度流水
</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 BxPlaceholderPage from '@/components/bx-ui/bx-placeholder-page.vue'
import { reactive, ref, onMounted } from 'vue'
import { attachmentApi, invoiceApi, invoiceWalletApi } from '@/api/bx'
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.switchTab({ url: '/pages/index/index' }) })
}
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>
+230
View File
@@ -1283,3 +1283,233 @@
.bx-page .all-list-card__bottom {
justify-content: flex-end;
}
/* —— 发票夹 wallet summary / cards —— */
.bx-page .wallet-summary.card,
.bx-page .wallet-summary {
margin: 12px 16px 8px;
padding: 14px 16px;
background: var(--surface);
border-radius: var(--r);
box-shadow: var(--shadow);
}
.bx-page .wallet-summary__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 14px;
color: var(--text-sec);
}
.bx-page .wallet-summary__val {
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.bx-page .wallet-summary__row--em .wallet-summary__val,
.bx-page .wallet-summary__row--em b {
color: var(--primary);
font-size: 18px;
}
.bx-page .wallet-summary__meta {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.bx-page .wallet-actions {
padding: 0 16px 8px;
}
.bx-page .wallet-list {
padding-top: 5px;
padding-bottom: 16px;
box-sizing: border-box;
}
.bx-page .invoice-wallet-card {
position: relative;
overflow: hidden;
padding-top: 20px;
}
.bx-page .invoice-wallet-card--static:active {
transform: none;
}
.bx-page .invoice-wallet-card__index {
position: absolute;
top: 0;
left: 0;
z-index: 1;
min-width: 28px;
height: 22px;
padding: 0 8px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0 0 10px 0;
background: var(--primary-dark);
color: #fff;
font-size: 12px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
box-sizing: border-box;
}
.bx-page .invoice-wallet-card__subs {
display: flex;
flex-direction: column;
gap: 6px;
margin: 4px 0 10px;
}
.bx-page .invoice-wallet-card__sub {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
line-height: 1.4;
}
.bx-page .invoice-wallet-card__sub .k {
flex-shrink: 0;
color: var(--text-muted);
}
.bx-page .invoice-wallet-card__sub .v {
min-width: 0;
text-align: right;
color: var(--text);
word-break: break-word;
}
.bx-page .invoice-wallet-card__att-label {
margin-bottom: 6px;
font-size: 12px;
color: var(--text-muted);
}
.bx-page .invoice-wallet-card__att-empty {
margin: 0;
font-size: 12px;
color: var(--text-muted);
}
.bx-page .advance-att-grid {
margin-top: 0;
}
.bx-page .advance-att-tile:active {
opacity: 0.92;
}
.bx-page .att-preview-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--text-muted);
font-size: 12px;
}
.bx-page .att-file-icon.att-doc-thumb {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 4px;
box-sizing: border-box;
}
.bx-page .att-doc-thumb__ext {
font-size: 11px;
font-weight: 800;
color: var(--primary-dark);
letter-spacing: 0.02em;
}
.bx-page .att-doc-thumb__name {
font-size: 10px;
color: var(--text-muted);
text-align: center;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
}
.bx-page .invoice-admin-card__bottom {
justify-content: flex-end;
margin-top: 10px;
}
.bx-page .empty-state--embed {
padding: 28px 16px;
min-height: 120px;
}
.bx-page .att-img.att-add {
display: flex;
align-items: center;
justify-content: center;
border-style: dashed;
color: var(--text-muted);
font-size: 28px;
line-height: 1;
}
.bx-page .form-hint {
display: block;
font-size: 11px;
color: var(--text-muted);
margin: 0 0 8px;
line-height: 1.4;
}
.bx-page .bx-upload-sheet .dialog-form-body {
padding: 8px 16px 4px;
}
.bx-page .bx-upload-sheet .form-item {
margin-bottom: 12px;
}
.bx-page .bx-upload-sheet .form-label {
display: block;
font-size: 13px;
color: var(--text-sec);
margin-bottom: 6px;
}
.bx-page .bx-upload-sheet .form-input {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: var(--rs);
background: var(--bg);
box-sizing: border-box;
font-size: 14px;
}
.bx-page .bx-upload-sheet .form-input-prefix {
position: relative;
}
.bx-page .bx-upload-sheet .form-input-prefix .form-input {
padding-left: 28px;
}
+69
View File
@@ -171,4 +171,73 @@ export function bxUploadFile(filePath, opts = {}) {
})
}
/**
* 发票 multipart 上传(POST /invoices,name=files)
* 对齐 bx-mobile FormData:amount / remark / files / targetUserId
* @param {{ amount: number|string, remark?: string, filePath: string, targetUserId?: string|number, showError?: boolean }} opts
*/
export function bxUploadInvoice(opts = {}) {
const showError = opts.showError !== false
const token = uni.getStorageSync(TOKEN_KEY) || ''
const filePath = opts.filePath
if (!filePath) {
return Promise.reject(new Error('请选择发票文件'))
}
const formData = {
amount: String(opts.amount ?? '')
}
if (opts.remark != null && String(opts.remark).trim()) {
formData.remark = String(opts.remark).trim()
}
if (opts.targetUserId != null && opts.targetUserId !== '') {
formData.targetUserId = String(opts.targetUserId)
}
const url = `${API_BASE}/invoices`
return new Promise((resolve, reject) => {
uni.uploadFile({
url,
filePath,
name: 'files',
formData,
header: {
...(token ? { Authorization: `Bearer ${token}` } : {})
},
success: (res) => {
const status = res.statusCode
let body = res.data
if (typeof body === 'string') {
try {
body = JSON.parse(body)
} catch (e) {
body = {}
}
}
if (status === 401) {
onUnauthorized()
reject(new Error('登录已过期'))
return
}
if (status < 200 || status >= 300) {
const message = parseDetailMessage(body) || `上传失败(${status})`
if (showError) uni.showToast({ title: String(message).slice(0, 40), icon: 'none' })
reject(new Error(message))
return
}
if (body && body.code !== undefined && body.code !== 0) {
const message = body.msg || '上传失败'
if (showError) uni.showToast({ title: String(message).slice(0, 40), icon: 'none' })
reject(new Error(message))
return
}
resolve(body)
},
fail: (err) => {
if (showError) uni.showToast({ title: '上传失败', icon: 'none' })
reject(err)
}
})
})
}
export default bxRequest