BillListView.vue 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. <template>
  2. <view class="bill-list">
  3. <ErpSearchBar
  4. v-model="keyword"
  5. :placeholder="`搜索${module?.title || ''}`"
  6. @search="onSearch"
  7. />
  8. <!-- 单据状态筛选 -->
  9. <view v-if="module?.shape === 'bill'" class="status-filter">
  10. <view
  11. v-for="s in statusOptions"
  12. :key="String(s.value)"
  13. class="status-filter__item"
  14. :class="{ 'status-filter__item--active': statusFilter === s.value }"
  15. @click="onStatusChange(s.value)"
  16. >
  17. <text>{{ s.label }}</text>
  18. </view>
  19. </view>
  20. <!-- 列表 -->
  21. <ErpEmpty
  22. v-if="!state.loading && !state.records.length"
  23. :show-retry="state.error"
  24. @retry="refresh"
  25. />
  26. <ErpListItem
  27. v-for="(row, idx) in state.records"
  28. :key="(row as any).id ?? idx"
  29. :title="rowTitle(row)"
  30. :rows="rowRows(row)"
  31. :status-text="statusText(row)"
  32. :status-type="statusType(row)"
  33. @click="openDetail(row)"
  34. />
  35. <ErpLoadMore
  36. v-if="state.records.length"
  37. :status="moreStatus"
  38. @retry="loadMore"
  39. />
  40. </view>
  41. </template>
  42. <script setup lang="ts">
  43. /**
  44. * 通用列表模板(props 驱动):搜索 + 状态筛选 + 卡片列表 + 触底加载
  45. * 页面壳通过 ref 调用 refresh()/loadMore() 挂接 onPullDownRefresh/onReachBottom
  46. */
  47. import { ref, reactive, computed, watch } from 'vue'
  48. import { getModule, BILL_STATUS_TEXT, BILL_STATUS_TYPE, type ModuleConfig } from '@/modules/registry'
  49. import { usePagedList } from '@/composables/usePagedList'
  50. import { formatMoney, formatDateTime } from '@/utils/format'
  51. import ErpSearchBar from '@/components/common/ErpSearchBar.vue'
  52. import ErpEmpty from '@/components/common/ErpEmpty.vue'
  53. import ErpListItem from '@/components/common/ErpListItem.vue'
  54. import ErpLoadMore from '@/components/common/ErpLoadMore.vue'
  55. import { moduleDetailPage } from '@/constants/routes'
  56. const props = defineProps<{ moduleKey: string; initialStatus?: string }>()
  57. const module = ref<ModuleConfig | null>(null)
  58. const keyword = ref('')
  59. const statusFilter = ref<number | null>(null)
  60. let paged: ReturnType<typeof usePagedList> | null = null
  61. // 模板渲染用响应式状态(initPaged 内与 usePagedList 同步)
  62. const state = reactive({ records: [] as any[], loading: false, finished: false, error: false })
  63. const statusOptions = [
  64. { label: '全部', value: null },
  65. { label: '未审核', value: 0 },
  66. { label: '已审核', value: 1 },
  67. { label: '已完成', value: 2 }
  68. ] as { label: string; value: number | null }[]
  69. const moreStatus = computed(() => {
  70. if (state.error) return 'error'
  71. if (state.loading) return 'loading'
  72. if (state.finished) return 'nomore'
  73. return 'more'
  74. })
  75. watch(
  76. () => props.moduleKey,
  77. (key) => {
  78. if (!key) return
  79. const m = getModule(key)
  80. if (!m) {
  81. uni.showToast({ title: '未知模块', icon: 'none' })
  82. return
  83. }
  84. module.value = m
  85. uni.setNavigationBarTitle({ title: m.title })
  86. // 深链状态筛选(如工作台待办直达 ?status=0 → 未审核)
  87. const s = props.initialStatus
  88. statusFilter.value = s === undefined || s === '' ? null : Number(s)
  89. initPaged(m)
  90. },
  91. { immediate: true }
  92. )
  93. function initPaged(m: ModuleConfig) {
  94. paged = usePagedList({
  95. apiPrefix: m.apiPrefix,
  96. searchKey: m.searchKey,
  97. fixedQuery: statusFilter.value === null ? {} : { status: statusFilter.value }
  98. })
  99. // 同步响应式状态到模板
  100. watch(
  101. [paged.records, paged.loading, paged.finished, paged.error],
  102. ([records, loading, finished, error]) => {
  103. state.records = [...records]
  104. state.loading = loading
  105. state.finished = finished
  106. state.error = error
  107. },
  108. { immediate: true }
  109. )
  110. refresh()
  111. }
  112. function refresh() {
  113. return paged?.refresh()
  114. }
  115. function loadMore() {
  116. return paged?.loadMore()
  117. }
  118. function onSearch() {
  119. paged?.onSearchInput(keyword.value)
  120. }
  121. // 输入即搜(300ms 防抖在 usePagedList 内)
  122. watch(keyword, (v) => {
  123. paged?.onSearchInput(v)
  124. })
  125. function onStatusChange(value: number | null) {
  126. statusFilter.value = value
  127. if (!paged) return
  128. paged.setFixedQuery(value === null ? { status: undefined } : { status: value })
  129. }
  130. function rowTitle(row: any): string {
  131. const m = module.value!
  132. if (m.shape === 'bill') return row.billNo || `${m.title} #${row.id}`
  133. if (m.shape === 'entity') return row.name || row.attributeName || row.nativeName || `${row.id}`
  134. return row.materialName || row.billNo || row.name || String(row.id ?? m.title)
  135. }
  136. function rowRows(row: any): { label: string; value?: string | number | null; strong?: boolean }[] {
  137. const m = module.value!
  138. if (m.shape === 'bill') {
  139. return [
  140. { label: '往来单位', value: row.organName || row.memberName || '-' },
  141. { label: '单据日期', value: row.operTime ? formatDateTime(row.operTime) : row.createTime ? formatDateTime(row.createTime) : '-' },
  142. {
  143. label: '金额',
  144. value:
  145. row.discountLastMoney != null
  146. ? formatMoney(row.discountLastMoney)
  147. : row.totalPrice != null
  148. ? formatMoney(row.totalPrice)
  149. : row.changeAmount != null
  150. ? formatMoney(row.changeAmount)
  151. : '-',
  152. strong: true
  153. }
  154. ]
  155. }
  156. if (m.shape === 'entity') {
  157. const rows: { label: string; value?: string | number | null }[] = []
  158. if (row.contacts || row.principal) rows.push({ label: '联系人', value: row.contacts || row.principal })
  159. if (row.phoneNum || row.telephone) rows.push({ label: '电话', value: row.phoneNum || row.telephone })
  160. if (row.depotType) rows.push({ label: '类型', value: row.depotType })
  161. if (row.address) rows.push({ label: '地址', value: row.address })
  162. if (row.remark) rows.push({ label: '备注', value: row.remark })
  163. if (!rows.length && row.enabled !== undefined) rows.push({ label: '状态', value: row.enabled === 1 ? '启用' : '停用' })
  164. return rows
  165. }
  166. // 报表:宽松展示前 3 个标量字段
  167. const rows: { label: string; value?: string | number | null; strong?: boolean }[] = []
  168. for (const [key, value] of Object.entries(row)) {
  169. if (rows.length >= 3) break
  170. if (['id', 'createTime', 'updateTime', 'pageNo', 'pageSize'].includes(key)) continue
  171. if (value === null || value === undefined || typeof value === 'object') continue
  172. const strong = /amount|money|price/i.test(key)
  173. rows.push({
  174. label: key,
  175. value: typeof value === 'number' && strong ? formatMoney(value) : String(value),
  176. strong
  177. })
  178. }
  179. return rows
  180. }
  181. function statusText(row: any): string {
  182. if (module.value?.shape !== 'bill' || row.status === undefined || row.status === null) return ''
  183. return BILL_STATUS_TEXT[row.status] ?? ''
  184. }
  185. function statusType(row: any): 'warning' | 'success' | 'primary' | undefined {
  186. if (module.value?.shape !== 'bill' || row.status === undefined || row.status === null) return undefined
  187. return BILL_STATUS_TYPE[row.status]
  188. }
  189. function openDetail(row: any) {
  190. const m = module.value!
  191. if (m.listOnly) return
  192. uni.navigateTo({ url: `${moduleDetailPage(m.pkg)}?module=${m.key}&id=${row.id}` })
  193. }
  194. defineExpose({ refresh, loadMore })
  195. </script>
  196. <style lang="scss" scoped>
  197. .bill-list {
  198. min-height: 100vh;
  199. background: #f2f5fa;
  200. padding-bottom: 40rpx;
  201. }
  202. .status-filter {
  203. display: flex;
  204. padding: 12rpx 24rpx;
  205. background: #fff;
  206. gap: 16rpx;
  207. &__item {
  208. padding: 8rpx 24rpx;
  209. border-radius: 24rpx;
  210. font-size: 24rpx;
  211. color: #606266;
  212. background: #f4f5f7;
  213. &--active {
  214. color: #fff;
  215. background: #2979ff;
  216. }
  217. }
  218. }
  219. </style>