apply.vue 25 KB

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