apply.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. <template>
  2. <view class="bx-page bx-page--form">
  3. <view class="topbar" :style="safeAreaStyle">
  4. <view class="topbar-left">
  5. <text class="topbar-back" @click="goBack">←</text>
  6. <text class="topbar-title">报销申请</text>
  7. </view>
  8. <view class="topbar-right" />
  9. </view>
  10. <view class="page-padded">
  11. <view class="form-card">
  12. <view class="form-header">
  13. <text class="form-header-title">报销信息</text>
  14. <text class="form-badge">{{ isEdit ? '编辑' : '新建' }}</text>
  15. </view>
  16. <view class="field">
  17. <text class="field-label">申请人</text>
  18. <view class="form-input" style="display: flex; align-items: center">
  19. {{ applicantLabel || '当前用户' }}
  20. </view>
  21. </view>
  22. <view class="field">
  23. <text class="field-label">报销日期<text class="req">*</text></text>
  24. <picker mode="date" :value="form.reimburseDate" @change="onDateChange">
  25. <view class="picker-trigger">
  26. <text class="value" :class="{ ph: !form.reimburseDate }">
  27. {{ form.reimburseDate || '请选择报销日期' }}
  28. </text>
  29. <text class="arrow">›</text>
  30. </view>
  31. </picker>
  32. </view>
  33. <view class="field">
  34. <text class="field-label">报销标题</text>
  35. <input
  36. class="form-input"
  37. :value="form.title"
  38. placeholder="不填则自动生成"
  39. @input="onTitleInput"
  40. />
  41. </view>
  42. </view>
  43. <view class="form-card">
  44. <view class="form-header">
  45. <text class="form-header-title">报销明细</text>
  46. <text class="form-badge">{{ form.details.length }}行</text>
  47. </view>
  48. <view v-for="(line, idx) in form.details" :key="idx" class="detail-line">
  49. <view v-if="idx > 0" class="detail-divider" />
  50. <view class="field">
  51. <text class="field-label">
  52. 费用项目
  53. <text v-if="form.details.length > 1" class="detail-line-no">
  54. (第 {{ idx + 1 }} 条)
  55. </text>
  56. <text class="req">*</text>
  57. </text>
  58. <view class="picker-trigger" @click="openExpensePicker(idx)">
  59. <text
  60. class="value"
  61. :class="{ ph: !lineExpenseDisplay(line.expenseItemId) }"
  62. >
  63. {{ lineExpenseDisplay(line.expenseItemId) || '请选择费用项目' }}
  64. </text>
  65. <text class="arrow">›</text>
  66. </view>
  67. </view>
  68. <view class="field">
  69. <text class="field-label">金额(元)<text class="req">*</text></text>
  70. <view class="form-input-prefix">
  71. <input
  72. class="form-input"
  73. type="digit"
  74. :value="line.amount"
  75. placeholder="0.00"
  76. :data-idx="idx"
  77. @input="onLineAmount"
  78. />
  79. </view>
  80. </view>
  81. <view class="field">
  82. <text class="field-label">报销事由<text class="req">*</text></text>
  83. <input
  84. class="form-input"
  85. :value="line.remark"
  86. placeholder="请输入报销事由"
  87. :data-idx="idx"
  88. @input="onLineRemark"
  89. />
  90. </view>
  91. <view class="field">
  92. <text class="field-label">发生时间</text>
  93. <picker
  94. mode="date"
  95. :value="line.occurDate || form.reimburseDate"
  96. :data-idx="idx"
  97. @change="onOccurDate"
  98. >
  99. <view class="picker-trigger">
  100. <text class="value" :class="{ ph: !line.occurDate }">
  101. {{ line.occurDate || '选填' }}
  102. </text>
  103. <text class="arrow">›</text>
  104. </view>
  105. </picker>
  106. </view>
  107. <view v-if="form.details.length > 1" class="detail-line-actions">
  108. <button class="btn btn-sm btn-danger" @click="removeDetailLine(idx)">
  109. 删除此行
  110. </button>
  111. </view>
  112. </view>
  113. <view class="detail-actions">
  114. <button class="btn btn-primary btn-sm" @click="addDetailLine">+ 新增明细行</button>
  115. <text class="total-amount">合计:<text style="font-weight: 700">¥{{ detailTotalAmount }}</text></text>
  116. </view>
  117. </view>
  118. <view class="form-card">
  119. <view class="form-header">
  120. <text class="form-header-title">关联发票(可选)</text>
  121. </view>
  122. <view class="invoice-balance-tip">
  123. 发票余额 ¥{{ money(walletBalance) }} · 本次合计 ¥{{ detailTotalAmount }}
  124. <text v-if="balanceInsufficient" class="invoice-balance-tip--warn">
  125. (余额不足,提交后余额将为负,请后续补发票)
  126. </text>
  127. </view>
  128. <button class="btn btn-primary btn-sm" @click="openInvoicePicker">从发票夹选择</button>
  129. <view v-if="selectedInvoicePicks.length" class="invoice-thumb-grid">
  130. <view
  131. v-for="pick in selectedInvoicePicks"
  132. :key="pick.key"
  133. class="invoice-thumb"
  134. >
  135. <view class="invoice-thumb__del" @click.stop="removeInvoicePick(pick.key)">×</view>
  136. <view class="invoice-thumb__media" @click="previewInvoicePick(pick)">
  137. <image
  138. v-if="isImageFileType(pick.fileType) && pick.previewUrl"
  139. :src="pick.previewUrl"
  140. mode="aspectFill"
  141. />
  142. <view v-else class="invoice-thumb__doc">
  143. <text class="invoice-thumb__doc-ext">{{ fileExtLabel(pick.fileName) }}</text>
  144. </view>
  145. </view>
  146. <view class="invoice-thumb__meta">
  147. <text class="invoice-thumb__amt">¥{{ money(pick.amount) }}</text>
  148. <text class="invoice-thumb__date">{{ formatInvoiceDateYmd(pick.createTime) }}</text>
  149. </view>
  150. </view>
  151. </view>
  152. <view class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</view>
  153. </view>
  154. <view class="form-card">
  155. <view class="form-header">
  156. <text class="form-header-title">申请附件(可选)</text>
  157. </view>
  158. <button
  159. class="btn btn-primary btn-sm"
  160. :disabled="uploading"
  161. @click="chooseAttachments"
  162. >
  163. {{ uploading ? '上传中…' : '上传图片' }}
  164. </button>
  165. <view v-if="files.length" class="att-grid">
  166. <view v-for="(file, idx) in files" :key="file.id" class="att-img att-img--with-name">
  167. <image :src="file.previewUrl || file.localPath" mode="aspectFill" />
  168. <view v-if="!file._pending" class="del" @click.stop="removeFile(idx)">×</view>
  169. <text class="att-img__name">{{ attachmentDisplayName(file, idx) }}</text>
  170. </view>
  171. </view>
  172. <view v-else class="detail-section-empty" style="margin-top: 8px">暂无附件(可选)</view>
  173. </view>
  174. <view class="form-card">
  175. <view class="form-header">
  176. <text class="form-header-title">报销说明</text>
  177. </view>
  178. <view class="field">
  179. <textarea
  180. class="form-textarea"
  181. :value="form.remark"
  182. placeholder="请输入报销说明"
  183. :maxlength="500"
  184. @input="onRemarkInput"
  185. />
  186. </view>
  187. </view>
  188. </view>
  189. <view class="bottom-action-bar">
  190. <button class="btn btn-primary btn-block" :disabled="submitting" @click="onSubmit">
  191. {{ submitting ? '保存中…' : '保存' }}
  192. </button>
  193. </view>
  194. <!-- 费用项目选择 -->
  195. <view v-if="showExpensePicker" class="bx-sheet-mask" @click="showExpensePicker = false">
  196. <view class="bx-sheet" @click.stop>
  197. <view class="bx-sheet__title">选择费用项目</view>
  198. <scroll-view scroll-y class="bx-sheet__body">
  199. <view v-if="!expenseOptions.length" class="bx-sheet__empty">暂无可用费用项目</view>
  200. <view
  201. v-for="opt in expenseOptions"
  202. :key="opt.id"
  203. class="bx-sheet__item"
  204. :class="{ 'is-selected': String(form.details[pickingLineIndex]?.expenseItemId) === opt.id }"
  205. @click="confirmExpensePick(opt.id)"
  206. >
  207. {{ opt.label }}
  208. </view>
  209. </scroll-view>
  210. </view>
  211. </view>
  212. <!-- 发票多选(缩略图网格 + 分页) -->
  213. <view v-if="showInvoicePicker" class="bx-sheet-mask" @click="showInvoicePicker = false">
  214. <view class="bx-sheet" @click.stop>
  215. <view class="bx-sheet__title">选择发票附件</view>
  216. <scroll-view
  217. scroll-y
  218. class="bx-sheet__body bx-sheet__body--invoice-thumbs"
  219. @scrolltolower="onInvoicePickerLoadMore"
  220. lower-threshold="80"
  221. >
  222. <view v-if="!attachItems.length && !invLoading" class="bx-sheet__empty">
  223. 暂无发票附件
  224. </view>
  225. <view v-else class="invoice-pick-grid">
  226. <view
  227. v-for="item in attachItems"
  228. :key="item.key"
  229. class="invoice-pick-card"
  230. :class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
  231. @click="togglePickerKey(item.key)"
  232. >
  233. <view class="invoice-pick-card__check">
  234. <text v-if="pickerDraftKeys.includes(item.key)">✓</text>
  235. </view>
  236. <view class="invoice-pick-card__preview">
  237. <image
  238. v-if="isImageFileType(item.fileType) && item.previewUrl"
  239. :src="item.previewUrl"
  240. mode="aspectFill"
  241. />
  242. <view v-else class="invoice-pick-card__doc">
  243. <text class="invoice-thumb__doc-ext">{{ fileExtLabel(item.fileName) }}</text>
  244. </view>
  245. </view>
  246. <view class="invoice-pick-card__meta">
  247. <view class="invoice-pick-card__amt">¥{{ money(item.amount) }}</view>
  248. <view class="invoice-pick-card__date">
  249. {{ formatInvoiceDateYmd(item.createTime) }}
  250. </view>
  251. </view>
  252. </view>
  253. </view>
  254. <view v-if="attachItems.length" class="invoice-pick-footer-tip">
  255. <text v-if="invLoading">加载中…</text>
  256. <text v-else-if="invFinished">没有更多了</text>
  257. <text v-else>上拉加载更多</text>
  258. </view>
  259. </scroll-view>
  260. <view class="bx-sheet__footer">
  261. <button class="btn btn-primary btn-block" @click="confirmInvoicePicker">
  262. 确定(已选 {{ pickerDraftKeys.length }})
  263. </button>
  264. </view>
  265. </view>
  266. </view>
  267. </view>
  268. </template>
  269. <script setup>
  270. import { ref, reactive, computed } from 'vue'
  271. import { onLoad } from '@dcloudio/uni-app'
  272. import { PAGE_PATHS } from '@/config/api'
  273. import { useUserStore } from '@/stores/user'
  274. import {
  275. reimburseApi,
  276. attachmentApi,
  277. expenseItemApi,
  278. invoiceWalletApi,
  279. invoiceApi
  280. } from '@/api/bx'
  281. import {
  282. normalizeExpenseItem,
  283. flattenExpenseTree,
  284. formatExpenseName
  285. } from '@/utils/bxFormat'
  286. import {
  287. isImageFileType,
  288. fileExtLabel,
  289. formatInvoiceDateYmd,
  290. buildAttachItemsFromInvoiceRows,
  291. mergeInvoicePicksByDraftKeys
  292. } from '@/utils/bxInvoiceThumb'
  293. import { getSafeAreaStyle } from '@/utils/safeArea'
  294. import { buildApplyAttachmentDisplayName } from '@/utils/bxAttachmentDisplayName'
  295. const safeAreaStyle = getSafeAreaStyle()
  296. const MAX_ATTACHMENTS = 10
  297. const userStore = useUserStore()
  298. const editId = ref('')
  299. const savedId = ref('')
  300. const submitting = ref(false)
  301. const uploading = ref(false)
  302. const files = ref([])
  303. function todayYmd() {
  304. const d = new Date()
  305. const m = String(d.getMonth() + 1).padStart(2, '0')
  306. const day = String(d.getDate()).padStart(2, '0')
  307. return `${d.getFullYear()}-${m}-${day}`
  308. }
  309. const form = reactive({
  310. title: '',
  311. remark: '',
  312. applicantId: '',
  313. reimburseDate: todayYmd(),
  314. details: [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
  315. })
  316. const flatList = ref([])
  317. const showExpensePicker = ref(false)
  318. const pickingLineIndex = ref(0)
  319. const walletBalance = ref(0)
  320. const INVOICE_PAGE_SIZE = 10
  321. const attachItems = ref([])
  322. const invPage = ref(1)
  323. const invLoading = ref(false)
  324. const invFinished = ref(false)
  325. const selectedInvoicePicks = ref([])
  326. const pickerDraftKeys = ref([])
  327. const showInvoicePicker = ref(false)
  328. const isEdit = computed(() => !!editId.value || !!savedId.value)
  329. const applicantLabel = computed(() => {
  330. const u = userStore.userInfo || {}
  331. return u.realName || u.username || u.nickName || u.name || ''
  332. })
  333. const expenseOptions = computed(() => {
  334. return (flatList.value || [])
  335. .filter((i) => i.status === 1 || i.status === '1')
  336. .map((i) => ({
  337. id: String(i.id),
  338. label: formatExpenseName(i.id, flatList.value)
  339. }))
  340. })
  341. const detailTotalNumber = computed(() =>
  342. form.details.reduce((sum, d) => sum + Number(d.amount || 0), 0)
  343. )
  344. const detailTotalAmount = computed(() =>
  345. detailTotalNumber.value.toLocaleString('zh-CN', {
  346. minimumFractionDigits: 2,
  347. maximumFractionDigits: 2
  348. })
  349. )
  350. const balanceInsufficient = computed(
  351. () => detailTotalNumber.value > 0 && walletBalance.value < detailTotalNumber.value
  352. )
  353. function money(v) {
  354. return Number(v || 0).toLocaleString('zh-CN', {
  355. minimumFractionDigits: 2,
  356. maximumFractionDigits: 2
  357. })
  358. }
  359. function attachmentDisplayName(file, index) {
  360. return buildApplyAttachmentDisplayName({
  361. remark: form.remark || '',
  362. originalFileName: file.fileName || '',
  363. index1: index + 1
  364. })
  365. }
  366. function lineExpenseDisplay(id) {
  367. if (id === '' || id == null) return ''
  368. const name = formatExpenseName(id, flatList.value)
  369. return name === '-' ? '' : name
  370. }
  371. function onTitleInput(e) {
  372. form.title = e.detail.value
  373. }
  374. function onRemarkInput(e) {
  375. form.remark = e.detail.value
  376. }
  377. function onLineAmount(e) {
  378. const idx = Number(e.currentTarget.dataset.idx)
  379. if (Number.isNaN(idx) || !form.details[idx]) return
  380. form.details[idx].amount = e.detail.value
  381. }
  382. function onLineRemark(e) {
  383. const idx = Number(e.currentTarget.dataset.idx)
  384. if (Number.isNaN(idx) || !form.details[idx]) return
  385. form.details[idx].remark = e.detail.value
  386. }
  387. function onOccurDate(e) {
  388. const idx = Number(e.currentTarget.dataset.idx)
  389. if (Number.isNaN(idx) || !form.details[idx]) return
  390. form.details[idx].occurDate = e.detail.value
  391. }
  392. function onDateChange(e) {
  393. form.reimburseDate = e.detail.value
  394. refreshTitle()
  395. }
  396. function goBack() {
  397. uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
  398. }
  399. function addDetailLine() {
  400. form.details.push({ expenseItemId: '', amount: '', remark: '', occurDate: '' })
  401. }
  402. function removeDetailLine(index) {
  403. form.details.splice(index, 1)
  404. }
  405. function openExpensePicker(idx) {
  406. pickingLineIndex.value = idx
  407. showExpensePicker.value = true
  408. }
  409. function confirmExpensePick(id) {
  410. form.details[pickingLineIndex.value].expenseItemId = String(id)
  411. showExpensePicker.value = false
  412. }
  413. function buildInvoicePayload() {
  414. const invoiceLinks = selectedInvoicePicks.value.map((p) => ({
  415. invoiceId: String(p.invoiceId),
  416. attachmentId: String(p.attachmentId)
  417. }))
  418. const invoiceIds = Array.from(new Set(invoiceLinks.map((p) => p.invoiceId)))
  419. return { invoiceIds, invoiceLinks }
  420. }
  421. async function refreshTitle() {
  422. if (!form.reimburseDate) return
  423. try {
  424. const params = { reimburseDate: form.reimburseDate }
  425. const exclude = editId.value || savedId.value
  426. if (exclude) params.excludeApplyId = exclude
  427. if (form.applicantId) params.applicantId = form.applicantId
  428. const res = await reimburseApi.generateTitle(params)
  429. const t = res?.data
  430. form.title = typeof t === 'string' ? t : t?.title || form.title
  431. } catch (e) {
  432. // keep
  433. }
  434. }
  435. async function refreshWallet() {
  436. try {
  437. const owner = form.applicantId || undefined
  438. const sumRes = await invoiceWalletApi.mineSummary(
  439. owner ? { userId: owner } : undefined
  440. )
  441. walletBalance.value = Number(sumRes?.data?.balance ?? 0)
  442. } catch (e) {
  443. // ignore
  444. }
  445. }
  446. function pagedRows(res) {
  447. const d = res?.data
  448. if (!d) return { rows: [], total: 0 }
  449. if (Array.isArray(d)) return { rows: d, total: d.length }
  450. const rows = d.rows ?? d.items ?? d.list ?? []
  451. const total = typeof d.total === 'number' ? d.total : rows.length
  452. return { rows, total }
  453. }
  454. function kickAttachPreviewLoads(items) {
  455. for (const item of items) {
  456. if (!isImageFileType(item.fileType) || item.previewUrl || item.previewLoading) continue
  457. item.previewLoading = true
  458. attachmentApi
  459. .preview(item.attachmentId)
  460. .then((pRes) => {
  461. item.previewUrl = pRes?.data?.url || ''
  462. })
  463. .catch(() => {})
  464. .finally(() => {
  465. item.previewLoading = false
  466. })
  467. }
  468. }
  469. async function fetchInvoiceAttachPage(isRefresh) {
  470. if (invLoading.value) return
  471. invLoading.value = true
  472. try {
  473. const owner = form.applicantId || undefined
  474. const res = await invoiceApi.list({
  475. page: invPage.value,
  476. pageSize: INVOICE_PAGE_SIZE,
  477. userId: owner
  478. })
  479. const { rows, total } = pagedRows(res)
  480. const chunk = buildAttachItemsFromInvoiceRows(rows)
  481. if (isRefresh) attachItems.value = chunk
  482. else attachItems.value = attachItems.value.concat(chunk)
  483. kickAttachPreviewLoads(chunk)
  484. const invoiceCountSoFar =
  485. (isRefresh ? 0 : (invPage.value - 1) * INVOICE_PAGE_SIZE) + rows.length
  486. if (rows.length < INVOICE_PAGE_SIZE || invoiceCountSoFar >= total) {
  487. invFinished.value = true
  488. }
  489. } catch (e) {
  490. uni.showToast({ title: '发票列表加载失败', icon: 'none' })
  491. } finally {
  492. invLoading.value = false
  493. }
  494. }
  495. async function resetAndLoadInvoices() {
  496. invPage.value = 1
  497. invFinished.value = false
  498. attachItems.value = []
  499. await fetchInvoiceAttachPage(true)
  500. invPage.value = 2
  501. }
  502. async function onInvoicePickerLoadMore() {
  503. if (invFinished.value || invLoading.value) return
  504. await fetchInvoiceAttachPage(false)
  505. invPage.value += 1
  506. }
  507. async function openInvoicePicker() {
  508. pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
  509. showInvoicePicker.value = true
  510. if (!attachItems.value.length) {
  511. await resetAndLoadInvoices()
  512. }
  513. }
  514. function togglePickerKey(key) {
  515. const i = pickerDraftKeys.value.indexOf(key)
  516. if (i >= 0) pickerDraftKeys.value.splice(i, 1)
  517. else pickerDraftKeys.value.push(key)
  518. }
  519. function confirmInvoicePicker() {
  520. selectedInvoicePicks.value = mergeInvoicePicksByDraftKeys(
  521. pickerDraftKeys.value,
  522. attachItems.value,
  523. selectedInvoicePicks.value
  524. )
  525. showInvoicePicker.value = false
  526. }
  527. async function previewInvoicePick(pick) {
  528. if (!isImageFileType(pick.fileType)) {
  529. uni.showToast({ title: '非图片附件请在管理端查看', icon: 'none' })
  530. return
  531. }
  532. let url = pick.previewUrl
  533. if (!url && pick.attachmentId) {
  534. try {
  535. const pRes = await attachmentApi.preview(pick.attachmentId)
  536. url = pRes?.data?.url || ''
  537. if (url) pick.previewUrl = url
  538. } catch (e) {
  539. uni.showToast({ title: '暂无法预览', icon: 'none' })
  540. return
  541. }
  542. }
  543. if (url) uni.previewImage({ urls: [url], current: url })
  544. else uni.showToast({ title: '暂无法预览', icon: 'none' })
  545. }
  546. function removeInvoicePick(key) {
  547. selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
  548. }
  549. function validateReimburseApplyForm() {
  550. for (let i = 0; i < form.details.length; i++) {
  551. const d = form.details[i]
  552. const row = i + 1
  553. const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
  554. const amountStr = String(d.amount ?? '').trim()
  555. const hasAmount = amountStr !== ''
  556. const amt = Number(amountStr)
  557. const amountOk = hasAmount && !Number.isNaN(amt) && amt > 0
  558. const hasRemark = !!d.remark?.trim()
  559. if (!hasExpense && !hasAmount && !hasRemark) continue
  560. if (!hasExpense) {
  561. uni.showToast({ title: `第 ${row} 行:请选择费用项目`, icon: 'none' })
  562. return null
  563. }
  564. if (!amountOk) {
  565. uni.showToast({ title: `第 ${row} 行:请输入大于 0 的金额`, icon: 'none' })
  566. return null
  567. }
  568. if (!d.remark?.trim()) {
  569. uni.showToast({ title: `第 ${row} 行:请填写报销事由`, icon: 'none' })
  570. return null
  571. }
  572. }
  573. const validDetails = form.details.filter((d) => {
  574. const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
  575. const amt = Number(String(d.amount ?? '').trim())
  576. return hasExpense && !Number.isNaN(amt) && amt > 0
  577. })
  578. if (validDetails.length === 0) {
  579. uni.showToast({ title: '请至少填写一条完整的报销明细', icon: 'none' })
  580. return null
  581. }
  582. if (!form.reimburseDate) {
  583. uni.showToast({ title: '请选择报销日期', icon: 'none' })
  584. return null
  585. }
  586. return validDetails
  587. }
  588. async function chooseAttachments() {
  589. const remain = MAX_ATTACHMENTS - files.value.length
  590. if (remain <= 0) {
  591. uni.showToast({ title: `最多上传 ${MAX_ATTACHMENTS} 个附件`, icon: 'none' })
  592. return
  593. }
  594. uni.chooseImage({
  595. count: remain,
  596. sizeType: ['compressed'],
  597. sourceType: ['album', 'camera'],
  598. success: async (res) => {
  599. const paths = res.tempFilePaths || []
  600. for (const path of paths) {
  601. await uploadOne(path)
  602. }
  603. }
  604. })
  605. }
  606. async function uploadOne(filePath) {
  607. const pendingId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
  608. files.value.push({
  609. id: pendingId,
  610. previewUrl: filePath,
  611. localPath: filePath,
  612. _pending: true,
  613. _existing: false
  614. })
  615. uploading.value = true
  616. try {
  617. const res = await attachmentApi.upload(filePath, '', 0)
  618. const data = res?.data || {}
  619. const id = String(data.id || '')
  620. let previewUrl = filePath
  621. if (id) {
  622. try {
  623. const pRes = await attachmentApi.preview(id)
  624. if (pRes?.data?.url) previewUrl = pRes.data.url
  625. } catch (e) {
  626. // keep local
  627. }
  628. }
  629. const idx = files.value.findIndex((f) => f.id === pendingId)
  630. if (idx >= 0) {
  631. files.value[idx] = {
  632. id: id || pendingId,
  633. previewUrl,
  634. localPath: filePath,
  635. fileName: data.fileName || data.file_name || '',
  636. _pending: false,
  637. _existing: false
  638. }
  639. }
  640. } catch (e) {
  641. files.value = files.value.filter((f) => f.id !== pendingId)
  642. } finally {
  643. uploading.value = files.value.some((f) => f._pending)
  644. }
  645. }
  646. function removeFile(idx) {
  647. const file = files.value[idx]
  648. if (!file) return
  649. if (file._existing && file.id && !String(file.id).startsWith('pending_')) {
  650. attachmentApi.delete(file.id).catch(() => {})
  651. }
  652. files.value.splice(idx, 1)
  653. }
  654. async function bindNewAttachments(bizId) {
  655. const newIds = files.value
  656. .filter((f) => f.id && !f._existing && !f._pending && !String(f.id).startsWith('pending_'))
  657. .map((f) => f.id)
  658. if (!newIds.length) return
  659. await attachmentApi.bindBiz(newIds, 'reimburse_apply', bizId)
  660. files.value.forEach((f) => {
  661. if (newIds.includes(f.id)) f._existing = true
  662. })
  663. }
  664. async function loadExpenseItems() {
  665. try {
  666. const res = await expenseItemApi.tree()
  667. const tree = (res.data || []).map(normalizeExpenseItem)
  668. flatList.value = flattenExpenseTree(tree)
  669. } catch (e) {
  670. flatList.value = []
  671. }
  672. }
  673. async function loadEditData(id) {
  674. try {
  675. const res = await reimburseApi.getById(id)
  676. const data = res?.data || {}
  677. form.title = data.title || ''
  678. form.remark = data.remark || ''
  679. form.applicantId = String(data.applicantId || userStore.userInfo?.id || '')
  680. form.reimburseDate = data.reimburseDate || todayYmd()
  681. form.details = (data.details || []).map((d) => ({
  682. expenseItemId: d.expenseId ?? d.expenseItemId ?? '',
  683. amount: String(d.amount ?? ''),
  684. remark: d.remark || '',
  685. occurDate: d.occurDate || ''
  686. }))
  687. if (!form.details.length) {
  688. form.details = [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
  689. }
  690. await refreshWallet()
  691. const linked = data.invoices || []
  692. const picks = []
  693. for (const inv of linked) {
  694. const invoiceId = String(inv.id || inv.invoiceId || '')
  695. if (!invoiceId) continue
  696. const attachmentId = String(inv.attachmentId || inv.attachment_id || '')
  697. if (!attachmentId) continue
  698. picks.push({
  699. key: `${invoiceId}:${attachmentId}`,
  700. invoiceId,
  701. attachmentId,
  702. amount: inv.amount,
  703. createTime: inv.createTime || inv.create_time || '',
  704. fileName: inv.fileName || inv.file_name || '发票附件',
  705. fileType: inv.fileType || inv.file_type || '',
  706. previewUrl: '',
  707. previewLoading: false
  708. })
  709. }
  710. selectedInvoicePicks.value = picks
  711. kickAttachPreviewLoads(selectedInvoicePicks.value)
  712. try {
  713. const attRes = await attachmentApi.listByBiz('reimburse_apply', id)
  714. const list = attRes?.data || []
  715. const mapped = []
  716. for (const a of list) {
  717. let previewUrl = ''
  718. if ((a.fileType || '').startsWith('image/')) {
  719. try {
  720. const pRes = await attachmentApi.preview(a.id)
  721. previewUrl = pRes?.data?.url || ''
  722. } catch (e) {
  723. // skip
  724. }
  725. }
  726. mapped.push({
  727. id: String(a.id),
  728. previewUrl,
  729. fileName: a.fileName || a.file_name || '',
  730. _pending: false,
  731. _existing: true
  732. })
  733. }
  734. files.value = mapped
  735. } catch (e) {
  736. files.value = []
  737. }
  738. } catch (e) {
  739. uni.showToast({ title: '加载数据失败', icon: 'none' })
  740. }
  741. }
  742. async function onSubmit() {
  743. const validDetails = validateReimburseApplyForm()
  744. if (!validDetails) return
  745. if (uploading.value) {
  746. uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
  747. return
  748. }
  749. submitting.value = true
  750. const isEditingAction = !!editId.value || !!savedId.value
  751. try {
  752. const invoicePayload = buildInvoicePayload()
  753. const payload = {
  754. title: form.title || undefined,
  755. remark: form.remark || undefined,
  756. applicantId: form.applicantId || undefined,
  757. reimburseDate: form.reimburseDate || undefined,
  758. details: validDetails.map((d) => ({
  759. expenseItemId: String(d.expenseItemId),
  760. amount: Number(d.amount),
  761. remark: String(d.remark).trim(),
  762. occurDate: d.occurDate ? String(d.occurDate) : undefined
  763. })),
  764. ...invoicePayload
  765. }
  766. let id
  767. if (editId.value || savedId.value) {
  768. id = savedId.value || editId.value
  769. await reimburseApi.update(id, payload)
  770. } else {
  771. const res = await reimburseApi.create(payload)
  772. id = String(res?.data?.id ?? res?.id ?? '')
  773. if (!id) throw new Error('保存成功但未返回报销单ID')
  774. savedId.value = id
  775. editId.value = id
  776. }
  777. try {
  778. await bindNewAttachments(id)
  779. } catch (e) {
  780. uni.showToast({ title: '报销单已保存,但附件关联失败', icon: 'none' })
  781. }
  782. uni.showModal({
  783. title: isEditingAction ? '保存成功' : '创建成功',
  784. content: '报销单已保存为草稿',
  785. confirmText: '提交审批',
  786. confirmColor: '#00a874',
  787. cancelText: '继续编辑',
  788. success: async (modalRes) => {
  789. if (!modalRes.confirm) return
  790. try {
  791. await reimburseApi.submit(id, invoicePayload)
  792. uni.showToast({ title: '提交成功', icon: 'success' })
  793. setTimeout(() => goBack(), 500)
  794. } catch (e) {
  795. // toasted
  796. }
  797. }
  798. })
  799. } catch (e) {
  800. // toasted by bxRequest
  801. } finally {
  802. submitting.value = false
  803. }
  804. }
  805. onLoad(async (query) => {
  806. const uid = String(userStore.userInfo?.id || userStore.userInfo?.userId || '')
  807. form.applicantId = uid
  808. await loadExpenseItems()
  809. await refreshWallet()
  810. const id = query?.id ? String(query.id) : ''
  811. if (id) {
  812. editId.value = id
  813. await loadEditData(id)
  814. } else {
  815. await refreshTitle()
  816. }
  817. })
  818. </script>
  819. <style lang="scss">
  820. @import '@/styles/bx-theme.scss';
  821. </style>