| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894 |
- <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' })
- } 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) {
- 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
- const attachmentId = String(inv.attachmentId || inv.attachment_id || '')
- if (!attachmentId) continue
- picks.push({
- key: `${invoiceId}:${attachmentId}`,
- invoiceId,
- attachmentId,
- amount: inv.amount,
- createTime: inv.createTime || inv.create_time || '',
- fileName: inv.fileName || inv.file_name || '发票附件',
- fileType: inv.fileType || inv.file_type || '',
- 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>
|