Files
shanxia2.0/miniprogram/pages/bx/admin/expense-item.vue
T

433 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>