expense-item.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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">费用项目管理</text>
  7. </view>
  8. <view class="topbar-right" />
  9. </view>
  10. <view class="fab-add" @click="openAddDialog">+</view>
  11. <scroll-view
  12. class="page-padded list-scroll"
  13. scroll-y
  14. :style="{ height: listHeight }"
  15. refresher-enabled
  16. :refresher-triggered="refreshing"
  17. @refresherrefresh="onRefresh"
  18. >
  19. <view v-if="loading && !expenseTree.length" class="page-body-loading">
  20. <text>加载中...</text>
  21. </view>
  22. <template v-if="expenseTree.length">
  23. <view class="section-title">费用项目</view>
  24. <view class="card-list">
  25. <view v-for="item in expenseTree" :key="item.id" class="tree-card">
  26. <view class="tree-head" @click="toggleExpand(item.id)">
  27. <view class="tree-head__left">
  28. <view class="tree-twisty" :class="{ expanded: isExpanded(item.id) }">›</view>
  29. <text class="tree-head__title">{{ item.itemName }}</text>
  30. </view>
  31. <view class="tree-head__tags">
  32. <text class="tag tag-neutral">一级</text>
  33. <text class="status-tag" :class="adminStatusTagClass(item.status)">
  34. {{ statusLabel(item.status) }}
  35. </text>
  36. </view>
  37. </view>
  38. <view v-if="isExpanded(item.id)" class="tree-body">
  39. <view class="tree-meta-grid">
  40. <view class="tree-meta-cell">
  41. <text class="k">项目编码</text>
  42. <text class="v">{{ item.itemCode || '-' }}</text>
  43. </view>
  44. <view class="tree-meta-cell">
  45. <text class="k">排序号</text>
  46. <text class="v">{{ item.sortOrder }}</text>
  47. </view>
  48. <view class="tree-meta-cell">
  49. <text class="k">子项数量</text>
  50. <text class="v">{{
  51. item.childCount ?? (item.children && item.children.length) ?? 0
  52. }}</text>
  53. </view>
  54. </view>
  55. <view class="op-card-body">
  56. <view class="op-actions">
  57. <button class="btn btn-sm btn-primary" @click.stop="openAddChildDialog(item)">
  58. 新增子项
  59. </button>
  60. <button class="btn btn-sm btn-primary" @click.stop="openEditDialog(item)">
  61. 编辑
  62. </button>
  63. <button
  64. class="btn btn-sm btn-danger"
  65. :disabled="!!loadingMap[`delete-${item.id}`]"
  66. @click.stop="handleDelete(item)"
  67. >
  68. {{ loadingMap[`delete-${item.id}`] ? '删除中...' : '删除' }}
  69. </button>
  70. </view>
  71. </view>
  72. <view
  73. v-for="child in item.children || []"
  74. :key="child.id"
  75. class="op-card tree-child-card"
  76. >
  77. <view class="op-card-head">
  78. <text class="op-card-title"
  79. >{{ item.itemName }}/{{ child.itemName }}</text
  80. >
  81. <view class="tree-head__tags">
  82. <text class="tag tag-neutral">二级</text>
  83. <text class="status-tag" :class="adminStatusTagClass(child.status)">
  84. {{ statusLabel(child.status) }}
  85. </text>
  86. </view>
  87. </view>
  88. <view class="op-card-sub">
  89. <text>编码:{{ child.itemCode || '-' }}</text>
  90. <text>排序:{{ child.sortOrder }}</text>
  91. </view>
  92. <view class="op-card-body">
  93. <view class="op-actions">
  94. <button class="btn btn-sm btn-primary" @click.stop="openEditDialog(child)">
  95. 编辑
  96. </button>
  97. <button
  98. class="btn btn-sm btn-danger"
  99. :disabled="!!loadingMap[`delete-${child.id}`]"
  100. @click.stop="handleDelete(child)"
  101. >
  102. {{ loadingMap[`delete-${child.id}`] ? '删除中...' : '删除' }}
  103. </button>
  104. </view>
  105. </view>
  106. </view>
  107. </view>
  108. </view>
  109. </view>
  110. </template>
  111. <view v-if="!loading && !expenseTree.length" class="empty-state empty-state--fill">
  112. <text>暂无费用项目数据</text>
  113. </view>
  114. </scroll-view>
  115. <!-- 新增/编辑 -->
  116. <view v-if="showForm" class="bx-sheet-mask" @click="showForm = false">
  117. <view class="bx-sheet bx-sheet--tall" @click.stop>
  118. <view class="bx-sheet__title">{{ dialogTitle }}</view>
  119. <scroll-view scroll-y class="bx-sheet__body bx-sheet__body--form">
  120. <view class="form-card" style="margin: 0; border: none; box-shadow: none">
  121. <view class="field">
  122. <text class="field-label">项目名称<span class="req">*</span></text>
  123. <input v-model="form.itemName" class="form-input" placeholder="请输入项目名称" />
  124. </view>
  125. <view class="field">
  126. <text class="field-label">项目编码</text>
  127. <input v-model="form.itemCode" class="form-input" placeholder="请输入项目编码" />
  128. </view>
  129. <view v-if="form.level === 2" class="field">
  130. <text class="field-label">上级项目</text>
  131. <view class="picker-trigger" @click="showParentPicker = true">
  132. <text :class="parentDisplay ? 'value' : 'ph'">{{
  133. parentDisplay || '请选择上级项目'
  134. }}</text>
  135. <text class="arrow">›</text>
  136. </view>
  137. </view>
  138. <view class="field">
  139. <text class="field-label">排序号</text>
  140. <input
  141. v-model="form.sortOrder"
  142. class="form-input"
  143. type="number"
  144. placeholder="请输入排序号"
  145. />
  146. </view>
  147. <view class="field">
  148. <text class="field-label">备注</text>
  149. <textarea
  150. v-model="form.remark"
  151. class="form-textarea"
  152. maxlength="200"
  153. placeholder="请输入备注(可选)"
  154. />
  155. <view class="form-textarea-counter">{{ (form.remark || '').length }}/200</view>
  156. </view>
  157. <view class="field">
  158. <text class="field-label">状态</text>
  159. <view class="switch-row">
  160. <switch
  161. :checked="form.status === 1"
  162. color="#00c98d"
  163. @change="onStatusSwitch"
  164. />
  165. <text class="switch-row__label">{{ form.status === 1 ? '启用' : '禁用' }}</text>
  166. </view>
  167. </view>
  168. </view>
  169. </scroll-view>
  170. <view class="bx-sheet__footer" style="display: flex; gap: 10px">
  171. <button class="btn btn-secondary" style="flex: 1" @click="showForm = false">取消</button>
  172. <button
  173. class="btn btn-primary"
  174. style="flex: 1"
  175. :disabled="!!loadingMap.submit"
  176. @click="handleSubmit"
  177. >
  178. {{ loadingMap.submit ? '保存中...' : '保存' }}
  179. </button>
  180. </view>
  181. </view>
  182. </view>
  183. <!-- 上级项目选择 -->
  184. <view v-if="showParentPicker" class="bx-sheet-mask" @click="showParentPicker = false">
  185. <view class="bx-sheet" @click.stop>
  186. <view class="bx-sheet__title">选择上级项目</view>
  187. <scroll-view scroll-y class="bx-sheet__body">
  188. <view v-if="!parentOptions.length" class="bx-sheet__empty">暂无一级项目</view>
  189. <view
  190. v-for="p in parentOptions"
  191. :key="p.id"
  192. class="bx-sheet__item"
  193. :class="{ 'is-selected': String(form.parentId) === String(p.id) }"
  194. @click="onParentPick(p)"
  195. >
  196. {{ p.itemName }}
  197. </view>
  198. </scroll-view>
  199. </view>
  200. </view>
  201. </view>
  202. </template>
  203. <script setup>
  204. import { ref, reactive, computed, onMounted } from 'vue'
  205. import { expenseItemApi } from '@/api/bx'
  206. import { statusLabel, adminStatusTagClass } from '@/utils/bxFormat'
  207. import { PAGE_PATHS } from '@/config/api'
  208. import { getSafeAreaStyle, getGlobalSafeArea } from '@/utils/safeArea'
  209. const safeAreaStyle = getSafeAreaStyle()
  210. const expenseTree = ref([])
  211. const loading = ref(false)
  212. const refreshing = ref(false)
  213. const expandedIds = ref([])
  214. const listHeight = ref('70vh')
  215. const showForm = ref(false)
  216. const showParentPicker = ref(false)
  217. const isEdit = ref(false)
  218. const loadingMap = reactive({})
  219. const dialogMode = ref('add')
  220. const form = reactive({
  221. id: '',
  222. itemName: '',
  223. itemCode: '',
  224. level: 1,
  225. parentId: '0',
  226. sortOrder: '1',
  227. status: 1,
  228. remark: ''
  229. })
  230. const parentOptions = computed(() =>
  231. expenseTree.value.filter((r) => Number(r.level) === 1)
  232. )
  233. const parentDisplay = computed(() => {
  234. if (!form.parentId || form.parentId === '0') return ''
  235. return parentOptions.value.find((p) => String(p.id) === form.parentId)?.itemName ?? ''
  236. })
  237. const dialogTitle = computed(() => {
  238. if (dialogMode.value === 'add') return '新增一级项目'
  239. if (dialogMode.value === 'addChild') return '新增二级项目'
  240. return '编辑项目'
  241. })
  242. function measureListHeight() {
  243. try {
  244. const sys = uni.getSystemInfoSync()
  245. const topbar = getGlobalSafeArea().headerHeight
  246. listHeight.value = `${Math.max(320, (sys.windowHeight || 600) - topbar)}px`
  247. } catch (e) {
  248. listHeight.value = '70vh'
  249. }
  250. }
  251. function isExpanded(id) {
  252. return expandedIds.value.includes(String(id))
  253. }
  254. function toggleExpand(id) {
  255. const sid = String(id)
  256. const idx = expandedIds.value.indexOf(sid)
  257. if (idx === -1) expandedIds.value.push(sid)
  258. else expandedIds.value.splice(idx, 1)
  259. }
  260. function resetForm() {
  261. Object.assign(form, {
  262. id: '',
  263. itemName: '',
  264. itemCode: '',
  265. level: 1,
  266. parentId: '0',
  267. sortOrder: '1',
  268. status: 1,
  269. remark: ''
  270. })
  271. }
  272. function openAddDialog() {
  273. dialogMode.value = 'add'
  274. isEdit.value = false
  275. resetForm()
  276. form.level = 1
  277. form.parentId = '0'
  278. showForm.value = true
  279. }
  280. function openAddChildDialog(parent) {
  281. dialogMode.value = 'addChild'
  282. isEdit.value = false
  283. resetForm()
  284. form.level = 2
  285. form.parentId = String(parent.id)
  286. showForm.value = true
  287. }
  288. function openEditDialog(item) {
  289. dialogMode.value = 'edit'
  290. isEdit.value = true
  291. Object.assign(form, {
  292. id: String(item.id),
  293. itemName: item.itemName ?? '',
  294. itemCode: item.itemCode ?? '',
  295. level: item.level ?? 1,
  296. parentId: item.parentId == null || item.parentId === 0 ? '0' : String(item.parentId),
  297. sortOrder: String(item.sortOrder ?? 1),
  298. status: item.status ?? 1,
  299. remark: item.remark ?? ''
  300. })
  301. showForm.value = true
  302. }
  303. function onParentPick(p) {
  304. form.parentId = String(p.id)
  305. showParentPicker.value = false
  306. }
  307. function onStatusSwitch(e) {
  308. form.status = e.detail.value ? 1 : 0
  309. }
  310. async function loadTree() {
  311. loading.value = true
  312. try {
  313. const res = await expenseItemApi.tree()
  314. expenseTree.value = res.data ?? []
  315. } catch (e) {
  316. // toasted by bxRequest
  317. } finally {
  318. loading.value = false
  319. refreshing.value = false
  320. }
  321. }
  322. function onRefresh() {
  323. refreshing.value = true
  324. loadTree()
  325. }
  326. function validateForm() {
  327. if (!String(form.itemName ?? '').trim()) {
  328. uni.showToast({ title: '请输入项目名称', icon: 'none' })
  329. return false
  330. }
  331. if (form.level === 2 && (!form.parentId || form.parentId === '0')) {
  332. uni.showToast({ title: '请选择上级项目', icon: 'none' })
  333. return false
  334. }
  335. return true
  336. }
  337. async function handleSubmit() {
  338. if (!validateForm()) return
  339. loadingMap.submit = true
  340. try {
  341. const sortParsed = parseInt(String(form.sortOrder).trim(), 10)
  342. const sortOrder = Number.isFinite(sortParsed) ? sortParsed : 1
  343. const payload = {
  344. itemName: form.itemName,
  345. itemCode: form.itemCode,
  346. level: form.level,
  347. parentId: form.parentId === '0' ? '0' : form.parentId,
  348. sortOrder,
  349. status: form.status,
  350. remark: form.remark
  351. }
  352. if (isEdit.value) {
  353. await expenseItemApi.update(form.id, payload)
  354. uni.showToast({ title: '编辑成功', icon: 'success' })
  355. } else {
  356. await expenseItemApi.create(payload)
  357. uni.showToast({ title: '新增成功', icon: 'success' })
  358. }
  359. showForm.value = false
  360. await loadTree()
  361. } catch (e) {
  362. // toasted
  363. } finally {
  364. loadingMap.submit = false
  365. }
  366. }
  367. function handleDelete(item) {
  368. const key = `delete-${item.id}`
  369. uni.showModal({
  370. title: '删除确认',
  371. content: `确定要删除费用项目「${item.itemName || item.itemCode}」吗?`,
  372. confirmText: '确定删除',
  373. confirmColor: '#ef4444',
  374. success: async (r) => {
  375. if (!r.confirm) return
  376. loadingMap[key] = true
  377. try {
  378. await expenseItemApi.delete(item.id)
  379. uni.showToast({ title: '删除成功', icon: 'success' })
  380. await loadTree()
  381. } catch (e) {
  382. // toasted
  383. } finally {
  384. loadingMap[key] = false
  385. }
  386. }
  387. })
  388. }
  389. function onBack() {
  390. uni.navigateBack({ fail: () => uni.reLaunch({ url: PAGE_PATHS.workbench }) })
  391. }
  392. onMounted(() => {
  393. measureListHeight()
  394. loadTree()
  395. })
  396. </script>
  397. <style lang="scss">
  398. @import '@/styles/bx-theme.scss';
  399. </style>