admin.vue 18 KB

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