| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611 |
- <template>
- <view class="bx-page">
- <view class="topbar" :style="safeAreaStyle">
- <view class="topbar-left">
- <text class="topbar-back" @click="onBack">←</text>
- <text class="topbar-title">{{
- detailUser ? detailUser.ownerUserName || '发票夹详情' : '发票夹管理'
- }}</text>
- </view>
- <view class="topbar-right" />
- </view>
- <!-- 员工额度列表 -->
- <template v-if="!detailUser">
- <view class="search-wrap">
- <view class="search-box">
- <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 { 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 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.reLaunch({ url: '/pages/workbench/workbench' }) })
- }
- 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>
|