admin.vue 18 KB

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