Files
shanxia2.0/miniprogram/pages/bx/invoice/admin.vue
T

612 lines
18 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="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>