mine.vue 17 KB

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