mine.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. <template>
  2. <view class="bx-page">
  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="wallet-summary card">
  11. <view class="wallet-summary__row">
  12. <text>总额</text>
  13. <text class="wallet-summary__val">¥{{ money(summary.totalAmount) }}</text>
  14. </view>
  15. <view class="wallet-summary__row">
  16. <text>已报销占用</text>
  17. <text class="wallet-summary__val">¥{{ money(summary.usedAmount) }}</text>
  18. </view>
  19. <view class="wallet-summary__row wallet-summary__row--em">
  20. <text>余额</text>
  21. <text class="wallet-summary__val">¥{{ money(summary.balance) }}</text>
  22. </view>
  23. <view class="wallet-summary__meta">发票 {{ summary.invoiceCount ?? 0 }} 张</view>
  24. </view>
  25. <view class="wallet-actions">
  26. <button class="btn btn-primary btn-block" @click="openUpload">上传发票</button>
  27. </view>
  28. <view class="search-wrap" style="padding-top: 0">
  29. <scroll-view scroll-x class="search-filter search-filter--scroll" :show-scrollbar="false">
  30. <view
  31. class="filter-tag"
  32. :class="{ active: activeTab === 0 }"
  33. @click="onTabChange(0)"
  34. >
  35. 发票列表
  36. </view>
  37. <view
  38. class="filter-tag"
  39. :class="{ active: activeTab === 1 }"
  40. @click="onTabChange(1)"
  41. >
  42. 额度流水
  43. </view>
  44. </scroll-view>
  45. </view>
  46. <scroll-view
  47. v-if="activeTab === 0"
  48. class="page-padded list-scroll wallet-list"
  49. scroll-y
  50. :style="{ height: listHeight }"
  51. refresher-enabled
  52. :refresher-triggered="invRefreshing"
  53. @refresherrefresh="onInvoicePullRefresh"
  54. @scrolltolower="onInvoiceLoadMore"
  55. >
  56. <view v-if="!invoices.length && !invLoading" class="empty-state empty-state--embed">
  57. <text>暂无发票</text>
  58. </view>
  59. <view
  60. v-for="(inv, idx) in invoices"
  61. :key="inv.id"
  62. class="expense-card invoice-wallet-card"
  63. >
  64. <text class="invoice-wallet-card__index">{{ idx + 1 }}</text>
  65. <view class="ec-amount-label">金额</view>
  66. <view class="ec-amount">{{ money(inv.amount) }}</view>
  67. <view class="invoice-wallet-card__subs">
  68. <view class="invoice-wallet-card__sub">
  69. <text class="k">备注</text>
  70. <text class="v">{{ inv.remark || '-' }}</text>
  71. </view>
  72. <view class="invoice-wallet-card__sub">
  73. <text class="k">创建时间</text>
  74. <text class="v">{{ inv.createTime || '-' }}</text>
  75. </view>
  76. </view>
  77. <view class="invoice-wallet-card__att-label">附件</view>
  78. <view v-if="invoiceFiles(inv).length" class="att-grid advance-att-grid">
  79. <view
  80. v-for="file in invoiceFiles(inv)"
  81. :key="file.id"
  82. class="att-img advance-att-tile"
  83. @click.stop="openAttachment(file)"
  84. >
  85. <image
  86. v-if="isImage(file.fileType) && file.previewUrl"
  87. :src="file.previewUrl"
  88. mode="aspectFill"
  89. />
  90. <view
  91. v-else-if="isImage(file.fileType) && file.previewLoading"
  92. class="att-preview-loading"
  93. >
  94. <text>...</text>
  95. </view>
  96. <view v-else class="att-file-icon att-doc-thumb">
  97. <text class="att-doc-thumb__ext">{{ fileExt(file.fileName) }}</text>
  98. <text class="att-doc-thumb__name">{{ file.fileName }}</text>
  99. </view>
  100. </view>
  101. </view>
  102. <text v-else class="invoice-wallet-card__att-empty">暂无附件</text>
  103. </view>
  104. <view v-if="invoices.length" class="list-footer-tip">
  105. {{ invFinished ? '没有更多了' : invLoading ? '加载中...' : '上拉加载更多' }}
  106. </view>
  107. </scroll-view>
  108. <scroll-view
  109. v-else
  110. class="page-padded list-scroll wallet-list"
  111. scroll-y
  112. :style="{ height: listHeight }"
  113. refresher-enabled
  114. :refresher-triggered="ledRefreshing"
  115. @refresherrefresh="onLedgerPullRefresh"
  116. @scrolltolower="onLedgerLoadMore"
  117. >
  118. <view v-if="!ledgers.length && !ledLoading" class="empty-state empty-state--embed">
  119. <text>暂无流水</text>
  120. </view>
  121. <view
  122. v-for="(row, idx) in ledgers"
  123. :key="row.id || idx"
  124. class="expense-card invoice-wallet-card"
  125. >
  126. <text class="invoice-wallet-card__index">{{ idx + 1 }}</text>
  127. <view class="ec-top">
  128. <text class="ec-type">{{ entryTypeLabel(row.entryType || row.entry_type) }}</text>
  129. </view>
  130. <view class="ec-amount-label">金额</view>
  131. <view class="ec-amount">{{ money(row.amount) }}</view>
  132. <view class="invoice-wallet-card__subs">
  133. <view class="invoice-wallet-card__sub">
  134. <text class="k">备注</text>
  135. <text class="v">{{ ledgerRemark(row) }}</text>
  136. </view>
  137. <view class="invoice-wallet-card__sub">
  138. <text class="k">创建时间</text>
  139. <text class="v">{{ row.createTime || row.create_time || '-' }}</text>
  140. </view>
  141. </view>
  142. </view>
  143. <view v-if="ledgers.length" class="list-footer-tip">
  144. {{ ledFinished ? '没有更多了' : ledLoading ? '加载中...' : '上拉加载更多' }}
  145. </view>
  146. </scroll-view>
  147. <!-- 上传发票 -->
  148. <view v-if="showUpload" class="bx-sheet-mask" @click="closeUpload">
  149. <view class="bx-sheet bx-upload-sheet" @click.stop>
  150. <view class="bx-sheet__title">上传发票</view>
  151. <view class="bx-sheet__body dialog-form-body">
  152. <view class="form-item">
  153. <text class="form-label">金额</text>
  154. <view class="form-input-prefix">
  155. <input
  156. class="form-input"
  157. type="digit"
  158. v-model="uploadAmount"
  159. placeholder="票面入账金额"
  160. />
  161. </view>
  162. </view>
  163. <view class="form-item">
  164. <text class="form-label">备注</text>
  165. <input
  166. class="form-input"
  167. v-model="uploadRemark"
  168. placeholder="可选"
  169. />
  170. </view>
  171. <view class="form-item">
  172. <text class="form-label">发票图片</text>
  173. <text class="form-hint">仅支持图片;PDF 请用管理端。多张时仅上传首张。</text>
  174. <view class="att-grid">
  175. <view
  176. v-for="(path, i) in uploadPaths"
  177. :key="path + i"
  178. class="att-img"
  179. >
  180. <image :src="path" mode="aspectFill" />
  181. <view class="del" @click="removeUploadPath(i)">×</view>
  182. </view>
  183. <view
  184. v-if="uploadPaths.length < 5"
  185. class="att-img att-add"
  186. @click="chooseUploadImages"
  187. >
  188. <text>+</text>
  189. </view>
  190. </view>
  191. </view>
  192. </view>
  193. <view class="bx-sheet__footer" style="display: flex; gap: 10px">
  194. <button class="btn btn-block" style="flex: 1" @click="closeUpload">取消</button>
  195. <button
  196. class="btn btn-primary btn-block"
  197. style="flex: 1"
  198. :disabled="uploading"
  199. @click="confirmUpload"
  200. >
  201. {{ uploading ? '上传中...' : '确定' }}
  202. </button>
  203. </view>
  204. </view>
  205. </view>
  206. </view>
  207. </template>
  208. <script setup>
  209. import { reactive, ref, onMounted } from 'vue'
  210. import { attachmentApi, invoiceApi, invoiceWalletApi } from '@/api/bx'
  211. import { getSafeAreaStyle } from '@/utils/safeArea'
  212. const safeAreaStyle = getSafeAreaStyle()
  213. const PAGE_SIZE = 5
  214. const activeTab = ref(0)
  215. const listHeight = ref('50vh')
  216. const summary = reactive({
  217. totalAmount: 0,
  218. usedAmount: 0,
  219. balance: 0,
  220. invoiceCount: 0
  221. })
  222. const invoices = ref([])
  223. const invLoading = ref(false)
  224. const invRefreshing = ref(false)
  225. const invFinished = ref(false)
  226. const invPage = ref(1)
  227. const ledgers = ref([])
  228. const allLedgers = ref([])
  229. const ledLoading = ref(false)
  230. const ledRefreshing = ref(false)
  231. const ledFinished = ref(false)
  232. const ledPage = ref(1)
  233. const ledgersFetched = ref(false)
  234. const showUpload = ref(false)
  235. const uploadAmount = ref('')
  236. const uploadRemark = ref('')
  237. const uploadPaths = ref([])
  238. const uploading = ref(false)
  239. function goBack() {
  240. uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/workbench/workbench' }) })
  241. }
  242. function money(v) {
  243. return Number(v || 0).toLocaleString('zh-CN', {
  244. minimumFractionDigits: 2,
  245. maximumFractionDigits: 2
  246. })
  247. }
  248. function entryTypeLabel(t) {
  249. const map = {
  250. credit_upload: '上传入账',
  251. debit_delete: '删除冲减',
  252. debit_reimburse: '报销占用',
  253. credit_release: '驳回/撤回退回'
  254. }
  255. return map[t] || t || '-'
  256. }
  257. function ledgerRemark(row) {
  258. const text = (row?.remark ?? '').toString().trim()
  259. return text || '-'
  260. }
  261. function pagedRows(res) {
  262. const d = res?.data
  263. if (!d) return { rows: [], total: 0 }
  264. if (Array.isArray(d)) return { rows: d, total: d.length }
  265. const rows = d.rows ?? d.items ?? d.list ?? []
  266. const total = typeof d.total === 'number' ? d.total : rows.length
  267. return { rows, total }
  268. }
  269. function isImage(fileType) {
  270. return !!(fileType && String(fileType).startsWith('image/'))
  271. }
  272. function fileExt(name) {
  273. const ext = name?.split('.').pop()
  274. return ext ? ext.toUpperCase() : 'FILE'
  275. }
  276. function invoiceFiles(inv) {
  277. return inv?._files || []
  278. }
  279. function normalizeInvoiceFiles(inv) {
  280. const raw = Array.isArray(inv?.attachments) ? inv.attachments : []
  281. if (raw.length) {
  282. return raw.map((a) => ({
  283. id: String(a.id),
  284. fileName: a.fileName || a.file_name || '附件',
  285. fileType: a.fileType || a.file_type || '',
  286. previewUrl: '',
  287. previewLoading: false
  288. }))
  289. }
  290. if (inv?.attachmentId || inv?.attachment_id) {
  291. return [
  292. {
  293. id: String(inv.attachmentId || inv.attachment_id),
  294. fileName: inv.fileName || inv.file_name || '附件',
  295. fileType: inv.fileType || inv.file_type || '',
  296. previewUrl: '',
  297. previewLoading: false
  298. }
  299. ]
  300. }
  301. return []
  302. }
  303. function kickInvoicePreviewLoads(rows) {
  304. for (const inv of rows) {
  305. const files = invoiceFiles(inv)
  306. for (const f of files) {
  307. if (!isImage(f.fileType) || f.previewUrl) continue
  308. f.previewLoading = true
  309. attachmentApi
  310. .preview(f.id)
  311. .then((pRes) => {
  312. f.previewUrl = pRes?.data?.url || ''
  313. })
  314. .catch(() => {})
  315. .finally(() => {
  316. f.previewLoading = false
  317. })
  318. }
  319. }
  320. }
  321. async function openAttachment(file) {
  322. if (isImage(file.fileType)) {
  323. let url = file.previewUrl
  324. if (!url) {
  325. try {
  326. const res = await attachmentApi.preview(file.id)
  327. url = res?.data?.url ?? ''
  328. if (url) file.previewUrl = url
  329. } catch (e) {
  330. uni.showToast({ title: '暂无法预览', icon: 'none' })
  331. return
  332. }
  333. }
  334. if (url) {
  335. uni.previewImage({ urls: [url], current: url })
  336. } else {
  337. uni.showToast({ title: '暂无法预览', icon: 'none' })
  338. }
  339. return
  340. }
  341. uni.showToast({ title: '非图片附件请在管理端查看', icon: 'none' })
  342. }
  343. function computeListHeight() {
  344. try {
  345. const sys = uni.getSystemInfoSync()
  346. const h = (sys.windowHeight || 600) - 320
  347. listHeight.value = `${Math.max(h, 280)}px`
  348. } catch (e) {
  349. listHeight.value = '50vh'
  350. }
  351. }
  352. async function loadSummary() {
  353. try {
  354. const sumRes = await invoiceWalletApi.mineSummary()
  355. Object.assign(summary, sumRes?.data || {})
  356. } catch (e) {
  357. /* summary 失败不阻断列表 */
  358. }
  359. }
  360. async function fetchInvoices(isRefresh) {
  361. try {
  362. const res = await invoiceApi.list({ page: invPage.value, pageSize: PAGE_SIZE })
  363. const { rows, total } = pagedRows(res)
  364. for (const inv of rows) {
  365. inv._files = normalizeInvoiceFiles(inv)
  366. }
  367. const start = isRefresh ? 0 : invoices.value.length
  368. if (isRefresh) invoices.value = rows
  369. else invoices.value.push(...rows)
  370. kickInvoicePreviewLoads(invoices.value.slice(start, start + rows.length))
  371. if (invoices.value.length >= total || rows.length < PAGE_SIZE) {
  372. invFinished.value = true
  373. } else {
  374. invFinished.value = false
  375. }
  376. } catch (e) {
  377. invFinished.value = true
  378. }
  379. }
  380. async function onInvoiceLoadMore() {
  381. if (invFinished.value || invLoading.value) return
  382. invLoading.value = true
  383. await fetchInvoices(false)
  384. invLoading.value = false
  385. invPage.value++
  386. }
  387. async function onInvoiceRefresh() {
  388. invPage.value = 1
  389. invFinished.value = false
  390. await Promise.all([loadSummary(), fetchInvoices(true)])
  391. invPage.value = 2
  392. }
  393. async function onInvoicePullRefresh() {
  394. invRefreshing.value = true
  395. await onInvoiceRefresh()
  396. invRefreshing.value = false
  397. }
  398. async function ensureLedgersLoaded() {
  399. if (ledgersFetched.value) return
  400. try {
  401. const ledRes = await invoiceApi.ledgers()
  402. const data = ledRes?.data
  403. allLedgers.value = Array.isArray(data) ? data : pagedRows(ledRes).rows
  404. ledgersFetched.value = true
  405. } catch (e) {
  406. allLedgers.value = []
  407. ledgersFetched.value = true
  408. }
  409. }
  410. async function fetchLedgersPage(isRefresh) {
  411. await ensureLedgersLoaded()
  412. const start = (ledPage.value - 1) * PAGE_SIZE
  413. const slice = allLedgers.value.slice(start, start + PAGE_SIZE)
  414. if (isRefresh) ledgers.value = slice
  415. else ledgers.value.push(...slice)
  416. if (ledgers.value.length >= allLedgers.value.length || slice.length < PAGE_SIZE) {
  417. ledFinished.value = true
  418. } else {
  419. ledFinished.value = false
  420. }
  421. }
  422. async function onLedgerLoadMore() {
  423. if (ledFinished.value || ledLoading.value) return
  424. ledLoading.value = true
  425. await fetchLedgersPage(false)
  426. ledLoading.value = false
  427. ledPage.value++
  428. }
  429. async function onLedgerRefresh() {
  430. ledgersFetched.value = false
  431. allLedgers.value = []
  432. ledPage.value = 1
  433. ledFinished.value = false
  434. await Promise.all([loadSummary(), fetchLedgersPage(true)])
  435. ledPage.value = 2
  436. }
  437. async function onLedgerPullRefresh() {
  438. ledRefreshing.value = true
  439. await onLedgerRefresh()
  440. ledRefreshing.value = false
  441. }
  442. function onTabChange(tab) {
  443. if (activeTab.value === tab) return
  444. activeTab.value = tab
  445. if (tab === 0 && !invoices.value.length && !invFinished.value) {
  446. invLoading.value = true
  447. void (async () => {
  448. await fetchInvoices(true)
  449. invLoading.value = false
  450. invPage.value = 2
  451. })()
  452. }
  453. if (tab === 1 && !ledgersFetched.value) {
  454. ledLoading.value = true
  455. void (async () => {
  456. await fetchLedgersPage(true)
  457. ledLoading.value = false
  458. ledPage.value = 2
  459. })()
  460. }
  461. }
  462. function openUpload() {
  463. uploadAmount.value = ''
  464. uploadRemark.value = ''
  465. uploadPaths.value = []
  466. showUpload.value = true
  467. }
  468. function closeUpload() {
  469. if (uploading.value) return
  470. showUpload.value = false
  471. }
  472. function chooseUploadImages() {
  473. const remain = 5 - uploadPaths.value.length
  474. if (remain <= 0) return
  475. uni.chooseImage({
  476. count: remain,
  477. sizeType: ['compressed'],
  478. sourceType: ['album', 'camera'],
  479. success: (res) => {
  480. const paths = res.tempFilePaths || []
  481. uploadPaths.value = uploadPaths.value.concat(paths).slice(0, 5)
  482. }
  483. })
  484. }
  485. function removeUploadPath(i) {
  486. uploadPaths.value.splice(i, 1)
  487. }
  488. async function confirmUpload() {
  489. const amount = Number(uploadAmount.value)
  490. if (!(amount > 0)) {
  491. uni.showToast({ title: '请输入有效金额', icon: 'none' })
  492. return
  493. }
  494. if (!uploadPaths.value.length) {
  495. uni.showToast({ title: '请选择发票文件', icon: 'none' })
  496. return
  497. }
  498. if (uploading.value) return
  499. if (uploadPaths.value.length > 1) {
  500. uni.showToast({ title: '小程序仅上传首张图片', icon: 'none' })
  501. }
  502. uploading.value = true
  503. try {
  504. await invoiceApi.upload({
  505. amount,
  506. remark: uploadRemark.value.trim(),
  507. filePath: uploadPaths.value[0]
  508. })
  509. uni.showToast({ title: '上传成功', icon: 'success' })
  510. showUpload.value = false
  511. uploadAmount.value = ''
  512. uploadRemark.value = ''
  513. uploadPaths.value = []
  514. activeTab.value = 0
  515. await onInvoiceRefresh()
  516. ledgersFetched.value = false
  517. } catch (e) {
  518. // toast in bxUploadInvoice
  519. } finally {
  520. uploading.value = false
  521. }
  522. }
  523. onMounted(() => {
  524. computeListHeight()
  525. void loadSummary()
  526. invLoading.value = true
  527. void (async () => {
  528. await fetchInvoices(true)
  529. invLoading.value = false
  530. invPage.value = 2
  531. })()
  532. })
  533. </script>
  534. <style lang="scss" scoped>
  535. @import '@/styles/bx-theme.scss';
  536. </style>