expense-item.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. <template>
  2. <view class="bx-page">
  3. <view class="topbar" :style="safeAreaStyle">
  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. import { getSafeAreaStyle, getGlobalSafeArea } from '@/utils/safeArea'
  211. const safeAreaStyle = getSafeAreaStyle()
  212. const expenseTree = ref([])
  213. const loading = ref(false)
  214. const refreshing = ref(false)
  215. const expandedIds = ref([])
  216. const listHeight = ref('70vh')
  217. const showForm = ref(false)
  218. const showParentPicker = ref(false)
  219. const isEdit = ref(false)
  220. const loadingMap = reactive({})
  221. const dialogMode = ref('add')
  222. const form = reactive({
  223. id: '',
  224. itemName: '',
  225. itemCode: '',
  226. level: 1,
  227. parentId: '0',
  228. sortOrder: '1',
  229. status: 1,
  230. remark: ''
  231. })
  232. const parentOptions = computed(() =>
  233. expenseTree.value.filter((r) => Number(r.level) === 1)
  234. )
  235. const parentDisplay = computed(() => {
  236. if (!form.parentId || form.parentId === '0') return ''
  237. return parentOptions.value.find((p) => String(p.id) === form.parentId)?.itemName ?? ''
  238. })
  239. const dialogTitle = computed(() => {
  240. if (dialogMode.value === 'add') return '新增一级项目'
  241. if (dialogMode.value === 'addChild') return '新增二级项目'
  242. return '编辑项目'
  243. })
  244. function measureListHeight() {
  245. try {
  246. const sys = uni.getSystemInfoSync()
  247. const topbar = getGlobalSafeArea().headerHeight
  248. listHeight.value = `${Math.max(320, (sys.windowHeight || 600) - topbar)}px`
  249. } catch (e) {
  250. listHeight.value = '70vh'
  251. }
  252. }
  253. function isExpanded(id) {
  254. return expandedIds.value.includes(String(id))
  255. }
  256. function toggleExpand(id) {
  257. const sid = String(id)
  258. const idx = expandedIds.value.indexOf(sid)
  259. if (idx === -1) expandedIds.value.push(sid)
  260. else expandedIds.value.splice(idx, 1)
  261. }
  262. function resetForm() {
  263. Object.assign(form, {
  264. id: '',
  265. itemName: '',
  266. itemCode: '',
  267. level: 1,
  268. parentId: '0',
  269. sortOrder: '1',
  270. status: 1,
  271. remark: ''
  272. })
  273. }
  274. function openAddDialog() {
  275. dialogMode.value = 'add'
  276. isEdit.value = false
  277. resetForm()
  278. form.level = 1
  279. form.parentId = '0'
  280. showForm.value = true
  281. }
  282. function openAddChildDialog(parent) {
  283. dialogMode.value = 'addChild'
  284. isEdit.value = false
  285. resetForm()
  286. form.level = 2
  287. form.parentId = String(parent.id)
  288. showForm.value = true
  289. }
  290. function openEditDialog(item) {
  291. dialogMode.value = 'edit'
  292. isEdit.value = true
  293. Object.assign(form, {
  294. id: String(item.id),
  295. itemName: item.itemName ?? '',
  296. itemCode: item.itemCode ?? '',
  297. level: item.level ?? 1,
  298. parentId: item.parentId == null || item.parentId === 0 ? '0' : String(item.parentId),
  299. sortOrder: String(item.sortOrder ?? 1),
  300. status: item.status ?? 1,
  301. remark: item.remark ?? ''
  302. })
  303. showForm.value = true
  304. }
  305. function onParentPick(p) {
  306. form.parentId = String(p.id)
  307. showParentPicker.value = false
  308. }
  309. function onStatusSwitch(e) {
  310. form.status = e.detail.value ? 1 : 0
  311. }
  312. async function loadTree() {
  313. loading.value = true
  314. try {
  315. const res = await expenseItemApi.tree()
  316. expenseTree.value = res.data ?? []
  317. } catch (e) {
  318. // toasted by bxRequest
  319. } finally {
  320. loading.value = false
  321. refreshing.value = false
  322. }
  323. }
  324. function onRefresh() {
  325. refreshing.value = true
  326. loadTree()
  327. }
  328. function validateForm() {
  329. if (!String(form.itemName ?? '').trim()) {
  330. uni.showToast({ title: '请输入项目名称', icon: 'none' })
  331. return false
  332. }
  333. if (form.level === 2 && (!form.parentId || form.parentId === '0')) {
  334. uni.showToast({ title: '请选择上级项目', icon: 'none' })
  335. return false
  336. }
  337. return true
  338. }
  339. async function handleSubmit() {
  340. if (!validateForm()) return
  341. loadingMap.submit = true
  342. try {
  343. const sortParsed = parseInt(String(form.sortOrder).trim(), 10)
  344. const sortOrder = Number.isFinite(sortParsed) ? sortParsed : 1
  345. const payload = {
  346. itemName: form.itemName,
  347. itemCode: form.itemCode,
  348. level: form.level,
  349. parentId: form.parentId === '0' ? '0' : form.parentId,
  350. sortOrder,
  351. status: form.status,
  352. remark: form.remark
  353. }
  354. if (isEdit.value) {
  355. await expenseItemApi.update(form.id, payload)
  356. uni.showToast({ title: '编辑成功', icon: 'success' })
  357. } else {
  358. await expenseItemApi.create(payload)
  359. uni.showToast({ title: '新增成功', icon: 'success' })
  360. }
  361. showForm.value = false
  362. await loadTree()
  363. } catch (e) {
  364. // toasted
  365. } finally {
  366. loadingMap.submit = false
  367. }
  368. }
  369. function handleDelete(item) {
  370. const key = `delete-${item.id}`
  371. uni.showModal({
  372. title: '删除确认',
  373. content: `确定要删除费用项目「${item.itemName || item.itemCode}」吗?`,
  374. confirmText: '确定删除',
  375. confirmColor: '#ef4444',
  376. success: async (r) => {
  377. if (!r.confirm) return
  378. loadingMap[key] = true
  379. try {
  380. await expenseItemApi.delete(item.id)
  381. uni.showToast({ title: '删除成功', icon: 'success' })
  382. await loadTree()
  383. } catch (e) {
  384. // toasted
  385. } finally {
  386. loadingMap[key] = false
  387. }
  388. }
  389. })
  390. }
  391. function onBack() {
  392. uni.navigateBack({ fail: () => uni.reLaunch({ url: PAGE_PATHS.workbench }) })
  393. }
  394. onMounted(() => {
  395. measureListHeight()
  396. loadTree()
  397. })
  398. </script>
  399. <style lang="scss">
  400. @import '@/styles/bx-theme.scss';
  401. </style>