907 lines
28 KiB
Vue
907 lines
28 KiB
Vue
<template>
|
||
<view class="bx-page bx-page--form">
|
||
<view class="topbar" :style="safeAreaStyle">
|
||
<view class="topbar-left">
|
||
<text class="topbar-back" @click="goBack">←</text>
|
||
<text class="topbar-title">报销申请</text>
|
||
</view>
|
||
<view class="topbar-right" />
|
||
</view>
|
||
|
||
<view class="page-padded">
|
||
<view class="form-card">
|
||
<view class="form-header">
|
||
<text class="form-header-title">报销信息</text>
|
||
<text class="form-badge">{{ isEdit ? '编辑' : '新建' }}</text>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">申请人</text>
|
||
<view class="form-input" style="display: flex; align-items: center">
|
||
{{ applicantLabel || '当前用户' }}
|
||
</view>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">报销日期<text class="req">*</text></text>
|
||
<picker mode="date" :value="form.reimburseDate" @change="onDateChange">
|
||
<view class="picker-trigger">
|
||
<text class="value" :class="{ ph: !form.reimburseDate }">
|
||
{{ form.reimburseDate || '请选择报销日期' }}
|
||
</text>
|
||
<text class="arrow">›</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">报销标题</text>
|
||
<input
|
||
class="form-input"
|
||
:value="form.title"
|
||
placeholder="不填则自动生成"
|
||
@input="onTitleInput"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="form-card">
|
||
<view class="form-header">
|
||
<text class="form-header-title">报销明细</text>
|
||
<text class="form-badge">{{ form.details.length }}行</text>
|
||
</view>
|
||
|
||
<view v-for="(line, idx) in form.details" :key="idx" class="detail-line">
|
||
<view v-if="idx > 0" class="detail-divider" />
|
||
|
||
<view class="field">
|
||
<text class="field-label">
|
||
费用项目
|
||
<text v-if="form.details.length > 1" class="detail-line-no">
|
||
(第 {{ idx + 1 }} 条)
|
||
</text>
|
||
<text class="req">*</text>
|
||
</text>
|
||
<view class="picker-trigger" @click="openExpensePicker(idx)">
|
||
<text
|
||
class="value"
|
||
:class="{ ph: !lineExpenseDisplay(line.expenseItemId) }"
|
||
>
|
||
{{ lineExpenseDisplay(line.expenseItemId) || '请选择费用项目' }}
|
||
</text>
|
||
<text class="arrow">›</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">金额(元)<text class="req">*</text></text>
|
||
<view class="form-input-prefix">
|
||
<input
|
||
class="form-input"
|
||
type="digit"
|
||
:value="line.amount"
|
||
placeholder="0.00"
|
||
:data-idx="idx"
|
||
@input="onLineAmount"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">报销事由<text class="req">*</text></text>
|
||
<input
|
||
class="form-input"
|
||
:value="line.remark"
|
||
placeholder="请输入报销事由"
|
||
:data-idx="idx"
|
||
@input="onLineRemark"
|
||
/>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">发生时间</text>
|
||
<picker
|
||
mode="date"
|
||
:value="line.occurDate || form.reimburseDate"
|
||
:data-idx="idx"
|
||
@change="onOccurDate"
|
||
>
|
||
<view class="picker-trigger">
|
||
<text class="value" :class="{ ph: !line.occurDate }">
|
||
{{ line.occurDate || '选填' }}
|
||
</text>
|
||
<text class="arrow">›</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view v-if="form.details.length > 1" class="detail-line-actions">
|
||
<button class="btn btn-sm btn-danger" @click="removeDetailLine(idx)">
|
||
删除此行
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="detail-actions">
|
||
<button class="btn btn-primary btn-sm" @click="addDetailLine">+ 新增明细行</button>
|
||
<text class="total-amount">合计:<text style="font-weight: 700">¥{{ detailTotalAmount }}</text></text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="form-card">
|
||
<view class="form-header">
|
||
<text class="form-header-title">关联发票(可选)</text>
|
||
</view>
|
||
<view class="invoice-balance-tip">
|
||
发票余额 ¥{{ money(walletBalance) }} · 本次合计 ¥{{ detailTotalAmount }}
|
||
<text v-if="balanceInsufficient" class="invoice-balance-tip--warn">
|
||
(余额不足,提交后余额将为负,请后续补发票)
|
||
</text>
|
||
</view>
|
||
<button class="btn btn-primary btn-sm" @click="openInvoicePicker">从发票夹选择</button>
|
||
<view v-if="selectedInvoicePicks.length" class="invoice-thumb-grid">
|
||
<view
|
||
v-for="pick in selectedInvoicePicks"
|
||
:key="pick.key"
|
||
class="invoice-thumb"
|
||
>
|
||
<view class="invoice-thumb__del" @click.stop="removeInvoicePick(pick.key)">×</view>
|
||
<view class="invoice-thumb__media" @click="previewInvoicePick(pick)">
|
||
<image
|
||
v-if="isImageFileType(pick.fileType) && pick.previewUrl"
|
||
:src="pick.previewUrl"
|
||
mode="aspectFill"
|
||
/>
|
||
<view v-else class="invoice-thumb__doc">
|
||
<text class="invoice-thumb__doc-ext">{{ fileExtLabel(pick.fileName) }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="invoice-thumb__meta">
|
||
<text class="invoice-thumb__amt">¥{{ money(pick.amount) }}</text>
|
||
<text class="invoice-thumb__date">{{ formatInvoiceDateYmd(pick.createTime) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</view>
|
||
</view>
|
||
|
||
<view class="form-card">
|
||
<view class="form-header">
|
||
<text class="form-header-title">申请附件(可选)</text>
|
||
</view>
|
||
<button
|
||
class="btn btn-primary btn-sm"
|
||
:disabled="uploading"
|
||
@click="chooseAttachments"
|
||
>
|
||
{{ uploading ? '上传中…' : '上传图片' }}
|
||
</button>
|
||
<view v-if="files.length" class="att-grid">
|
||
<view v-for="(file, idx) in files" :key="file.id" class="att-img att-img--with-name">
|
||
<image :src="file.previewUrl || file.localPath" mode="aspectFill" />
|
||
<view v-if="!file._pending" class="del" @click.stop="removeFile(idx)">×</view>
|
||
<text class="att-img__name">{{ attachmentDisplayName(file, idx) }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-else class="detail-section-empty" style="margin-top: 8px">暂无附件(可选)</view>
|
||
</view>
|
||
|
||
<view class="form-card">
|
||
<view class="form-header">
|
||
<text class="form-header-title">报销说明</text>
|
||
</view>
|
||
<view class="field">
|
||
<textarea
|
||
class="form-textarea"
|
||
:value="form.remark"
|
||
placeholder="请输入报销说明"
|
||
:maxlength="500"
|
||
@input="onRemarkInput"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="bottom-action-bar">
|
||
<button class="btn btn-primary btn-block" :disabled="submitting" @click="onSubmit">
|
||
{{ submitting ? '保存中…' : '保存' }}
|
||
</button>
|
||
</view>
|
||
|
||
<!-- 费用项目选择 -->
|
||
<view v-if="showExpensePicker" class="bx-sheet-mask" @click="showExpensePicker = false">
|
||
<view class="bx-sheet" @click.stop>
|
||
<view class="bx-sheet__title">选择费用项目</view>
|
||
<scroll-view scroll-y class="bx-sheet__body">
|
||
<view v-if="!expenseOptions.length" class="bx-sheet__empty">暂无可用费用项目</view>
|
||
<view
|
||
v-for="opt in expenseOptions"
|
||
:key="opt.id"
|
||
class="bx-sheet__item"
|
||
:class="{ 'is-selected': String(form.details[pickingLineIndex]?.expenseItemId) === opt.id }"
|
||
@click="confirmExpensePick(opt.id)"
|
||
>
|
||
{{ opt.label }}
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 发票多选(缩略图网格 + 分页) -->
|
||
<view v-if="showInvoicePicker" class="bx-sheet-mask" @click="showInvoicePicker = false">
|
||
<view class="bx-sheet" @click.stop>
|
||
<view class="bx-sheet__title">选择发票附件</view>
|
||
<scroll-view
|
||
scroll-y
|
||
class="bx-sheet__body bx-sheet__body--invoice-thumbs"
|
||
@scrolltolower="onInvoicePickerLoadMore"
|
||
lower-threshold="80"
|
||
>
|
||
<view v-if="!attachItems.length && !invLoading" class="bx-sheet__empty">
|
||
暂无发票附件
|
||
</view>
|
||
<view v-else class="invoice-pick-grid">
|
||
<view
|
||
v-for="item in attachItems"
|
||
:key="item.key"
|
||
class="invoice-pick-card"
|
||
:class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
|
||
@click="togglePickerKey(item.key)"
|
||
>
|
||
<view class="invoice-pick-card__check">
|
||
<text v-if="pickerDraftKeys.includes(item.key)">✓</text>
|
||
</view>
|
||
<view class="invoice-pick-card__preview">
|
||
<image
|
||
v-if="isImageFileType(item.fileType) && item.previewUrl"
|
||
:src="item.previewUrl"
|
||
mode="aspectFill"
|
||
/>
|
||
<view v-else class="invoice-pick-card__doc">
|
||
<text class="invoice-thumb__doc-ext">{{ fileExtLabel(item.fileName) }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="invoice-pick-card__meta">
|
||
<view class="invoice-pick-card__amt">¥{{ money(item.amount) }}</view>
|
||
<view class="invoice-pick-card__date">
|
||
{{ formatInvoiceDateYmd(item.createTime) }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="attachItems.length" class="invoice-pick-footer-tip">
|
||
<text v-if="invLoading">加载中…</text>
|
||
<text v-else-if="invFinished">没有更多了</text>
|
||
<text v-else>上拉加载更多</text>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="bx-sheet__footer">
|
||
<button class="btn btn-primary btn-block" @click="confirmInvoicePicker">
|
||
确定(已选 {{ pickerDraftKeys.length }})
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { PAGE_PATHS } from '@/config/api'
|
||
import { useUserStore } from '@/stores/user'
|
||
import {
|
||
reimburseApi,
|
||
attachmentApi,
|
||
expenseItemApi,
|
||
invoiceWalletApi,
|
||
invoiceApi
|
||
} from '@/api/bx'
|
||
import {
|
||
normalizeExpenseItem,
|
||
flattenExpenseTree,
|
||
formatExpenseName
|
||
} from '@/utils/bxFormat'
|
||
import {
|
||
isImageFileType,
|
||
fileExtLabel,
|
||
formatInvoiceDateYmd,
|
||
buildAttachItemsFromInvoiceRows,
|
||
mergeInvoicePicksByDraftKeys
|
||
} from '@/utils/bxInvoiceThumb'
|
||
import { getSafeAreaStyle } from '@/utils/safeArea'
|
||
import { buildApplyAttachmentDisplayName } from '@/utils/bxAttachmentDisplayName'
|
||
|
||
const safeAreaStyle = getSafeAreaStyle()
|
||
|
||
const MAX_ATTACHMENTS = 10
|
||
const userStore = useUserStore()
|
||
|
||
const editId = ref('')
|
||
const savedId = ref('')
|
||
const submitting = ref(false)
|
||
const uploading = ref(false)
|
||
const files = ref([])
|
||
|
||
function todayYmd() {
|
||
const d = new Date()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
return `${d.getFullYear()}-${m}-${day}`
|
||
}
|
||
|
||
const form = reactive({
|
||
title: '',
|
||
remark: '',
|
||
applicantId: '',
|
||
reimburseDate: todayYmd(),
|
||
details: [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
|
||
})
|
||
|
||
const flatList = ref([])
|
||
const showExpensePicker = ref(false)
|
||
const pickingLineIndex = ref(0)
|
||
|
||
const walletBalance = ref(0)
|
||
const INVOICE_PAGE_SIZE = 10
|
||
const attachItems = ref([])
|
||
const invPage = ref(1)
|
||
const invLoading = ref(false)
|
||
const invFinished = ref(false)
|
||
const selectedInvoicePicks = ref([])
|
||
const pickerDraftKeys = ref([])
|
||
const showInvoicePicker = ref(false)
|
||
|
||
const isEdit = computed(() => !!editId.value || !!savedId.value)
|
||
|
||
const applicantLabel = computed(() => {
|
||
const u = userStore.userInfo || {}
|
||
return u.realName || u.username || u.nickName || u.name || ''
|
||
})
|
||
|
||
const expenseOptions = computed(() => {
|
||
return (flatList.value || [])
|
||
.filter((i) => i.status === 1 || i.status === '1')
|
||
.map((i) => ({
|
||
id: String(i.id),
|
||
label: formatExpenseName(i.id, flatList.value)
|
||
}))
|
||
})
|
||
|
||
const detailTotalNumber = computed(() =>
|
||
form.details.reduce((sum, 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
|
||
)
|
||
|
||
function money(v) {
|
||
return Number(v || 0).toLocaleString('zh-CN', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
})
|
||
}
|
||
|
||
function attachmentDisplayName(file, index) {
|
||
return buildApplyAttachmentDisplayName({
|
||
remark: form.remark || '',
|
||
originalFileName: file.fileName || '',
|
||
index1: index + 1
|
||
})
|
||
}
|
||
|
||
function lineExpenseDisplay(id) {
|
||
if (id === '' || id == null) return ''
|
||
const name = formatExpenseName(id, flatList.value)
|
||
return name === '-' ? '' : name
|
||
}
|
||
|
||
function onTitleInput(e) {
|
||
form.title = e.detail.value
|
||
}
|
||
function onRemarkInput(e) {
|
||
form.remark = e.detail.value
|
||
}
|
||
function onLineAmount(e) {
|
||
const idx = Number(e.currentTarget.dataset.idx)
|
||
if (Number.isNaN(idx) || !form.details[idx]) return
|
||
form.details[idx].amount = e.detail.value
|
||
}
|
||
function onLineRemark(e) {
|
||
const idx = Number(e.currentTarget.dataset.idx)
|
||
if (Number.isNaN(idx) || !form.details[idx]) return
|
||
form.details[idx].remark = e.detail.value
|
||
}
|
||
function onOccurDate(e) {
|
||
const idx = Number(e.currentTarget.dataset.idx)
|
||
if (Number.isNaN(idx) || !form.details[idx]) return
|
||
form.details[idx].occurDate = e.detail.value
|
||
}
|
||
function onDateChange(e) {
|
||
form.reimburseDate = e.detail.value
|
||
refreshTitle()
|
||
}
|
||
|
||
function goBack() {
|
||
uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
|
||
}
|
||
|
||
function addDetailLine() {
|
||
form.details.push({ expenseItemId: '', amount: '', remark: '', occurDate: '' })
|
||
}
|
||
|
||
function removeDetailLine(index) {
|
||
form.details.splice(index, 1)
|
||
}
|
||
|
||
function openExpensePicker(idx) {
|
||
pickingLineIndex.value = idx
|
||
showExpensePicker.value = true
|
||
}
|
||
|
||
function confirmExpensePick(id) {
|
||
form.details[pickingLineIndex.value].expenseItemId = String(id)
|
||
showExpensePicker.value = false
|
||
}
|
||
|
||
function buildInvoicePayload() {
|
||
const invoiceLinks = selectedInvoicePicks.value.map((p) => ({
|
||
invoiceId: String(p.invoiceId),
|
||
attachmentId: String(p.attachmentId)
|
||
}))
|
||
const invoiceIds = Array.from(new Set(invoiceLinks.map((p) => p.invoiceId)))
|
||
return { invoiceIds, invoiceLinks }
|
||
}
|
||
|
||
async function refreshTitle() {
|
||
if (!form.reimburseDate) return
|
||
try {
|
||
const params = { reimburseDate: form.reimburseDate }
|
||
const exclude = editId.value || savedId.value
|
||
if (exclude) params.excludeApplyId = exclude
|
||
if (form.applicantId) params.applicantId = form.applicantId
|
||
const res = await reimburseApi.generateTitle(params)
|
||
const t = res?.data
|
||
form.title = typeof t === 'string' ? t : t?.title || form.title
|
||
} catch (e) {
|
||
// keep
|
||
}
|
||
}
|
||
|
||
async function refreshWallet() {
|
||
try {
|
||
const owner = form.applicantId || undefined
|
||
const sumRes = await invoiceWalletApi.mineSummary(
|
||
owner ? { userId: owner } : undefined
|
||
)
|
||
walletBalance.value = Number(sumRes?.data?.balance ?? 0)
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
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 kickAttachPreviewLoads(items) {
|
||
for (const item of items) {
|
||
if (!isImageFileType(item.fileType) || item.previewUrl || item.previewLoading) continue
|
||
item.previewLoading = true
|
||
attachmentApi
|
||
.preview(item.attachmentId)
|
||
.then((pRes) => {
|
||
item.previewUrl = pRes?.data?.url || ''
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
item.previewLoading = false
|
||
})
|
||
}
|
||
}
|
||
|
||
async function fetchInvoiceAttachPage(isRefresh) {
|
||
if (invLoading.value) return
|
||
invLoading.value = true
|
||
try {
|
||
const owner = form.applicantId || undefined
|
||
const res = await invoiceApi.list({
|
||
page: invPage.value,
|
||
pageSize: INVOICE_PAGE_SIZE,
|
||
userId: owner
|
||
})
|
||
const { rows, total } = pagedRows(res)
|
||
const chunk = buildAttachItemsFromInvoiceRows(rows)
|
||
if (isRefresh) attachItems.value = chunk
|
||
else attachItems.value = attachItems.value.concat(chunk)
|
||
kickAttachPreviewLoads(chunk)
|
||
const invoiceCountSoFar =
|
||
(isRefresh ? 0 : (invPage.value - 1) * INVOICE_PAGE_SIZE) + rows.length
|
||
if (rows.length < INVOICE_PAGE_SIZE || invoiceCountSoFar >= total) {
|
||
invFinished.value = true
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '发票列表加载失败', icon: 'none' })
|
||
if (isRefresh) invFinished.value = true
|
||
} finally {
|
||
invLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function resetAndLoadInvoices() {
|
||
invPage.value = 1
|
||
invFinished.value = false
|
||
attachItems.value = []
|
||
await fetchInvoiceAttachPage(true)
|
||
invPage.value = 2
|
||
}
|
||
|
||
async function onInvoicePickerLoadMore() {
|
||
if (invFinished.value || invLoading.value) return
|
||
await fetchInvoiceAttachPage(false)
|
||
invPage.value += 1
|
||
}
|
||
|
||
async function openInvoicePicker() {
|
||
pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
|
||
showInvoicePicker.value = true
|
||
if (!attachItems.value.length && !invFinished.value) {
|
||
await resetAndLoadInvoices()
|
||
}
|
||
}
|
||
|
||
function togglePickerKey(key) {
|
||
const i = pickerDraftKeys.value.indexOf(key)
|
||
if (i >= 0) pickerDraftKeys.value.splice(i, 1)
|
||
else pickerDraftKeys.value.push(key)
|
||
}
|
||
|
||
function confirmInvoicePicker() {
|
||
selectedInvoicePicks.value = mergeInvoicePicksByDraftKeys(
|
||
pickerDraftKeys.value,
|
||
attachItems.value,
|
||
selectedInvoicePicks.value
|
||
)
|
||
showInvoicePicker.value = false
|
||
}
|
||
|
||
async function previewInvoicePick(pick) {
|
||
if (!isImageFileType(pick.fileType)) {
|
||
uni.showToast({ title: '非图片附件请在管理端查看', icon: 'none' })
|
||
return
|
||
}
|
||
let url = pick.previewUrl
|
||
if (!url && pick.attachmentId) {
|
||
try {
|
||
const pRes = await attachmentApi.preview(pick.attachmentId)
|
||
url = pRes?.data?.url || ''
|
||
if (url) pick.previewUrl = url
|
||
} catch (e) {
|
||
uni.showToast({ title: '暂无法预览', icon: 'none' })
|
||
return
|
||
}
|
||
}
|
||
if (url) uni.previewImage({ urls: [url], current: url })
|
||
else uni.showToast({ title: '暂无法预览', icon: 'none' })
|
||
}
|
||
|
||
function removeInvoicePick(key) {
|
||
selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
|
||
}
|
||
|
||
function validateReimburseApplyForm() {
|
||
for (let i = 0; i < form.details.length; i++) {
|
||
const d = form.details[i]
|
||
const row = i + 1
|
||
const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
|
||
const amountStr = String(d.amount ?? '').trim()
|
||
const hasAmount = amountStr !== ''
|
||
const amt = Number(amountStr)
|
||
const amountOk = hasAmount && !Number.isNaN(amt) && amt > 0
|
||
const hasRemark = !!d.remark?.trim()
|
||
|
||
if (!hasExpense && !hasAmount && !hasRemark) continue
|
||
|
||
if (!hasExpense) {
|
||
uni.showToast({ title: `第 ${row} 行:请选择费用项目`, icon: 'none' })
|
||
return null
|
||
}
|
||
if (!amountOk) {
|
||
uni.showToast({ title: `第 ${row} 行:请输入大于 0 的金额`, icon: 'none' })
|
||
return null
|
||
}
|
||
if (!d.remark?.trim()) {
|
||
uni.showToast({ title: `第 ${row} 行:请填写报销事由`, icon: 'none' })
|
||
return null
|
||
}
|
||
}
|
||
|
||
const validDetails = form.details.filter((d) => {
|
||
const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
|
||
const amt = Number(String(d.amount ?? '').trim())
|
||
return hasExpense && !Number.isNaN(amt) && amt > 0
|
||
})
|
||
if (validDetails.length === 0) {
|
||
uni.showToast({ title: '请至少填写一条完整的报销明细', icon: 'none' })
|
||
return null
|
||
}
|
||
if (!form.reimburseDate) {
|
||
uni.showToast({ title: '请选择报销日期', icon: 'none' })
|
||
return null
|
||
}
|
||
return validDetails
|
||
}
|
||
|
||
async function chooseAttachments() {
|
||
const remain = MAX_ATTACHMENTS - files.value.length
|
||
if (remain <= 0) {
|
||
uni.showToast({ title: `最多上传 ${MAX_ATTACHMENTS} 个附件`, icon: 'none' })
|
||
return
|
||
}
|
||
uni.chooseImage({
|
||
count: remain,
|
||
sizeType: ['compressed'],
|
||
sourceType: ['album', 'camera'],
|
||
success: async (res) => {
|
||
const paths = res.tempFilePaths || []
|
||
for (const path of paths) {
|
||
await uploadOne(path)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
async function uploadOne(filePath) {
|
||
const pendingId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
|
||
files.value.push({
|
||
id: pendingId,
|
||
previewUrl: filePath,
|
||
localPath: filePath,
|
||
_pending: true,
|
||
_existing: false
|
||
})
|
||
uploading.value = true
|
||
try {
|
||
const res = await attachmentApi.upload(filePath, '', 0)
|
||
const data = res?.data || {}
|
||
const id = String(data.id || '')
|
||
let previewUrl = filePath
|
||
if (id) {
|
||
try {
|
||
const pRes = await attachmentApi.preview(id)
|
||
if (pRes?.data?.url) previewUrl = pRes.data.url
|
||
} catch (e) {
|
||
// keep local
|
||
}
|
||
}
|
||
const idx = files.value.findIndex((f) => f.id === pendingId)
|
||
if (idx >= 0) {
|
||
files.value[idx] = {
|
||
id: id || pendingId,
|
||
previewUrl,
|
||
localPath: filePath,
|
||
fileName: data.fileName || data.file_name || '',
|
||
_pending: false,
|
||
_existing: false
|
||
}
|
||
}
|
||
} catch (e) {
|
||
files.value = files.value.filter((f) => f.id !== pendingId)
|
||
} finally {
|
||
uploading.value = files.value.some((f) => f._pending)
|
||
}
|
||
}
|
||
|
||
function removeFile(idx) {
|
||
const file = files.value[idx]
|
||
if (!file) return
|
||
if (file._existing && file.id && !String(file.id).startsWith('pending_')) {
|
||
attachmentApi.delete(file.id).catch(() => {})
|
||
}
|
||
files.value.splice(idx, 1)
|
||
}
|
||
|
||
async function bindNewAttachments(bizId) {
|
||
const newIds = files.value
|
||
.filter((f) => f.id && !f._existing && !f._pending && !String(f.id).startsWith('pending_'))
|
||
.map((f) => f.id)
|
||
if (!newIds.length) return
|
||
await attachmentApi.bindBiz(newIds, 'reimburse_apply', bizId)
|
||
files.value.forEach((f) => {
|
||
if (newIds.includes(f.id)) f._existing = true
|
||
})
|
||
}
|
||
|
||
async function loadExpenseItems() {
|
||
try {
|
||
const res = await expenseItemApi.tree()
|
||
const tree = (res.data || []).map(normalizeExpenseItem)
|
||
flatList.value = flattenExpenseTree(tree)
|
||
} catch (e) {
|
||
flatList.value = []
|
||
}
|
||
}
|
||
|
||
async function loadEditData(id) {
|
||
try {
|
||
const res = await reimburseApi.getById(id)
|
||
const data = res?.data || {}
|
||
form.title = data.title || ''
|
||
form.remark = data.remark || ''
|
||
form.applicantId = String(data.applicantId || userStore.userInfo?.id || '')
|
||
form.reimburseDate = data.reimburseDate || todayYmd()
|
||
form.details = (data.details || []).map((d) => ({
|
||
expenseItemId: d.expenseId ?? d.expenseItemId ?? '',
|
||
amount: String(d.amount ?? ''),
|
||
remark: d.remark || '',
|
||
occurDate: d.occurDate || ''
|
||
}))
|
||
if (!form.details.length) {
|
||
form.details = [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
|
||
}
|
||
|
||
await refreshWallet()
|
||
const linked = data.invoices || []
|
||
const picks = []
|
||
for (const inv of linked) {
|
||
const invoiceId = String(inv.id || inv.invoiceId || '')
|
||
if (!invoiceId) continue
|
||
let attachmentId = String(inv.attachmentId || inv.attachment_id || '')
|
||
let fileName = inv.fileName || inv.file_name || '发票附件'
|
||
let fileType = inv.fileType || inv.file_type || ''
|
||
const createTime = inv.createTime || inv.create_time || ''
|
||
if (!attachmentId) {
|
||
const src = attachItems.value.find((w) => String(w.invoiceId) === invoiceId)
|
||
if (src) {
|
||
attachmentId = src.attachmentId
|
||
fileName = src.fileName
|
||
fileType = src.fileType
|
||
}
|
||
}
|
||
if (!attachmentId) continue
|
||
picks.push({
|
||
key: `${invoiceId}:${attachmentId}`,
|
||
invoiceId,
|
||
attachmentId,
|
||
amount: inv.amount,
|
||
createTime,
|
||
fileName,
|
||
fileType,
|
||
previewUrl: '',
|
||
previewLoading: false
|
||
})
|
||
}
|
||
selectedInvoicePicks.value = picks
|
||
kickAttachPreviewLoads(selectedInvoicePicks.value)
|
||
|
||
try {
|
||
const attRes = await attachmentApi.listByBiz('reimburse_apply', id)
|
||
const list = attRes?.data || []
|
||
const mapped = []
|
||
for (const a of list) {
|
||
let previewUrl = ''
|
||
if ((a.fileType || '').startsWith('image/')) {
|
||
try {
|
||
const pRes = await attachmentApi.preview(a.id)
|
||
previewUrl = pRes?.data?.url || ''
|
||
} catch (e) {
|
||
// skip
|
||
}
|
||
}
|
||
mapped.push({
|
||
id: String(a.id),
|
||
previewUrl,
|
||
fileName: a.fileName || a.file_name || '',
|
||
_pending: false,
|
||
_existing: true
|
||
})
|
||
}
|
||
files.value = mapped
|
||
} catch (e) {
|
||
files.value = []
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '加载数据失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
async function onSubmit() {
|
||
const validDetails = validateReimburseApplyForm()
|
||
if (!validDetails) return
|
||
if (uploading.value) {
|
||
uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
const isEditingAction = !!editId.value || !!savedId.value
|
||
try {
|
||
const invoicePayload = buildInvoicePayload()
|
||
const payload = {
|
||
title: form.title || undefined,
|
||
remark: form.remark || undefined,
|
||
applicantId: form.applicantId || undefined,
|
||
reimburseDate: form.reimburseDate || undefined,
|
||
details: validDetails.map((d) => ({
|
||
expenseItemId: String(d.expenseItemId),
|
||
amount: Number(d.amount),
|
||
remark: String(d.remark).trim(),
|
||
occurDate: d.occurDate ? String(d.occurDate) : undefined
|
||
})),
|
||
...invoicePayload
|
||
}
|
||
|
||
let id
|
||
if (editId.value || savedId.value) {
|
||
id = savedId.value || editId.value
|
||
await reimburseApi.update(id, payload)
|
||
} else {
|
||
const res = await reimburseApi.create(payload)
|
||
id = String(res?.data?.id ?? res?.id ?? '')
|
||
if (!id) throw new Error('保存成功但未返回报销单ID')
|
||
savedId.value = id
|
||
editId.value = id
|
||
}
|
||
|
||
try {
|
||
await bindNewAttachments(id)
|
||
} catch (e) {
|
||
uni.showToast({ title: '报销单已保存,但附件关联失败', icon: 'none' })
|
||
}
|
||
|
||
uni.showModal({
|
||
title: isEditingAction ? '保存成功' : '创建成功',
|
||
content: '报销单已保存为草稿',
|
||
confirmText: '提交审批',
|
||
confirmColor: '#00a874',
|
||
cancelText: '继续编辑',
|
||
success: async (modalRes) => {
|
||
if (!modalRes.confirm) return
|
||
try {
|
||
await reimburseApi.submit(id, invoicePayload)
|
||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||
setTimeout(() => goBack(), 500)
|
||
} catch (e) {
|
||
// toasted
|
||
}
|
||
}
|
||
})
|
||
} catch (e) {
|
||
// toasted by bxRequest
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
onLoad(async (query) => {
|
||
const uid = String(userStore.userInfo?.id || userStore.userInfo?.userId || '')
|
||
form.applicantId = uid
|
||
await loadExpenseItems()
|
||
await refreshWallet()
|
||
const id = query?.id ? String(query.id) : ''
|
||
if (id) {
|
||
editId.value = id
|
||
await loadEditData(id)
|
||
} else {
|
||
await refreshTitle()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
@import '@/styles/bx-theme.scss';
|
||
</style>
|