feat(bx-mobile): add invoice wallet pages and bind reimburse apply to balance rules

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-09 16:13:42 +08:00
1 parent 522d691050
commit a8590db98b
7 files changed
+745 -123

No files matched your search

+17 -1
View File
@@ -54,7 +54,8 @@ export const reimburseApi = {
getApprovals: (id: BxId) => bxClient.get(`/reimburses/${id}/approvals`),
create: (data: Record<string, any>) => bxClient.post('/reimburses/', data),
update: (id: BxId, data: Record<string, any>) => bxClient.put(`/reimburses/${id}`, data),
submit: (id: BxId) => bxClient.post(`/reimburses/${id}/submit`),
submit: (id: BxId, data?: Record<string, any>) =>
bxClient.post(`/reimburses/${id}/submit`, data),
approve: (id: BxId, data: { approvalOpinion?: string }) =>
bxClient.post(`/reimburses/${id}/approve`, data),
reject: (id: BxId, data: { approvalOpinion?: string }) =>
@@ -62,6 +63,21 @@ export const reimburseApi = {
delete: (id: BxId) => bxClient.delete(`/reimburses/${id}`),
}
export const invoiceWalletApi = {
mineSummary: (params?: { userId?: BxId }) =>
bxClient.get('/invoice-wallets/mine/summary', { params }),
list: (params: { page: number; pageSize: number; keyword?: string }) =>
bxClient.get('/invoice-wallets', { params }),
}
export const invoiceApi = {
list: (params?: { page?: number; pageSize?: number; userId?: BxId }) =>
bxClient.get('/invoices', { params }),
upload: (formData: FormData) => bxClient.post('/invoices', formData),
delete: (id: BxId) => bxClient.delete(`/invoices/${id}`),
ledgers: (params?: { userId?: BxId }) => bxClient.get('/invoice-ledgers', { params }),
}
export const paymentApi = {
pending: (params?: Record<string, any>) => bxClient.get('/payments/pending', { params }),
pay: (id: BxId, data?: { payRemark?: string; actualAmount?: number }) =>
+1 -1
View File
@@ -141,4 +141,4 @@ export const BX_MENU_CATALOG: BxMenuEntry[] = [
]
/** 当前已开放阶段:发版时改为 1 → 2 → 3 */
export const BX_MENU_CURRENT_PHASE: BxMenuPhase = 1
export const BX_MENU_CURRENT_PHASE: BxMenuPhase = 2
+13 -1
View File
@@ -117,7 +117,19 @@ const routes = [
component: () => import('@/views/all/AllPage.vue'),
meta: { menuCodes: ['bx:viewAll:list'] },
},
/* P1:不注册 invoice/*、admin 配置与 IAM(users/roles/menus) */
{
path: 'invoice/mine',
name: 'InvoiceWalletMine',
component: () => import('@/views/invoice/InvoiceWalletMinePage.vue'),
meta: { menuCodes: ['bx:invoiceWallet:mine'] },
},
{
path: 'invoice/admin',
name: 'InvoiceWalletAdmin',
component: () => import('@/views/invoice/InvoiceWalletAdminPage.vue'),
meta: { menuCodes: ['bx:invoiceWallet:admin'] },
},
/* P3 再注册 admin 配置;IAM(users/roles/menus)已移除 */
{ path: 'profile', name: 'Profile', component: () => import('@/views/profile/ProfilePage.vue') },
],
},
@@ -0,0 +1,267 @@
<template>
<div class="page">
<div class="topbar">
<div class="topbar-left">
<div class="topbar-back" @click="onBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</div>
</div>
<span class="topbar-title">{{ detailUser ? detailUser.ownerUserName : '发票夹管理' }}</span>
<div class="topbar-right"></div>
</div>
<!-- 员工额度列表 -->
<template v-if="!detailUser">
<van-search v-model="keyword" placeholder="搜索员工姓名" @search="loadWallets" />
<van-pull-refresh v-model="refreshing" @refresh="loadWallets">
<div v-if="!wallets.length" class="empty-state empty-state--embed">
<p>暂无数据</p>
</div>
<div
v-for="row in wallets"
:key="row.ownerUserId"
class="list-card"
role="button"
@click="openDetail(row)"
>
<div class="list-card-title">{{ row.ownerUserName || row.ownerUserId }}</div>
<div class="list-card-kv">
<span class="list-card-kv__k">总额 / 已用 / 余额</span>
<span class="list-card-kv__v">
¥{{ money(row.totalAmount) }} / ¥{{ money(row.usedAmount) }} /
<b>¥{{ money(row.balance) }}</b>
</span>
</div>
<div class="list-card-kv">
<span class="list-card-kv__k">发票</span>
<span class="list-card-kv__v">{{ row.invoiceCount ?? 0 }} 张</span>
</div>
</div>
</van-pull-refresh>
</template>
<!-- 某人详情 -->
<template v-else>
<div class="wallet-summary card">
<div class="wallet-summary__row">
<span>总额</span>
<b>¥{{ money(detailSummary.totalAmount) }}</b>
</div>
<div class="wallet-summary__row">
<span>已用</span>
<b>¥{{ money(detailSummary.usedAmount) }}</b>
</div>
<div class="wallet-summary__row wallet-summary__row--em">
<span>余额</span>
<b>¥{{ money(detailSummary.balance) }}</b>
</div>
</div>
<div class="wallet-actions">
<button type="button" class="btn btn-primary btn-block" @click="showUpload = true">
代传发票
</button>
</div>
<div v-for="inv in detailInvoices" :key="inv.id" class="list-card">
<div class="list-card-kv">
<span class="list-card-kv__k">金额</span>
<span class="list-card-kv__v">¥{{ money(inv.amount) }}</span>
</div>
<div class="list-card-kv">
<span class="list-card-kv__k">备注</span>
<span class="list-card-kv__v">{{ inv.remark || '-' }}</span>
</div>
<button type="button" class="btn btn-ghost btn-sm danger" @click="onDelete(inv)">
删除发票
</button>
</div>
</template>
<van-dialog
v-model:show="showUpload"
title="代传发票"
show-cancel-button
:confirm-button-color="PRIMARY_CONFIRM_BUTTON_COLOR"
:before-close="onUploadBeforeClose"
>
<div class="dialog-form-body">
<van-field v-model="uploadAmount" type="number" label="金额" placeholder="票面入账金额" />
<van-field v-model="uploadRemark" label="备注" placeholder="可选" />
<div class="upload-picker">
<van-uploader v-model="uploadFiles" :max-count="5" multiple accept="image/*,.pdf" />
</div>
</div>
</van-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showToast } from 'vant'
import { DELETE_CONFIRM_BUTTON_COLOR, PRIMARY_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
import { invoiceApi, invoiceWalletApi } from '@/api'
import { BizError } from '@/api/client'
const router = useRouter()
const keyword = ref('')
const refreshing = ref(false)
const wallets = ref<any[]>([])
const detailUser = ref<any>(null)
const detailSummary = reactive({
totalAmount: 0,
usedAmount: 0,
balance: 0,
invoiceCount: 0,
})
const detailInvoices = ref<any[]>([])
const showUpload = ref(false)
const uploadAmount = ref('')
const uploadRemark = ref('')
const uploadFiles = ref<any[]>([])
const uploading = ref(false)
function money(v: unknown) {
return Number(v || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
function paged(res: any): any[] {
const d = res?.data
if (!d) return []
return d.rows ?? d.items ?? d.list ?? (Array.isArray(d) ? d : [])
}
async function loadWallets() {
refreshing.value = true
try {
const res: any = await invoiceWalletApi.list({
page: 1,
pageSize: 100,
keyword: keyword.value || undefined,
})
wallets.value = paged(res)
} catch (e: any) {
if (!(e instanceof BizError)) showToast(e?.message || '加载失败')
} finally {
refreshing.value = false
}
}
async function openDetail(row: any) {
detailUser.value = row
try {
const [sumRes, invRes]: any[] = await Promise.all([
invoiceWalletApi.mineSummary({ userId: row.ownerUserId }),
invoiceApi.list({ page: 1, pageSize: 100, userId: row.ownerUserId }),
])
Object.assign(detailSummary, sumRes?.data || row)
detailInvoices.value = paged(invRes)
} catch (e: any) {
if (!(e instanceof BizError)) showToast(e?.message || '加载失败')
}
}
function onBack() {
if (detailUser.value) {
detailUser.value = null
loadWallets()
return
}
router.back()
}
async function onDelete(inv: any) {
try {
await showConfirmDialog({
title: '删除发票',
message: '删除将冲减总额,且余额不可为负。确定删除?',
confirmButtonColor: DELETE_CONFIRM_BUTTON_COLOR,
})
await invoiceApi.delete(inv.id)
showToast('已删除')
await openDetail(detailUser.value)
} catch (e: any) {
if (e !== 'cancel' && !(e instanceof BizError)) showToast(e?.message || '删除失败')
}
}
async function onUploadBeforeClose(action: string) {
if (action !== 'confirm') return true
if (!detailUser.value) return false
const amount = Number(uploadAmount.value)
if (!(amount > 0)) {
showToast('请输入有效金额')
return false
}
const files = uploadFiles.value.map((f) => f.file).filter(Boolean) as File[]
if (!files.length) {
showToast('请选择发票文件')
return false
}
if (uploading.value) return false
uploading.value = true
try {
const form = new FormData()
form.append('amount', String(amount))
form.append('targetUserId', String(detailUser.value.ownerUserId))
if (uploadRemark.value.trim()) form.append('remark', uploadRemark.value.trim())
for (const f of files) form.append('files', f)
await invoiceApi.upload(form)
showToast('上传成功')
uploadAmount.value = ''
uploadRemark.value = ''
uploadFiles.value = []
await openDetail(detailUser.value)
return true
} catch (e: any) {
if (!(e instanceof BizError)) showToast(e?.message || '上传失败')
return false
} finally {
uploading.value = false
}
}
loadWallets()
</script>
<style scoped>
.wallet-summary {
margin: 12px 16px;
padding: 14px 16px;
}
.wallet-summary__row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 14px;
}
.wallet-summary__row--em b {
color: var(--primary, #10b981);
font-size: 18px;
}
.wallet-actions {
padding: 0 16px 12px;
}
.list-card {
margin: 0 16px 10px;
padding: 12px 14px;
background: #fff;
border-radius: 10px;
}
.list-card-title {
font-weight: 600;
margin-bottom: 6px;
}
.danger {
color: #dc2626;
margin-top: 8px;
}
.upload-picker {
padding: 8px 16px 16px;
}
</style>
@@ -0,0 +1,239 @@
<template>
<div class="page">
<div class="topbar">
<div class="topbar-left">
<div class="topbar-back" @click="router.back()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</div>
</div>
<span class="topbar-title">我的发票夹</span>
<div class="topbar-right"></div>
</div>
<div class="wallet-summary card">
<div class="wallet-summary__row">
<span>总额</span>
<b>¥{{ money(summary.totalAmount) }}</b>
</div>
<div class="wallet-summary__row">
<span>已报销占用</span>
<b>¥{{ money(summary.usedAmount) }}</b>
</div>
<div class="wallet-summary__row wallet-summary__row--em">
<span>余额</span>
<b>¥{{ money(summary.balance) }}</b>
</div>
<div class="wallet-summary__meta">发票 {{ summary.invoiceCount ?? 0 }} 张</div>
</div>
<div class="wallet-actions">
<button type="button" class="btn btn-primary btn-block" @click="showUpload = true">
上传发票
</button>
</div>
<van-tabs v-model:active="activeTab" sticky offset-top="46">
<van-tab title="发票列表">
<van-pull-refresh v-model="refreshing" @refresh="reload">
<div v-if="!invoices.length" class="empty-state empty-state--embed">
<p>暂无发票</p>
</div>
<div v-for="inv in invoices" :key="inv.id" class="list-card">
<div class="list-card-kv">
<span class="list-card-kv__k">金额</span>
<span class="list-card-kv__v">¥{{ money(inv.amount) }}</span>
</div>
<div class="list-card-kv">
<span class="list-card-kv__k">备注</span>
<span class="list-card-kv__v">{{ inv.remark || '-' }}</span>
</div>
<div class="list-card-kv">
<span class="list-card-kv__k">时间</span>
<span class="list-card-kv__v">{{ inv.createTime || '-' }}</span>
</div>
</div>
</van-pull-refresh>
</van-tab>
<van-tab title="额度流水">
<div v-if="!ledgers.length" class="empty-state empty-state--embed">
<p>暂无流水</p>
</div>
<div v-for="(row, idx) in ledgers" :key="row.id || idx" class="list-card">
<div class="list-card-kv">
<span class="list-card-kv__k">类型</span>
<span class="list-card-kv__v">{{ entryTypeLabel(row.entryType) }}</span>
</div>
<div class="list-card-kv">
<span class="list-card-kv__k">金额</span>
<span class="list-card-kv__v">¥{{ money(row.amount) }}</span>
</div>
<div class="list-card-kv">
<span class="list-card-kv__k">备注</span>
<span class="list-card-kv__v">{{ row.remark || '-' }}</span>
</div>
<div class="list-card-kv">
<span class="list-card-kv__k">时间</span>
<span class="list-card-kv__v">{{ row.createTime || '-' }}</span>
</div>
</div>
</van-tab>
</van-tabs>
<van-dialog
v-model:show="showUpload"
title="上传发票"
show-cancel-button
:confirm-button-color="PRIMARY_CONFIRM_BUTTON_COLOR"
:before-close="onUploadBeforeClose"
>
<div class="dialog-form-body">
<van-field v-model="uploadAmount" type="number" label="金额" placeholder="票面入账金额" />
<van-field v-model="uploadRemark" label="备注" placeholder="可选" />
<div class="upload-picker">
<van-uploader v-model="uploadFiles" :max-count="5" multiple accept="image/*,.pdf" />
</div>
</div>
</van-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { PRIMARY_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
import { invoiceApi, invoiceWalletApi } from '@/api'
import { BizError } from '@/api/client'
const router = useRouter()
const activeTab = ref(0)
const refreshing = ref(false)
const summary = reactive({
totalAmount: 0,
usedAmount: 0,
balance: 0,
invoiceCount: 0,
})
const invoices = ref<any[]>([])
const ledgers = ref<any[]>([])
const showUpload = ref(false)
const uploadAmount = ref('')
const uploadRemark = ref('')
const uploadFiles = ref<any[]>([])
const uploading = ref(false)
function money(v: unknown) {
return Number(v || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
function entryTypeLabel(t: string) {
const map: Record<string, string> = {
credit_upload: '上传入账',
debit_delete: '删除冲减',
debit_reimburse: '报销占用',
credit_release: '驳回/撤回退回',
}
return map[t] || t || '-'
}
function paged(res: any): any[] {
const d = res?.data
if (!d) return []
return d.rows ?? d.items ?? d.list ?? (Array.isArray(d) ? d : [])
}
async function reload() {
refreshing.value = true
try {
const [sumRes, invRes, ledRes]: any[] = await Promise.all([
invoiceWalletApi.mineSummary(),
invoiceApi.list({ page: 1, pageSize: 100 }),
invoiceApi.ledgers(),
])
Object.assign(summary, sumRes?.data || {})
invoices.value = paged(invRes)
ledgers.value = paged(ledRes)
} catch (e: any) {
if (!(e instanceof BizError)) showToast(e?.message || '加载失败')
} finally {
refreshing.value = false
}
}
async function onUploadBeforeClose(action: string) {
if (action !== 'confirm') return true
const amount = Number(uploadAmount.value)
if (!(amount > 0)) {
showToast('请输入有效金额')
return false
}
const files = uploadFiles.value.map((f) => f.file).filter(Boolean) as File[]
if (!files.length) {
showToast('请选择发票文件')
return false
}
if (uploading.value) return false
uploading.value = true
try {
const form = new FormData()
form.append('amount', String(amount))
if (uploadRemark.value.trim()) form.append('remark', uploadRemark.value.trim())
for (const f of files) form.append('files', f)
await invoiceApi.upload(form)
showToast('上传成功')
uploadAmount.value = ''
uploadRemark.value = ''
uploadFiles.value = []
await reload()
return true
} catch (e: any) {
if (!(e instanceof BizError)) showToast(e?.message || '上传失败')
return false
} finally {
uploading.value = false
}
}
onMounted(reload)
</script>
<style scoped>
.wallet-summary {
margin: 12px 16px;
padding: 14px 16px;
}
.wallet-summary__row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 14px;
color: var(--text-secondary);
}
.wallet-summary__row--em b {
color: var(--primary, #10b981);
font-size: 18px;
}
.wallet-summary__meta {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.wallet-actions {
padding: 0 16px 12px;
}
.list-card {
margin: 0 16px 10px;
padding: 12px 14px;
background: #fff;
border-radius: 10px;
}
.upload-picker {
padding: 8px 16px 16px;
}
</style>
@@ -93,47 +93,60 @@
</div>
</div>
<!-- 附件(与备用金申请页结构、样式一致) -->
<!-- 发票夹:可选关联 + 余额提示(不再本地上传发票) -->
<div class="form-card">
<div class="form-header">
<h3>附件</h3>
<h3>关联发票(可选)</h3>
</div>
<div class="uploader-slot-btn advance-attachment-upload">
<van-uploader
:after-read="onAfterRead"
:before-read="beforeReadAttachment"
multiple
result-type="file"
:accept="attachmentAccept"
>
<button type="button" class="btn btn-primary btn-sm">上传附件</button>
</van-uploader>
</div>
<div v-if="attachment.files.value.length" class="att-grid advance-att-grid">
<p class="invoice-balance-tip">
发票余额 <b>¥{{ money(walletBalance) }}</b> · 本次合计
<b>¥{{ detailTotalAmount }}</b>
<span v-if="balanceInsufficient" class="invoice-balance-tip--warn">(余额不足,无法提交)</span>
</p>
<button type="button" class="btn btn-primary btn-sm" @click="openInvoicePicker">
从发票夹选择
</button>
<div v-if="selectedInvoiceIds.length" class="invoice-picked">
<div
v-for="(file, aidx) in attachment.files.value"
:key="file.id"
class="att-img advance-att-tile"
:title="file.fileName"
role="button"
tabindex="0"
@click="attachment.open(file)"
@keydown.enter.prevent="attachment.open(file)"
v-for="inv in selectedInvoiceRows"
:key="inv.id"
class="invoice-picked__item"
>
<img
v-if="attachment.isImage(file.fileType) && file.previewUrl"
:src="file.previewUrl"
:alt="file.fileName"
/>
<div v-else class="att-file-icon att-doc-thumb">
<span class="att-doc-thumb__ext">{{ attachment.fileExt(file.fileName) }}</span>
<span class="att-doc-thumb__name">{{ file.fileName }}</span>
</div>
<button type="button" class="del" @click.stop="attachment.remove(aidx)">×</button>
<span>¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }}</span>
<button type="button" class="del" @click="removeInvoice(inv.id)">×</button>
</div>
</div>
<p class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</p>
</div>
<van-popup v-model:show="showInvoicePicker" position="bottom" round :style="{ height: '70%' }">
<div class="invoice-picker">
<div class="invoice-picker__title">选择发票</div>
<van-checkbox-group v-model="pickerCheckedIds">
<van-cell-group>
<van-cell
v-for="inv in walletInvoices"
:key="inv.id"
clickable
@click="togglePickerId(inv.id)"
>
<template #title>
¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }}
</template>
<template #right-icon>
<van-checkbox :name="String(inv.id)" @click.stop />
</template>
</van-cell>
</van-cell-group>
</van-checkbox-group>
<div class="invoice-picker__footer">
<button type="button" class="btn btn-primary btn-block" @click="confirmInvoicePicker">
确定(已选 {{ pickerCheckedIds.length }})
</button>
</div>
</div>
</van-popup>
<!-- 报销说明 -->
<div class="form-card">
<div class="form-header">
@@ -228,58 +241,19 @@
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { reimburseApi } from '@/api'
import { invoiceApi, invoiceWalletApi, reimburseApi } from '@/api'
import { BizError } from '@/api/client'
import {
useExpenseItemTree,
formatExpenseName,
type ExpenseItem,
} from '@/hooks/useExpenseItemTree'
import { useAttachment } from '@/hooks/useAttachment'
import { SUBMIT_APPROVE_BUTTON_COLOR } from '@/constants/dialog'
import BottomActionBar from '@/components/mobile/BottomActionBar.vue'
const MAX_ATTACHMENTS = 10
const router = useRouter()
const route = useRoute()
/** 与备用金申请附件 accept 一致 */
const attachmentAccept =
[
'image/*',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv',
'application/rtf',
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/x-7z-compressed',
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.txt',
'.csv',
'.rtf',
'.wps',
'.et',
'.dps',
'.zip',
'.rar',
'.7z',
].join(',')
interface DetailLine {
expenseItemId: number | string
amount: string
@@ -299,12 +273,80 @@ const pickingLineIndex = ref(0)
const selectedLeftId = ref<string | null>(null)
const { expenseTree, flatList, load: loadExpenseItems } = useExpenseItemTree()
const attachment = useAttachment()
const detailTotalAmount = computed(() => {
const total = form.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0)
return total.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
})
const walletBalance = ref(0)
const walletInvoices = ref<any[]>([])
const selectedInvoiceIds = ref<string[]>([])
const showInvoicePicker = ref(false)
const pickerCheckedIds = ref<string[]>([])
const detailTotalNumber = computed(() =>
form.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0),
)
const detailTotalAmount = computed(() =>
detailTotalNumber.value.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
)
const balanceInsufficient = computed(
() => detailTotalNumber.value > 0 && walletBalance.value < detailTotalNumber.value,
)
const selectedInvoiceRows = computed(() =>
walletInvoices.value.filter((inv) => selectedInvoiceIds.value.includes(String(inv.id))),
)
function money(v: unknown) {
return Number(v || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
async function refreshWallet() {
try {
const [sumRes, invRes]: any[] = await Promise.all([
invoiceWalletApi.mineSummary(),
invoiceApi.list({ page: 1, pageSize: 200 }),
])
walletBalance.value = Number(sumRes?.data?.balance ?? 0)
const d = invRes?.data
walletInvoices.value = d?.rows ?? d?.items ?? d?.list ?? (Array.isArray(d) ? d : [])
} catch {
/* ignore */
}
}
function openInvoicePicker() {
pickerCheckedIds.value = [...selectedInvoiceIds.value]
showInvoicePicker.value = true
}
function togglePickerId(id: string | number) {
const sid = String(id)
const i = pickerCheckedIds.value.indexOf(sid)
if (i >= 0) pickerCheckedIds.value.splice(i, 1)
else pickerCheckedIds.value.push(sid)
}
function confirmInvoicePicker() {
selectedInvoiceIds.value = [...pickerCheckedIds.value]
showInvoicePicker.value = false
}
function removeInvoice(id: string | number) {
const sid = String(id)
selectedInvoiceIds.value = selectedInvoiceIds.value.filter((x) => x !== sid)
}
function buildInvoicePayload() {
const invoiceIds = [...selectedInvoiceIds.value]
const invoiceLinks = invoiceIds.map((invoiceId) => ({ invoiceId }))
return { invoiceIds, invoiceLinks }
}
function lineExpenseDisplay(id: number | string): string {
if (id === '' || id == null) return ''
@@ -393,28 +435,6 @@ function removeDetailLine(index: number) {
form.details.splice(index, 1)
}
function beforeReadAttachment(file: File | File[]) {
const batch = Array.isArray(file) ? file : [file]
const remain = MAX_ATTACHMENTS - attachment.files.value.length
if (remain <= 0) {
showToast(`最多上传 ${MAX_ATTACHMENTS} 个附件`)
return false
}
if (batch.length > remain) {
showToast(`还可上传 ${remain} 个附件,超出部分已忽略`)
return Promise.resolve(batch.slice(0, remain))
}
return true
}
async function onAfterRead(uploadFile: any) {
const files = Array.isArray(uploadFile) ? uploadFile : [uploadFile]
for (const f of files) {
if (!f?.file) continue
await attachment.upload(f.file, 'reimburse_apply', '')
}
}
/** 与明细行 * 一致:逐行校验,避免漏填仍被 filter 静默丢弃;失败返回 null */
function validateReimburseApplyForm(): DetailLine[] | null {
for (let i = 0; i < form.details.length; i++) {
@@ -461,6 +481,7 @@ async function onSubmit() {
submitting.value = true
try {
const invoicePayload = buildInvoicePayload()
const payload = {
title: form.title || undefined,
remark: form.remark || undefined,
@@ -469,6 +490,7 @@ async function onSubmit() {
amount: Number(d.amount),
remark: String(d.remark).trim(),
})),
...invoicePayload,
}
let id: string
@@ -476,7 +498,6 @@ async function onSubmit() {
if (editingId.value) {
id = editingId.value
await reimburseApi.update(id, payload)
await attachment.bindNew('reimburse_apply', id)
showToast('保存成功')
} else {
const res: any = await reimburseApi.create(payload)
@@ -484,31 +505,41 @@ async function onSubmit() {
if (!id) {
throw new Error('保存成功但未返回报销单ID')
}
await attachment.bindNew('reimburse_apply', id)
editingId.value = id
showToast('保存成功')
}
showDialog({
title: isEditingAction ? '保存成功' : '创建成功',
message: '报销单已保存为草稿',
message: balanceInsufficient.value
? '已保存为草稿。发票余额不足,请先充值发票夹后再提交。'
: '报销单已保存为草稿',
showCancelButton: true,
confirmButtonText: '提交审批',
confirmButtonColor: SUBMIT_APPROVE_BUTTON_COLOR,
cancelButtonText: '继续编辑',
}).then(() => {
// 用户点击"提交审批"
reimburseApi.submit(id).then(() => {
showToast('提交成功')
router.back()
}).catch((e: any) => {
if (!(e instanceof BizError)) {
showToast(e?.message || '提交失败')
}
})
}).catch(() => {
// 用户点击"继续编辑" — 留在当前页
})
.then(() => {
if (balanceInsufficient.value) {
showToast('余额不足,无法提交')
return
}
reimburseApi
.submit(id, invoicePayload)
.then(async () => {
showToast('提交成功')
await refreshWallet()
router.back()
})
.catch((e: any) => {
if (!(e instanceof BizError)) {
showToast(e?.message || '提交失败')
}
})
})
.catch(() => {
/* 继续编辑 */
})
} catch (e: any) {
if (!(e instanceof BizError)) {
showToast(e?.message || '保存失败')
@@ -522,7 +553,7 @@ async function loadEditData() {
const id = route.query.id
if (!id) {
editingId.value = null
attachment.clear()
selectedInvoiceIds.value = []
return
}
editingId.value = String(id)
@@ -540,7 +571,11 @@ async function loadEditData() {
if (form.details.length === 0) {
form.details = [{ expenseItemId: '', amount: '', remark: '' }]
}
await attachment.loadList('reimburse_apply', editingId.value)
const linked = data.invoices || []
selectedInvoiceIds.value = linked.map((x: any) => String(x.id || x.invoiceId)).filter(Boolean)
if (data.status && data.status !== 'draft' && data.status !== 'rejected') {
showToast('旧草稿若仍含本地发票附件,请改走发票夹关联')
}
} catch (e: any) {
showToast(e?.message || '加载数据失败')
}
@@ -551,7 +586,7 @@ watch(
async (id, prev) => {
if (!id) {
editingId.value = null
attachment.clear()
selectedInvoiceIds.value = []
return
}
if (String(id) === String(prev ?? '')) return
@@ -561,7 +596,7 @@ watch(
onMounted(async () => {
sessionStorage.removeItem('bx_edit_resubmit')
await loadExpenseItems()
await Promise.all([loadExpenseItems(), refreshWallet()])
await loadEditData()
if (!editingId.value && !form.title) {
try {
@@ -569,7 +604,9 @@ onMounted(async () => {
if (res.data) {
form.title = typeof res.data === 'string' ? res.data : res.data.title || ''
}
} catch { /* skip */ }
} catch {
/* skip */
}
}
})
</script>
@@ -615,6 +652,57 @@ onMounted(async () => {
font-size: 16px;
}
.invoice-balance-tip {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 10px;
line-height: 1.5;
}
.invoice-balance-tip--warn {
color: #dc2626;
margin-left: 4px;
}
.invoice-picked {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.invoice-picked__item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: #f8fafc;
border-radius: 8px;
font-size: 13px;
}
.invoice-picked__item .del {
border: none;
background: transparent;
font-size: 18px;
color: #94a3b8;
}
.form-tip {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.invoice-picker {
display: flex;
flex-direction: column;
height: 100%;
padding-bottom: env(safe-area-inset-bottom);
}
.invoice-picker__title {
padding: 14px 16px;
font-weight: 600;
text-align: center;
}
.invoice-picker__footer {
margin-top: auto;
padding: 12px 16px;
}
.advance-attachment-upload {
display: flex;
justify-content: flex-start;
@@ -282,7 +282,7 @@
| 项 | 说明 |
|----|------|
| `bx-mobile` 移动端 | 未迁入门户 |
| `bx-mobile` 移动端 | H5 适配进行中:P1 门户鉴权+核心单据已对接;P2 发票夹已对齐 |
| 历史数据迁移 | 方案外,空库启动 |
| 原 bx 独立用户体系 | 已废弃 |