ExpenseProjectPage.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. <template>
  2. <div class="page">
  3. <div class="topbar">
  4. <div class="topbar-left">
  5. <div class="topbar-back" @click="router.back()">
  6. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
  7. </div>
  8. </div>
  9. <span class="topbar-title">费用项目管理</span>
  10. <div class="topbar-right"></div>
  11. </div>
  12. <button type="button" class="fab-add" aria-label="新增费用项目" @click="openAddDialog()">
  13. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
  14. </button>
  15. <div class="page-padded">
  16. <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
  17. <div v-if="loading && expenseTree.length === 0" class="page-body-loading">
  18. <van-loading size="24px" vertical>加载中...</van-loading>
  19. </div>
  20. <template v-if="expenseTree.length > 0">
  21. <div class="section-title">费用项目</div>
  22. <div class="card-list">
  23. <div v-for="item in expenseTree" :key="item.id" class="tree-card">
  24. <!-- Level 1 Header -->
  25. <div class="tree-head" @click="toggleExpand(item.id)">
  26. <div style="display: flex; align-items: center; gap: 8px;">
  27. <span :class="['tree-node', '', expandedIds.includes(String(item.id)) ? 'expanded' : '']" style="padding: 0;">
  28. <span class="twisty" style="background: rgba(16, 185, 129, .10); width: 22px; height: 22px; border-radius: 7px; display: flex; align-items: center; justify-content: center;">
  29. <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--primary-dark)" stroke-width="2" :style="{ transform: expandedIds.includes(String(item.id)) ? 'rotate(90deg)' : '', transition: '.15s' }"><path d="m9 18 6-6-6-6"/></svg>
  30. </span>
  31. </span>
  32. <span class="title">{{ item.itemName }}</span>
  33. </div>
  34. <div style="display: flex; gap: 4px; align-items: center;">
  35. <span class="tag tag-neutral">一级</span>
  36. <StatusTag :text="statusLabel(item.status)" :variant="item.status === 1 ? 'success' : 'muted'" />
  37. </div>
  38. </div>
  39. <!-- Level 1 Details -->
  40. <div v-show="expandedIds.includes(String(item.id))">
  41. <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 8px 14px; background: var(--bg);">
  42. <div>
  43. <div style="font-size: 11px; color: var(--text-muted);">项目编码</div>
  44. <div style="font-size: 13px; font-weight: 500; color: var(--text); margin-top: 2px;">{{ item.itemCode || '-' }}</div>
  45. </div>
  46. <div>
  47. <div style="font-size: 11px; color: var(--text-muted);">排序号</div>
  48. <div style="font-size: 13px; font-weight: 500; color: var(--text); margin-top: 2px;">{{ item.sortOrder }}</div>
  49. </div>
  50. <div>
  51. <div style="font-size: 11px; color: var(--text-muted);">子项数量</div>
  52. <div style="font-size: 13px; font-weight: 500; color: var(--text); margin-top: 2px;">{{ item.childCount ?? (item.children?.length ?? 0) }}</div>
  53. </div>
  54. </div>
  55. <div class="op-card-body" style="padding: 10px 14px 10px;">
  56. <div class="op-actions">
  57. <button class="btn btn-sm btn-primary" @click="openAddChildDialog(item)">新增子项</button>
  58. <button class="btn btn-sm btn-primary" @click="openEditDialog(item)">编辑</button>
  59. <button class="btn btn-sm btn-danger" :disabled="loadingMap[`delete-${item.id}`]" @click="handleDelete(item)">
  60. {{ loadingMap[`delete-${item.id}`] ? '删除中...' : '删除' }}
  61. </button>
  62. </div>
  63. </div>
  64. <!-- Children (Level 2) -->
  65. <template v-if="item.children && item.children.length > 0">
  66. <div
  67. v-for="child in item.children"
  68. :key="child.id"
  69. class="op-card"
  70. style="margin: 0 14px 10px; border-left: 3px solid var(--primary);"
  71. >
  72. <div class="op-card-head">
  73. <div class="op-card-title" style="font-size: 14px;">{{ item.itemName }}/{{ child.itemName }}</div>
  74. <div style="display: flex; gap: 4px;">
  75. <span class="tag tag-neutral">二级</span>
  76. <StatusTag :text="statusLabel(child.status)" :variant="child.status === 1 ? 'success' : 'muted'" />
  77. </div>
  78. </div>
  79. <div class="op-card-sub">
  80. <span>编码:{{ child.itemCode || '-' }}</span>
  81. <span>排序:{{ child.sortOrder }}</span>
  82. </div>
  83. <div class="op-card-body">
  84. <div class="op-actions">
  85. <button class="btn btn-sm btn-primary" @click="openEditDialog(child)">编辑</button>
  86. <button class="btn btn-sm btn-danger" :disabled="loadingMap[`delete-${child.id}`]" @click="handleDelete(child)">
  87. {{ loadingMap[`delete-${child.id}`] ? '删除中...' : '删除' }}
  88. </button>
  89. </div>
  90. </div>
  91. </div>
  92. </template>
  93. </div>
  94. </div>
  95. </div>
  96. </template>
  97. <div v-if="!loading && expenseTree.length === 0" class="empty-state empty-state--fill">
  98. <van-icon name="gold-coin-o" :size="56" color="var(--text-muted)" />
  99. <p>暂无费用项目数据</p>
  100. </div>
  101. </van-pull-refresh>
  102. </div><!-- /page-padded -->
  103. <!-- Add/Edit Popup -->
  104. <van-popup
  105. v-model:show="showForm"
  106. position="bottom"
  107. round
  108. :style="{ maxHeight: '85vh' }"
  109. >
  110. <div class="popup-sheet-title-wrap">
  111. <div class="section-title">{{ dialogTitle }}</div>
  112. </div>
  113. <div class="form-card" style="margin: 0 16px;">
  114. <div class="field">
  115. <label class="field-label">项目名称<span class="req">*</span></label>
  116. <input v-model="form.itemName" class="form-input" placeholder="请输入项目名称" />
  117. </div>
  118. <div class="field">
  119. <label class="field-label">项目编码</label>
  120. <input v-model="form.itemCode" class="form-input" placeholder="请输入项目编码" />
  121. </div>
  122. <div v-if="form.level === 2" class="field">
  123. <label class="field-label">上级项目</label>
  124. <div class="form-input-icon">
  125. <input
  126. :value="parentDisplay"
  127. class="form-input"
  128. readonly
  129. placeholder="请选择上级项目"
  130. @click="showParentPicker = true"
  131. />
  132. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
  133. </div>
  134. </div>
  135. <div class="field">
  136. <label class="field-label">排序号</label>
  137. <input v-model="form.sortOrder" type="number" class="form-input" placeholder="请输入排序号" />
  138. </div>
  139. <div class="field">
  140. <label class="field-label">备注</label>
  141. <textarea v-model="form.remark" class="form-textarea" rows="2" placeholder="请输入备注(可选)" maxlength="200"></textarea>
  142. <div class="form-textarea-counter">{{ form.remark.length }}/200</div>
  143. </div>
  144. <div class="field">
  145. <label class="field-label">状态</label>
  146. <div style="display: flex; align-items: center; gap: 10px;">
  147. <van-switch v-model="statusSwitch" size="20px" />
  148. <span style="font-size: 13px; color: var(--text-sec);">{{ form.status === 1 ? '启用' : '禁用' }}</span>
  149. </div>
  150. </div>
  151. </div>
  152. <div class="bottom-action-bar" style="position: static; box-shadow: none; padding: 12px 16px; padding-bottom: calc(12px + env(safe-area-inset-bottom));">
  153. <button class="btn btn-secondary" style="flex: 1;" @click="showForm = false">取消</button>
  154. <button class="btn btn-primary" style="flex: 1;" :disabled="loadingMap.submit" @click="handleSubmit">
  155. {{ loadingMap.submit ? '保存中...' : '保存' }}
  156. </button>
  157. </div>
  158. </van-popup>
  159. <!-- Parent Picker -->
  160. <van-popup v-model:show="showParentPicker" position="bottom" round>
  161. <van-picker
  162. :columns="parentColumns"
  163. @confirm="onParentConfirm"
  164. @cancel="showParentPicker = false"
  165. />
  166. </van-popup>
  167. </div>
  168. </template>
  169. <script setup lang="ts">
  170. import { ref, reactive, computed, onMounted } from 'vue'
  171. import { useRouter } from 'vue-router'
  172. import { showToast, showDialog } from 'vant'
  173. import { expenseItemApi } from '@/api'
  174. import { DELETE_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
  175. import { statusLabel } from '@/utils/format'
  176. import StatusTag from '@/components/mobile/StatusTag.vue'
  177. const router = useRouter()
  178. const expenseTree = ref<any[]>([])
  179. const loading = ref(false)
  180. const refreshing = ref(false)
  181. const expandedIds = ref<string[]>([])
  182. const showForm = ref(false)
  183. const showParentPicker = ref(false)
  184. const isEdit = ref(false)
  185. const loadingMap = reactive<Record<string, boolean>>({})
  186. const dialogMode = ref<'add' | 'addChild' | 'edit'>('add')
  187. const form = reactive({
  188. id: '' as string,
  189. itemName: '',
  190. itemCode: '',
  191. level: 1,
  192. parentId: '0' as string,
  193. sortOrder: '1',
  194. status: 1,
  195. remark: '',
  196. })
  197. const statusSwitch = computed({
  198. get: () => form.status === 1 || form.status === ('1' as any),
  199. set: (v: boolean) => { form.status = v ? 1 : 0 },
  200. })
  201. const parentOptions = computed(() => expenseTree.value.filter((r: any) => Number(r.level) === 1))
  202. const parentColumns = computed(() =>
  203. parentOptions.value.map((p: any) => ({ text: p.itemName, value: String(p.id) })),
  204. )
  205. const parentDisplay = computed(() => {
  206. if (!form.parentId || form.parentId === '0') return ''
  207. return parentOptions.value.find((p: any) => String(p.id) === form.parentId)?.itemName ?? ''
  208. })
  209. const dialogTitle = computed(() => {
  210. if (dialogMode.value === 'add') return '新增一级项目'
  211. if (dialogMode.value === 'addChild') return '新增二级项目'
  212. return '编辑项目'
  213. })
  214. function toggleExpand(id: string | number) {
  215. const sid = String(id)
  216. const idx = expandedIds.value.indexOf(sid)
  217. if (idx === -1) {
  218. expandedIds.value.push(sid)
  219. } else {
  220. expandedIds.value.splice(idx, 1)
  221. }
  222. }
  223. function resetForm() {
  224. Object.assign(form, {
  225. id: '',
  226. itemName: '',
  227. itemCode: '',
  228. level: 1,
  229. parentId: '0',
  230. sortOrder: '1',
  231. status: 1,
  232. remark: '',
  233. })
  234. }
  235. function openAddDialog() {
  236. dialogMode.value = 'add'
  237. isEdit.value = false
  238. resetForm()
  239. form.level = 1
  240. form.parentId = '0'
  241. showForm.value = true
  242. }
  243. function openAddChildDialog(parent: any) {
  244. dialogMode.value = 'addChild'
  245. isEdit.value = false
  246. resetForm()
  247. form.level = 2
  248. form.parentId = String(parent.id)
  249. showForm.value = true
  250. }
  251. function openEditDialog(item: any) {
  252. dialogMode.value = 'edit'
  253. isEdit.value = true
  254. Object.assign(form, {
  255. id: String(item.id),
  256. itemName: item.itemName ?? '',
  257. itemCode: item.itemCode ?? '',
  258. level: item.level ?? 1,
  259. parentId: item.parentId == null || item.parentId === 0 ? '0' : String(item.parentId),
  260. sortOrder: String(item.sortOrder ?? 1),
  261. status: item.status ?? 1,
  262. remark: item.remark ?? '',
  263. })
  264. showForm.value = true
  265. }
  266. function onParentConfirm({ selectedOptions }: any) {
  267. if (selectedOptions.length > 0) {
  268. form.parentId = String(selectedOptions[0].value)
  269. }
  270. showParentPicker.value = false
  271. }
  272. async function loadTree() {
  273. loading.value = true
  274. try {
  275. const res: any = await expenseItemApi.tree()
  276. expenseTree.value = res.data ?? []
  277. expandedIds.value = expenseTree.value.map((m: any) => String(m.id))
  278. } catch (e: any) {
  279. showToast(e?.message || '加载数据失败')
  280. } finally {
  281. loading.value = false
  282. refreshing.value = false
  283. }
  284. }
  285. function onRefresh() {
  286. loadTree()
  287. }
  288. function validateExpenseItemForm(): boolean {
  289. if (!String(form.itemName ?? '').trim()) {
  290. showToast('请输入项目名称')
  291. return false
  292. }
  293. if (form.level === 2 && (!form.parentId || form.parentId === '0')) {
  294. showToast('请选择上级项目')
  295. return false
  296. }
  297. return true
  298. }
  299. async function handleSubmit() {
  300. if (!validateExpenseItemForm()) return
  301. loadingMap.submit = true
  302. try {
  303. const sortParsed = parseInt(String(form.sortOrder).trim(), 10)
  304. const sortOrder = Number.isFinite(sortParsed) ? sortParsed : 1
  305. const payload = {
  306. itemName: form.itemName,
  307. itemCode: form.itemCode,
  308. level: form.level,
  309. parentId: form.parentId === '0' ? '0' : form.parentId,
  310. sortOrder,
  311. status: form.status,
  312. remark: form.remark,
  313. }
  314. if (isEdit.value) {
  315. await expenseItemApi.update(form.id, payload)
  316. showToast('编辑成功')
  317. } else {
  318. await expenseItemApi.create(payload)
  319. showToast('新增成功')
  320. }
  321. showForm.value = false
  322. await loadTree()
  323. } catch (e: any) {
  324. showToast(e?.message || '操作失败')
  325. } finally {
  326. loadingMap.submit = false
  327. }
  328. }
  329. async function handleDelete(item: any) {
  330. const key = `delete-${item.id}`
  331. try {
  332. await showDialog({
  333. title: '删除确认',
  334. message: `确定要删除费用项目「${item.itemName || item.itemCode}」吗?`,
  335. showCancelButton: true,
  336. confirmButtonText: '确定删除',
  337. confirmButtonColor: DELETE_CONFIRM_BUTTON_COLOR,
  338. cancelButtonText: '取消',
  339. })
  340. loadingMap[key] = true
  341. await expenseItemApi.delete(item.id)
  342. showToast('删除成功')
  343. await loadTree()
  344. } catch (e: any) {
  345. if (e !== 'cancel') {
  346. showToast(e?.message || '删除失败')
  347. }
  348. } finally {
  349. loadingMap[key] = false
  350. }
  351. }
  352. onMounted(loadTree)
  353. </script>