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:
1 parent
522d691050
commit
a8590db98b
7 files changed
+745
-123
No files matched your search
@@ -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 }) =>
|
||||
|
||||
@@ -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
|
||||
@@ -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 独立用户体系 | 已废弃 |
|
||||
|
||||
|
||||
Reference in new issue
Block a user