| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432 |
- <template>
- <view class="bx-page">
- <view class="topbar" :style="safeAreaStyle">
- <view class="topbar-left">
- <text class="topbar-back" @click="onBack">←</text>
- <text class="topbar-title">费用项目管理</text>
- </view>
- <view class="topbar-right" />
- </view>
- <view class="fab-add" @click="openAddDialog">+</view>
- <scroll-view
- class="page-padded list-scroll"
- scroll-y
- :style="{ height: listHeight }"
- refresher-enabled
- :refresher-triggered="refreshing"
- @refresherrefresh="onRefresh"
- >
- <view v-if="loading && !expenseTree.length" class="page-body-loading">
- <text>加载中...</text>
- </view>
- <template v-if="expenseTree.length">
- <view class="section-title">费用项目</view>
- <view class="card-list">
- <view v-for="item in expenseTree" :key="item.id" class="tree-card">
- <view class="tree-head" @click="toggleExpand(item.id)">
- <view class="tree-head__left">
- <view class="tree-twisty" :class="{ expanded: isExpanded(item.id) }">›</view>
- <text class="tree-head__title">{{ item.itemName }}</text>
- </view>
- <view class="tree-head__tags">
- <text class="tag tag-neutral">一级</text>
- <text class="status-tag" :class="adminStatusTagClass(item.status)">
- {{ statusLabel(item.status) }}
- </text>
- </view>
- </view>
- <view v-if="isExpanded(item.id)" class="tree-body">
- <view class="tree-meta-grid">
- <view class="tree-meta-cell">
- <text class="k">项目编码</text>
- <text class="v">{{ item.itemCode || '-' }}</text>
- </view>
- <view class="tree-meta-cell">
- <text class="k">排序号</text>
- <text class="v">{{ item.sortOrder }}</text>
- </view>
- <view class="tree-meta-cell">
- <text class="k">子项数量</text>
- <text class="v">{{
- item.childCount ?? (item.children && item.children.length) ?? 0
- }}</text>
- </view>
- </view>
- <view class="op-card-body">
- <view class="op-actions">
- <button class="btn btn-sm btn-primary" @click.stop="openAddChildDialog(item)">
- 新增子项
- </button>
- <button class="btn btn-sm btn-primary" @click.stop="openEditDialog(item)">
- 编辑
- </button>
- <button
- class="btn btn-sm btn-danger"
- :disabled="!!loadingMap[`delete-${item.id}`]"
- @click.stop="handleDelete(item)"
- >
- {{ loadingMap[`delete-${item.id}`] ? '删除中...' : '删除' }}
- </button>
- </view>
- </view>
- <view
- v-for="child in item.children || []"
- :key="child.id"
- class="op-card tree-child-card"
- >
- <view class="op-card-head">
- <text class="op-card-title"
- >{{ item.itemName }}/{{ child.itemName }}</text
- >
- <view class="tree-head__tags">
- <text class="tag tag-neutral">二级</text>
- <text class="status-tag" :class="adminStatusTagClass(child.status)">
- {{ statusLabel(child.status) }}
- </text>
- </view>
- </view>
- <view class="op-card-sub">
- <text>编码:{{ child.itemCode || '-' }}</text>
- <text>排序:{{ child.sortOrder }}</text>
- </view>
- <view class="op-card-body">
- <view class="op-actions">
- <button class="btn btn-sm btn-primary" @click.stop="openEditDialog(child)">
- 编辑
- </button>
- <button
- class="btn btn-sm btn-danger"
- :disabled="!!loadingMap[`delete-${child.id}`]"
- @click.stop="handleDelete(child)"
- >
- {{ loadingMap[`delete-${child.id}`] ? '删除中...' : '删除' }}
- </button>
- </view>
- </view>
- </view>
- </view>
- </view>
- </view>
- </template>
- <view v-if="!loading && !expenseTree.length" class="empty-state empty-state--fill">
- <text>暂无费用项目数据</text>
- </view>
- </scroll-view>
- <!-- 新增/编辑 -->
- <view v-if="showForm" class="bx-sheet-mask" @click="showForm = false">
- <view class="bx-sheet bx-sheet--tall" @click.stop>
- <view class="bx-sheet__title">{{ dialogTitle }}</view>
- <scroll-view scroll-y class="bx-sheet__body bx-sheet__body--form">
- <view class="form-card" style="margin: 0; border: none; box-shadow: none">
- <view class="field">
- <text class="field-label">项目名称<span class="req">*</span></text>
- <input v-model="form.itemName" class="form-input" placeholder="请输入项目名称" />
- </view>
- <view class="field">
- <text class="field-label">项目编码</text>
- <input v-model="form.itemCode" class="form-input" placeholder="请输入项目编码" />
- </view>
- <view v-if="form.level === 2" class="field">
- <text class="field-label">上级项目</text>
- <view class="picker-trigger" @click="showParentPicker = true">
- <text :class="parentDisplay ? 'value' : 'ph'">{{
- parentDisplay || '请选择上级项目'
- }}</text>
- <text class="arrow">›</text>
- </view>
- </view>
- <view class="field">
- <text class="field-label">排序号</text>
- <input
- v-model="form.sortOrder"
- class="form-input"
- type="number"
- placeholder="请输入排序号"
- />
- </view>
- <view class="field">
- <text class="field-label">备注</text>
- <textarea
- v-model="form.remark"
- class="form-textarea"
- maxlength="200"
- placeholder="请输入备注(可选)"
- />
- <view class="form-textarea-counter">{{ (form.remark || '').length }}/200</view>
- </view>
- <view class="field">
- <text class="field-label">状态</text>
- <view class="switch-row">
- <switch
- :checked="form.status === 1"
- color="#00c98d"
- @change="onStatusSwitch"
- />
- <text class="switch-row__label">{{ form.status === 1 ? '启用' : '禁用' }}</text>
- </view>
- </view>
- </view>
- </scroll-view>
- <view class="bx-sheet__footer" style="display: flex; gap: 10px">
- <button class="btn btn-secondary" style="flex: 1" @click="showForm = false">取消</button>
- <button
- class="btn btn-primary"
- style="flex: 1"
- :disabled="!!loadingMap.submit"
- @click="handleSubmit"
- >
- {{ loadingMap.submit ? '保存中...' : '保存' }}
- </button>
- </view>
- </view>
- </view>
- <!-- 上级项目选择 -->
- <view v-if="showParentPicker" class="bx-sheet-mask" @click="showParentPicker = false">
- <view class="bx-sheet" @click.stop>
- <view class="bx-sheet__title">选择上级项目</view>
- <scroll-view scroll-y class="bx-sheet__body">
- <view v-if="!parentOptions.length" class="bx-sheet__empty">暂无一级项目</view>
- <view
- v-for="p in parentOptions"
- :key="p.id"
- class="bx-sheet__item"
- :class="{ 'is-selected': String(form.parentId) === String(p.id) }"
- @click="onParentPick(p)"
- >
- {{ p.itemName }}
- </view>
- </scroll-view>
- </view>
- </view>
- </view>
- </template>
- <script setup>
- import { ref, reactive, computed, onMounted } from 'vue'
- import { expenseItemApi } from '@/api/bx'
- import { statusLabel, adminStatusTagClass } from '@/utils/bxFormat'
- import { PAGE_PATHS } from '@/config/api'
- import { getSafeAreaStyle, getGlobalSafeArea } from '@/utils/safeArea'
- const safeAreaStyle = getSafeAreaStyle()
- const expenseTree = ref([])
- const loading = ref(false)
- const refreshing = ref(false)
- const expandedIds = ref([])
- const listHeight = ref('70vh')
- const showForm = ref(false)
- const showParentPicker = ref(false)
- const isEdit = ref(false)
- const loadingMap = reactive({})
- const dialogMode = ref('add')
- const form = reactive({
- id: '',
- itemName: '',
- itemCode: '',
- level: 1,
- parentId: '0',
- sortOrder: '1',
- status: 1,
- remark: ''
- })
- const parentOptions = computed(() =>
- expenseTree.value.filter((r) => Number(r.level) === 1)
- )
- const parentDisplay = computed(() => {
- if (!form.parentId || form.parentId === '0') return ''
- return parentOptions.value.find((p) => String(p.id) === form.parentId)?.itemName ?? ''
- })
- const dialogTitle = computed(() => {
- if (dialogMode.value === 'add') return '新增一级项目'
- if (dialogMode.value === 'addChild') return '新增二级项目'
- return '编辑项目'
- })
- function measureListHeight() {
- try {
- const sys = uni.getSystemInfoSync()
- const topbar = getGlobalSafeArea().headerHeight
- listHeight.value = `${Math.max(320, (sys.windowHeight || 600) - topbar)}px`
- } catch (e) {
- listHeight.value = '70vh'
- }
- }
- function isExpanded(id) {
- return expandedIds.value.includes(String(id))
- }
- function toggleExpand(id) {
- const sid = String(id)
- const idx = expandedIds.value.indexOf(sid)
- if (idx === -1) expandedIds.value.push(sid)
- else expandedIds.value.splice(idx, 1)
- }
- function resetForm() {
- Object.assign(form, {
- id: '',
- itemName: '',
- itemCode: '',
- level: 1,
- parentId: '0',
- sortOrder: '1',
- status: 1,
- remark: ''
- })
- }
- function openAddDialog() {
- dialogMode.value = 'add'
- isEdit.value = false
- resetForm()
- form.level = 1
- form.parentId = '0'
- showForm.value = true
- }
- function openAddChildDialog(parent) {
- dialogMode.value = 'addChild'
- isEdit.value = false
- resetForm()
- form.level = 2
- form.parentId = String(parent.id)
- showForm.value = true
- }
- function openEditDialog(item) {
- dialogMode.value = 'edit'
- isEdit.value = true
- Object.assign(form, {
- id: String(item.id),
- itemName: item.itemName ?? '',
- itemCode: item.itemCode ?? '',
- level: item.level ?? 1,
- parentId: item.parentId == null || item.parentId === 0 ? '0' : String(item.parentId),
- sortOrder: String(item.sortOrder ?? 1),
- status: item.status ?? 1,
- remark: item.remark ?? ''
- })
- showForm.value = true
- }
- function onParentPick(p) {
- form.parentId = String(p.id)
- showParentPicker.value = false
- }
- function onStatusSwitch(e) {
- form.status = e.detail.value ? 1 : 0
- }
- async function loadTree() {
- loading.value = true
- try {
- const res = await expenseItemApi.tree()
- expenseTree.value = res.data ?? []
- } catch (e) {
- // toasted by bxRequest
- } finally {
- loading.value = false
- refreshing.value = false
- }
- }
- function onRefresh() {
- refreshing.value = true
- loadTree()
- }
- function validateForm() {
- if (!String(form.itemName ?? '').trim()) {
- uni.showToast({ title: '请输入项目名称', icon: 'none' })
- return false
- }
- if (form.level === 2 && (!form.parentId || form.parentId === '0')) {
- uni.showToast({ title: '请选择上级项目', icon: 'none' })
- return false
- }
- return true
- }
- async function handleSubmit() {
- if (!validateForm()) return
- loadingMap.submit = true
- try {
- const sortParsed = parseInt(String(form.sortOrder).trim(), 10)
- const sortOrder = Number.isFinite(sortParsed) ? sortParsed : 1
- const payload = {
- itemName: form.itemName,
- itemCode: form.itemCode,
- level: form.level,
- parentId: form.parentId === '0' ? '0' : form.parentId,
- sortOrder,
- status: form.status,
- remark: form.remark
- }
- if (isEdit.value) {
- await expenseItemApi.update(form.id, payload)
- uni.showToast({ title: '编辑成功', icon: 'success' })
- } else {
- await expenseItemApi.create(payload)
- uni.showToast({ title: '新增成功', icon: 'success' })
- }
- showForm.value = false
- await loadTree()
- } catch (e) {
- // toasted
- } finally {
- loadingMap.submit = false
- }
- }
- function handleDelete(item) {
- const key = `delete-${item.id}`
- uni.showModal({
- title: '删除确认',
- content: `确定要删除费用项目「${item.itemName || item.itemCode}」吗?`,
- confirmText: '确定删除',
- confirmColor: '#ef4444',
- success: async (r) => {
- if (!r.confirm) return
- loadingMap[key] = true
- try {
- await expenseItemApi.delete(item.id)
- uni.showToast({ title: '删除成功', icon: 'success' })
- await loadTree()
- } catch (e) {
- // toasted
- } finally {
- loadingMap[key] = false
- }
- }
- })
- }
- function onBack() {
- uni.navigateBack({ fail: () => uni.reLaunch({ url: PAGE_PATHS.workbench }) })
- }
- onMounted(() => {
- measureListHeight()
- loadTree()
- })
- </script>
- <style lang="scss">
- @import '@/styles/bx-theme.scss';
- </style>
|