expense-item.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  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">费用项目管理</text>
  10. <view class="topbar-right" />
  11. </view>
  12. <view class="fab-add" @click="openAddDialog">+</view>
  13. <scroll-view
  14. class="page-padded list-scroll"
  15. scroll-y
  16. :style="{ height: listHeight }"
  17. refresher-enabled
  18. :refresher-triggered="refreshing"
  19. @refresherrefresh="onRefresh"
  20. >
  21. <view v-if="loading && !expenseTree.length" class="page-body-loading">
  22. <text>加载中...</text>
  23. </view>
  24. <template v-if="expenseTree.length">
  25. <view class="section-title">费用项目</view>
  26. <view class="card-list">
  27. <view v-for="item in expenseTree" :key="item.id" class="tree-card">
  28. <view class="tree-head" @click="toggleExpand(item.id)">
  29. <view class="tree-head__left">
  30. <view class="tree-twisty" :class="{ expanded: isExpanded(item.id) }">›</view>
  31. <text class="tree-head__title">{{ item.itemName }}</text>
  32. </view>
  33. <view class="tree-head__tags">
  34. <text class="tag tag-neutral">一级</text>
  35. <text class="status-tag" :class="adminStatusTagClass(item.status)">
  36. {{ statusLabel(item.status) }}
  37. </text>
  38. </view>
  39. </view>
  40. <view v-if="isExpanded(item.id)" class="tree-body">
  41. <view class="tree-meta-grid">
  42. <view class="tree-meta-cell">
  43. <text class="k">项目编码</text>
  44. <text class="v">{{ item.itemCode || '-' }}</text>
  45. </view>
  46. <view class="tree-meta-cell">
  47. <text class="k">排序号</text>
  48. <text class="v">{{ item.sortOrder }}</text>
  49. </view>
  50. <view class="tree-meta-cell">
  51. <text class="k">子项数量</text>
  52. <text class="v">{{
  53. item.childCount ?? (item.children && item.children.length) ?? 0
  54. }}</text>
  55. </view>
  56. </view>
  57. <view class="op-card-body">
  58. <view class="op-actions">
  59. <button class="btn btn-sm btn-primary" @click.stop="openAddChildDialog(item)">
  60. 新增子项
  61. </button>
  62. <button class="btn btn-sm btn-primary" @click.stop="openEditDialog(item)">
  63. 编辑
  64. </button>
  65. <button
  66. class="btn btn-sm btn-danger"
  67. :disabled="!!loadingMap[`delete-${item.id}`]"
  68. @click.stop="handleDelete(item)"
  69. >
  70. {{ loadingMap[`delete-${item.id}`] ? '删除中...' : '删除' }}
  71. </button>
  72. </view>
  73. </view>
  74. <view
  75. v-for="child in item.children || []"
  76. :key="child.id"
  77. class="op-card tree-child-card"
  78. >
  79. <view class="op-card-head">
  80. <text class="op-card-title"
  81. >{{ item.itemName }}/{{ child.itemName }}</text
  82. >
  83. <view class="tree-head__tags">
  84. <text class="tag tag-neutral">二级</text>
  85. <text class="status-tag" :class="adminStatusTagClass(child.status)">
  86. {{ statusLabel(child.status) }}
  87. </text>
  88. </view>
  89. </view>
  90. <view class="op-card-sub">
  91. <text>编码:{{ child.itemCode || '-' }}</text>
  92. <text>排序:{{ child.sortOrder }}</text>
  93. </view>
  94. <view class="op-card-body">
  95. <view class="op-actions">
  96. <button class="btn btn-sm btn-primary" @click.stop="openEditDialog(child)">
  97. 编辑
  98. </button>
  99. <button
  100. class="btn btn-sm btn-danger"
  101. :disabled="!!loadingMap[`delete-${child.id}`]"
  102. @click.stop="handleDelete(child)"
  103. >
  104. {{ loadingMap[`delete-${child.id}`] ? '删除中...' : '删除' }}
  105. </button>
  106. </view>
  107. </view>
  108. </view>
  109. </view>
  110. </view>
  111. </view>
  112. </template>
  113. <view v-if="!loading && !expenseTree.length" class="empty-state empty-state--fill">
  114. <text>暂无费用项目数据</text>
  115. </view>
  116. </scroll-view>
  117. <!-- 新增/编辑 -->
  118. <view v-if="showForm" class="bx-sheet-mask" @click="showForm = false">
  119. <view class="bx-sheet bx-sheet--tall" @click.stop>
  120. <view class="bx-sheet__title">{{ dialogTitle }}</view>
  121. <scroll-view scroll-y class="bx-sheet__body bx-sheet__body--form">
  122. <view class="form-card" style="margin: 0; border: none; box-shadow: none">
  123. <view class="field">
  124. <text class="field-label">项目名称<span class="req">*</span></text>
  125. <input v-model="form.itemName" class="form-input" placeholder="请输入项目名称" />
  126. </view>
  127. <view class="field">
  128. <text class="field-label">项目编码</text>
  129. <input v-model="form.itemCode" class="form-input" placeholder="请输入项目编码" />
  130. </view>
  131. <view v-if="form.level === 2" class="field">
  132. <text class="field-label">上级项目</text>
  133. <view class="picker-trigger" @click="showParentPicker = true">
  134. <text :class="parentDisplay ? 'value' : 'ph'">{{
  135. parentDisplay || '请选择上级项目'
  136. }}</text>
  137. <text class="arrow">›</text>
  138. </view>
  139. </view>
  140. <view class="field">
  141. <text class="field-label">排序号</text>
  142. <input
  143. v-model="form.sortOrder"
  144. class="form-input"
  145. type="number"
  146. placeholder="请输入排序号"
  147. />
  148. </view>
  149. <view class="field">
  150. <text class="field-label">备注</text>
  151. <textarea
  152. v-model="form.remark"
  153. class="form-textarea"
  154. maxlength="200"
  155. placeholder="请输入备注(可选)"
  156. />
  157. <view class="form-textarea-counter">{{ (form.remark || '').length }}/200</view>
  158. </view>
  159. <view class="field">
  160. <text class="field-label">状态</text>
  161. <view class="switch-row">
  162. <switch
  163. :checked="form.status === 1"
  164. color="#00c98d"
  165. @change="onStatusSwitch"
  166. />
  167. <text class="switch-row__label">{{ form.status === 1 ? '启用' : '禁用' }}</text>
  168. </view>
  169. </view>
  170. </view>
  171. </scroll-view>
  172. <view class="bx-sheet__footer" style="display: flex; gap: 10px">
  173. <button class="btn btn-secondary" style="flex: 1" @click="showForm = false">取消</button>
  174. <button
  175. class="btn btn-primary"
  176. style="flex: 1"
  177. :disabled="!!loadingMap.submit"
  178. @click="handleSubmit"
  179. >
  180. {{ loadingMap.submit ? '保存中...' : '保存' }}
  181. </button>
  182. </view>
  183. </view>
  184. </view>
  185. <!-- 上级项目选择 -->
  186. <view v-if="showParentPicker" class="bx-sheet-mask" @click="showParentPicker = false">
  187. <view class="bx-sheet" @click.stop>
  188. <view class="bx-sheet__title">选择上级项目</view>
  189. <scroll-view scroll-y class="bx-sheet__body">
  190. <view v-if="!parentOptions.length" class="bx-sheet__empty">暂无一级项目</view>
  191. <view
  192. v-for="p in parentOptions"
  193. :key="p.id"
  194. class="bx-sheet__item"
  195. :class="{ 'is-selected': String(form.parentId) === String(p.id) }"
  196. @click="onParentPick(p)"
  197. >
  198. {{ p.itemName }}
  199. </view>
  200. </scroll-view>
  201. </view>
  202. </view>
  203. </view>
  204. </template>
  205. <script setup>
  206. import { ref, reactive, computed, onMounted } from 'vue'
  207. import { expenseItemApi } from '@/api/bx'
  208. import { statusLabel, adminStatusTagClass } from '@/utils/bxFormat'
  209. import { PAGE_PATHS } from '@/config/api'
  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 = 44 + (sys.statusBarHeight || 0)
  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.switchTab({ 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>