admin.vue 18 KB

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