433 lines
14 KiB
Vue
433 lines
14 KiB
Vue
<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>
|