expense-item.vue 14 KB

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