apply.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
  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-picked-row">
  130. <view
  131. v-for="pick in selectedInvoicePicks"
  132. :key="pick.key"
  133. class="invoice-picked-item"
  134. >
  135. <view class="invoice-picked-item__meta">
  136. <text class="invoice-picked-item__name">{{ pick.fileName }}</text>
  137. <text class="invoice-picked-item__amt">整票 ¥{{ money(pick.amount) }}</text>
  138. </view>
  139. <view class="invoice-picked-item__remove" @click="removeInvoicePick(pick.key)">×</view>
  140. </view>
  141. </view>
  142. <view class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</view>
  143. </view>
  144. <view class="form-card">
  145. <view class="form-header">
  146. <text class="form-header-title">申请附件(可选)</text>
  147. </view>
  148. <button
  149. class="btn btn-primary btn-sm"
  150. :disabled="uploading"
  151. @click="chooseAttachments"
  152. >
  153. {{ uploading ? '上传中…' : '上传图片' }}
  154. </button>
  155. <view v-if="files.length" class="att-grid">
  156. <view v-for="(file, idx) in files" :key="file.id" class="att-img">
  157. <image :src="file.previewUrl || file.localPath" mode="aspectFill" />
  158. <view v-if="!file._pending" class="del" @click.stop="removeFile(idx)">×</view>
  159. </view>
  160. </view>
  161. <view v-else class="detail-section-empty" style="margin-top: 8px">暂无附件(可选)</view>
  162. </view>
  163. <view class="form-card">
  164. <view class="form-header">
  165. <text class="form-header-title">报销说明</text>
  166. </view>
  167. <view class="field">
  168. <textarea
  169. class="form-textarea"
  170. :value="form.remark"
  171. placeholder="请输入报销说明"
  172. :maxlength="500"
  173. @input="onRemarkInput"
  174. />
  175. </view>
  176. </view>
  177. </view>
  178. <view class="bottom-action-bar">
  179. <button class="btn btn-primary btn-block" :disabled="submitting" @click="onSubmit">
  180. {{ submitting ? '保存中…' : '保存' }}
  181. </button>
  182. </view>
  183. <!-- 费用项目选择 -->
  184. <view v-if="showExpensePicker" class="bx-sheet-mask" @click="showExpensePicker = false">
  185. <view class="bx-sheet" @click.stop>
  186. <view class="bx-sheet__title">选择费用项目</view>
  187. <scroll-view scroll-y class="bx-sheet__body">
  188. <view v-if="!expenseOptions.length" class="bx-sheet__empty">暂无可用费用项目</view>
  189. <view
  190. v-for="opt in expenseOptions"
  191. :key="opt.id"
  192. class="bx-sheet__item"
  193. :class="{ 'is-selected': String(form.details[pickingLineIndex]?.expenseItemId) === opt.id }"
  194. @click="confirmExpensePick(opt.id)"
  195. >
  196. {{ opt.label }}
  197. </view>
  198. </scroll-view>
  199. </view>
  200. </view>
  201. <!-- 发票多选(扁平列表) -->
  202. <view v-if="showInvoicePicker" class="bx-sheet-mask" @click="showInvoicePicker = false">
  203. <view class="bx-sheet" @click.stop>
  204. <view class="bx-sheet__title">选择发票附件</view>
  205. <scroll-view scroll-y class="bx-sheet__body">
  206. <view v-if="!attachItems.length" class="bx-sheet__empty">暂无发票附件</view>
  207. <view
  208. v-for="item in attachItems"
  209. :key="item.key"
  210. class="bx-sheet__item"
  211. :class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
  212. @click="togglePickerKey(item.key)"
  213. >
  214. <view>
  215. <text>{{ item.fileName }}</text>
  216. <view class="bx-sheet__item-sub">整票 ¥{{ money(item.amount) }}</view>
  217. </view>
  218. <text v-if="pickerDraftKeys.includes(item.key)">✓</text>
  219. </view>
  220. </scroll-view>
  221. <view class="bx-sheet__footer">
  222. <button class="btn btn-primary btn-block" @click="confirmInvoicePicker">
  223. 确定(已选 {{ pickerDraftKeys.length }})
  224. </button>
  225. </view>
  226. </view>
  227. </view>
  228. </view>
  229. </template>
  230. <script setup>
  231. import { ref, reactive, computed } from 'vue'
  232. import { onLoad } from '@dcloudio/uni-app'
  233. import { PAGE_PATHS } from '@/config/api'
  234. import { useUserStore } from '@/stores/user'
  235. import {
  236. reimburseApi,
  237. attachmentApi,
  238. expenseItemApi,
  239. invoiceWalletApi,
  240. invoiceApi
  241. } from '@/api/bx'
  242. import {
  243. normalizeExpenseItem,
  244. flattenExpenseTree,
  245. formatExpenseName
  246. } from '@/utils/bxFormat'
  247. import { getSafeAreaStyle } from '@/utils/safeArea'
  248. const safeAreaStyle = getSafeAreaStyle()
  249. const MAX_ATTACHMENTS = 10
  250. const userStore = useUserStore()
  251. const editId = ref('')
  252. const savedId = ref('')
  253. const submitting = ref(false)
  254. const uploading = ref(false)
  255. const files = ref([])
  256. function todayYmd() {
  257. const d = new Date()
  258. const m = String(d.getMonth() + 1).padStart(2, '0')
  259. const day = String(d.getDate()).padStart(2, '0')
  260. return `${d.getFullYear()}-${m}-${day}`
  261. }
  262. const form = reactive({
  263. title: '',
  264. remark: '',
  265. applicantId: '',
  266. reimburseDate: todayYmd(),
  267. details: [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
  268. })
  269. const flatList = ref([])
  270. const showExpensePicker = ref(false)
  271. const pickingLineIndex = ref(0)
  272. const walletBalance = ref(0)
  273. const attachItems = ref([])
  274. const selectedInvoicePicks = ref([])
  275. const pickerDraftKeys = ref([])
  276. const showInvoicePicker = ref(false)
  277. const isEdit = computed(() => !!editId.value || !!savedId.value)
  278. const applicantLabel = computed(() => {
  279. const u = userStore.userInfo || {}
  280. return u.realName || u.username || u.nickName || u.name || ''
  281. })
  282. const expenseOptions = computed(() => {
  283. return (flatList.value || [])
  284. .filter((i) => i.status === 1 || i.status === '1')
  285. .map((i) => ({
  286. id: String(i.id),
  287. label: formatExpenseName(i.id, flatList.value)
  288. }))
  289. })
  290. const detailTotalNumber = computed(() =>
  291. form.details.reduce((sum, d) => sum + Number(d.amount || 0), 0)
  292. )
  293. const detailTotalAmount = computed(() =>
  294. detailTotalNumber.value.toLocaleString('zh-CN', {
  295. minimumFractionDigits: 2,
  296. maximumFractionDigits: 2
  297. })
  298. )
  299. const balanceInsufficient = computed(
  300. () => detailTotalNumber.value > 0 && walletBalance.value < detailTotalNumber.value
  301. )
  302. function money(v) {
  303. return Number(v || 0).toLocaleString('zh-CN', {
  304. minimumFractionDigits: 2,
  305. maximumFractionDigits: 2
  306. })
  307. }
  308. function lineExpenseDisplay(id) {
  309. if (id === '' || id == null) return ''
  310. const name = formatExpenseName(id, flatList.value)
  311. return name === '-' ? '' : name
  312. }
  313. function onTitleInput(e) {
  314. form.title = e.detail.value
  315. }
  316. function onRemarkInput(e) {
  317. form.remark = e.detail.value
  318. }
  319. function onLineAmount(e) {
  320. const idx = Number(e.currentTarget.dataset.idx)
  321. if (Number.isNaN(idx) || !form.details[idx]) return
  322. form.details[idx].amount = e.detail.value
  323. }
  324. function onLineRemark(e) {
  325. const idx = Number(e.currentTarget.dataset.idx)
  326. if (Number.isNaN(idx) || !form.details[idx]) return
  327. form.details[idx].remark = e.detail.value
  328. }
  329. function onOccurDate(e) {
  330. const idx = Number(e.currentTarget.dataset.idx)
  331. if (Number.isNaN(idx) || !form.details[idx]) return
  332. form.details[idx].occurDate = e.detail.value
  333. }
  334. function onDateChange(e) {
  335. form.reimburseDate = e.detail.value
  336. refreshTitle()
  337. }
  338. function goBack() {
  339. uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
  340. }
  341. function addDetailLine() {
  342. form.details.push({ expenseItemId: '', amount: '', remark: '', occurDate: '' })
  343. }
  344. function removeDetailLine(index) {
  345. form.details.splice(index, 1)
  346. }
  347. function openExpensePicker(idx) {
  348. pickingLineIndex.value = idx
  349. showExpensePicker.value = true
  350. }
  351. function confirmExpensePick(id) {
  352. form.details[pickingLineIndex.value].expenseItemId = String(id)
  353. showExpensePicker.value = false
  354. }
  355. function invoiceFileList(inv) {
  356. const raw = Array.isArray(inv?.attachments) ? inv.attachments : []
  357. if (raw.length) {
  358. return raw.map((a) => ({
  359. id: String(a.id || a.attachmentId),
  360. fileName: a.fileName || a.file_name || '发票附件',
  361. fileType: a.fileType || a.file_type || ''
  362. }))
  363. }
  364. const aid = inv?.attachmentId || inv?.attachment_id
  365. if (aid) {
  366. return [
  367. {
  368. id: String(aid),
  369. fileName: inv.fileName || inv.file_name || '发票附件',
  370. fileType: inv.fileType || inv.file_type || ''
  371. }
  372. ]
  373. }
  374. return []
  375. }
  376. function buildAttachItems(rows) {
  377. const items = []
  378. for (const row of rows || []) {
  379. const createTime = row.createTime || row.create_time || ''
  380. for (const file of invoiceFileList(row)) {
  381. if (!file.id) continue
  382. items.push({
  383. key: `${row.id}:${file.id}`,
  384. invoiceId: String(row.id),
  385. attachmentId: file.id,
  386. amount: row.amount,
  387. createTime,
  388. fileName: file.fileName,
  389. fileType: file.fileType
  390. })
  391. }
  392. }
  393. return items
  394. }
  395. function buildInvoicePayload() {
  396. const invoiceLinks = selectedInvoicePicks.value.map((p) => ({
  397. invoiceId: String(p.invoiceId),
  398. attachmentId: String(p.attachmentId)
  399. }))
  400. const invoiceIds = Array.from(new Set(invoiceLinks.map((p) => p.invoiceId)))
  401. return { invoiceIds, invoiceLinks }
  402. }
  403. async function refreshTitle() {
  404. if (!form.reimburseDate) return
  405. try {
  406. const params = { reimburseDate: form.reimburseDate }
  407. const exclude = editId.value || savedId.value
  408. if (exclude) params.excludeApplyId = exclude
  409. if (form.applicantId) params.applicantId = form.applicantId
  410. const res = await reimburseApi.generateTitle(params)
  411. const t = res?.data
  412. form.title = typeof t === 'string' ? t : t?.title || form.title
  413. } catch (e) {
  414. // keep
  415. }
  416. }
  417. async function refreshWallet() {
  418. try {
  419. const owner = form.applicantId || undefined
  420. const [sumRes, invRes] = await Promise.all([
  421. invoiceWalletApi.mineSummary(owner ? { userId: owner } : undefined),
  422. invoiceApi.list({ page: 1, pageSize: 200, userId: owner })
  423. ])
  424. walletBalance.value = Number(sumRes?.data?.balance ?? 0)
  425. const d = invRes?.data
  426. const rows = d?.rows ?? d?.items ?? d?.list ?? (Array.isArray(d) ? d : [])
  427. attachItems.value = buildAttachItems(rows)
  428. } catch (e) {
  429. // ignore
  430. }
  431. }
  432. function openInvoicePicker() {
  433. pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
  434. showInvoicePicker.value = true
  435. }
  436. function togglePickerKey(key) {
  437. const i = pickerDraftKeys.value.indexOf(key)
  438. if (i >= 0) pickerDraftKeys.value.splice(i, 1)
  439. else pickerDraftKeys.value.push(key)
  440. }
  441. function confirmInvoicePicker() {
  442. const set = new Set(pickerDraftKeys.value)
  443. selectedInvoicePicks.value = attachItems.value
  444. .filter((i) => set.has(i.key))
  445. .map((i) => ({ ...i }))
  446. showInvoicePicker.value = false
  447. }
  448. function removeInvoicePick(key) {
  449. selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
  450. }
  451. function validateReimburseApplyForm() {
  452. for (let i = 0; i < form.details.length; i++) {
  453. const d = form.details[i]
  454. const row = i + 1
  455. const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
  456. const amountStr = String(d.amount ?? '').trim()
  457. const hasAmount = amountStr !== ''
  458. const amt = Number(amountStr)
  459. const amountOk = hasAmount && !Number.isNaN(amt) && amt > 0
  460. const hasRemark = !!d.remark?.trim()
  461. if (!hasExpense && !hasAmount && !hasRemark) continue
  462. if (!hasExpense) {
  463. uni.showToast({ title: `第 ${row} 行:请选择费用项目`, icon: 'none' })
  464. return null
  465. }
  466. if (!amountOk) {
  467. uni.showToast({ title: `第 ${row} 行:请输入大于 0 的金额`, icon: 'none' })
  468. return null
  469. }
  470. if (!d.remark?.trim()) {
  471. uni.showToast({ title: `第 ${row} 行:请填写报销事由`, icon: 'none' })
  472. return null
  473. }
  474. }
  475. const validDetails = form.details.filter((d) => {
  476. const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
  477. const amt = Number(String(d.amount ?? '').trim())
  478. return hasExpense && !Number.isNaN(amt) && amt > 0
  479. })
  480. if (validDetails.length === 0) {
  481. uni.showToast({ title: '请至少填写一条完整的报销明细', icon: 'none' })
  482. return null
  483. }
  484. if (!form.reimburseDate) {
  485. uni.showToast({ title: '请选择报销日期', icon: 'none' })
  486. return null
  487. }
  488. return validDetails
  489. }
  490. async function chooseAttachments() {
  491. const remain = MAX_ATTACHMENTS - files.value.length
  492. if (remain <= 0) {
  493. uni.showToast({ title: `最多上传 ${MAX_ATTACHMENTS} 个附件`, icon: 'none' })
  494. return
  495. }
  496. uni.chooseImage({
  497. count: remain,
  498. sizeType: ['compressed'],
  499. sourceType: ['album', 'camera'],
  500. success: async (res) => {
  501. const paths = res.tempFilePaths || []
  502. for (const path of paths) {
  503. await uploadOne(path)
  504. }
  505. }
  506. })
  507. }
  508. async function uploadOne(filePath) {
  509. const pendingId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
  510. files.value.push({
  511. id: pendingId,
  512. previewUrl: filePath,
  513. localPath: filePath,
  514. _pending: true,
  515. _existing: false
  516. })
  517. uploading.value = true
  518. try {
  519. const res = await attachmentApi.upload(filePath, '', 0)
  520. const data = res?.data || {}
  521. const id = String(data.id || '')
  522. let previewUrl = filePath
  523. if (id) {
  524. try {
  525. const pRes = await attachmentApi.preview(id)
  526. if (pRes?.data?.url) previewUrl = pRes.data.url
  527. } catch (e) {
  528. // keep local
  529. }
  530. }
  531. const idx = files.value.findIndex((f) => f.id === pendingId)
  532. if (idx >= 0) {
  533. files.value[idx] = {
  534. id: id || pendingId,
  535. previewUrl,
  536. localPath: filePath,
  537. _pending: false,
  538. _existing: false
  539. }
  540. }
  541. } catch (e) {
  542. files.value = files.value.filter((f) => f.id !== pendingId)
  543. } finally {
  544. uploading.value = files.value.some((f) => f._pending)
  545. }
  546. }
  547. function removeFile(idx) {
  548. const file = files.value[idx]
  549. if (!file) return
  550. if (file._existing && file.id && !String(file.id).startsWith('pending_')) {
  551. attachmentApi.delete(file.id).catch(() => {})
  552. }
  553. files.value.splice(idx, 1)
  554. }
  555. async function bindNewAttachments(bizId) {
  556. const newIds = files.value
  557. .filter((f) => f.id && !f._existing && !f._pending && !String(f.id).startsWith('pending_'))
  558. .map((f) => f.id)
  559. if (!newIds.length) return
  560. await attachmentApi.bindBiz(newIds, 'reimburse_apply', bizId)
  561. files.value.forEach((f) => {
  562. if (newIds.includes(f.id)) f._existing = true
  563. })
  564. }
  565. async function loadExpenseItems() {
  566. try {
  567. const res = await expenseItemApi.tree()
  568. const tree = (res.data || []).map(normalizeExpenseItem)
  569. flatList.value = flattenExpenseTree(tree)
  570. } catch (e) {
  571. flatList.value = []
  572. }
  573. }
  574. async function loadEditData(id) {
  575. try {
  576. const res = await reimburseApi.getById(id)
  577. const data = res?.data || {}
  578. form.title = data.title || ''
  579. form.remark = data.remark || ''
  580. form.applicantId = String(data.applicantId || userStore.userInfo?.id || '')
  581. form.reimburseDate = data.reimburseDate || todayYmd()
  582. form.details = (data.details || []).map((d) => ({
  583. expenseItemId: d.expenseId ?? d.expenseItemId ?? '',
  584. amount: String(d.amount ?? ''),
  585. remark: d.remark || '',
  586. occurDate: d.occurDate || ''
  587. }))
  588. if (!form.details.length) {
  589. form.details = [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
  590. }
  591. await refreshWallet()
  592. const linked = data.invoices || []
  593. const picks = []
  594. for (const inv of linked) {
  595. const invoiceId = String(inv.id || inv.invoiceId || '')
  596. if (!invoiceId) continue
  597. let attachmentId = String(inv.attachmentId || inv.attachment_id || '')
  598. let fileName = inv.fileName || inv.file_name || '发票附件'
  599. let fileType = inv.fileType || inv.file_type || ''
  600. const createTime = inv.createTime || inv.create_time || ''
  601. if (!attachmentId) {
  602. const src = attachItems.value.find((w) => String(w.invoiceId) === invoiceId)
  603. if (src) {
  604. attachmentId = src.attachmentId
  605. fileName = src.fileName
  606. fileType = src.fileType
  607. }
  608. }
  609. if (!attachmentId) continue
  610. picks.push({
  611. key: `${invoiceId}:${attachmentId}`,
  612. invoiceId,
  613. attachmentId,
  614. amount: inv.amount,
  615. createTime,
  616. fileName,
  617. fileType
  618. })
  619. }
  620. selectedInvoicePicks.value = picks
  621. try {
  622. const attRes = await attachmentApi.listByBiz('reimburse_apply', id)
  623. const list = attRes?.data || []
  624. const mapped = []
  625. for (const a of list) {
  626. let previewUrl = ''
  627. if ((a.fileType || '').startsWith('image/')) {
  628. try {
  629. const pRes = await attachmentApi.preview(a.id)
  630. previewUrl = pRes?.data?.url || ''
  631. } catch (e) {
  632. // skip
  633. }
  634. }
  635. mapped.push({
  636. id: String(a.id),
  637. previewUrl,
  638. _pending: false,
  639. _existing: true
  640. })
  641. }
  642. files.value = mapped
  643. } catch (e) {
  644. files.value = []
  645. }
  646. } catch (e) {
  647. uni.showToast({ title: '加载数据失败', icon: 'none' })
  648. }
  649. }
  650. async function onSubmit() {
  651. const validDetails = validateReimburseApplyForm()
  652. if (!validDetails) return
  653. if (uploading.value) {
  654. uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
  655. return
  656. }
  657. submitting.value = true
  658. const isEditingAction = !!editId.value || !!savedId.value
  659. try {
  660. const invoicePayload = buildInvoicePayload()
  661. const payload = {
  662. title: form.title || undefined,
  663. remark: form.remark || undefined,
  664. applicantId: form.applicantId || undefined,
  665. reimburseDate: form.reimburseDate || undefined,
  666. details: validDetails.map((d) => ({
  667. expenseItemId: String(d.expenseItemId),
  668. amount: Number(d.amount),
  669. remark: String(d.remark).trim(),
  670. occurDate: d.occurDate ? String(d.occurDate) : undefined
  671. })),
  672. ...invoicePayload
  673. }
  674. let id
  675. if (editId.value || savedId.value) {
  676. id = savedId.value || editId.value
  677. await reimburseApi.update(id, payload)
  678. } else {
  679. const res = await reimburseApi.create(payload)
  680. id = String(res?.data?.id ?? res?.id ?? '')
  681. if (!id) throw new Error('保存成功但未返回报销单ID')
  682. savedId.value = id
  683. editId.value = id
  684. }
  685. try {
  686. await bindNewAttachments(id)
  687. } catch (e) {
  688. uni.showToast({ title: '报销单已保存,但附件关联失败', icon: 'none' })
  689. }
  690. uni.showModal({
  691. title: isEditingAction ? '保存成功' : '创建成功',
  692. content: '报销单已保存为草稿',
  693. confirmText: '提交审批',
  694. confirmColor: '#00a874',
  695. cancelText: '继续编辑',
  696. success: async (modalRes) => {
  697. if (!modalRes.confirm) return
  698. try {
  699. await reimburseApi.submit(id, invoicePayload)
  700. uni.showToast({ title: '提交成功', icon: 'success' })
  701. setTimeout(() => goBack(), 500)
  702. } catch (e) {
  703. // toasted
  704. }
  705. }
  706. })
  707. } catch (e) {
  708. // toasted by bxRequest
  709. } finally {
  710. submitting.value = false
  711. }
  712. }
  713. onLoad(async (query) => {
  714. const uid = String(userStore.userInfo?.id || userStore.userInfo?.userId || '')
  715. form.applicantId = uid
  716. await loadExpenseItems()
  717. await refreshWallet()
  718. const id = query?.id ? String(query.id) : ''
  719. if (id) {
  720. editId.value = id
  721. await loadEditData(id)
  722. } else {
  723. await refreshTitle()
  724. }
  725. })
  726. </script>
  727. <style lang="scss">
  728. @import '@/styles/bx-theme.scss';
  729. </style>