| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- <template>
- <div class="page">
- <div class="topbar">
- <div class="topbar-left">
- <div class="topbar-back" @click="router.back()">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
- </div>
- </div>
- <span class="topbar-title">费用项目管理</span>
- <div class="topbar-right"></div>
- </div>
- <button type="button" class="fab-add" aria-label="新增费用项目" @click="openAddDialog()">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
- </button>
- <div class="page-padded">
- <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
- <div v-if="loading && expenseTree.length === 0" class="page-body-loading">
- <van-loading size="24px" vertical>加载中...</van-loading>
- </div>
- <template v-if="expenseTree.length > 0">
- <div class="section-title">费用项目</div>
- <div class="card-list">
- <div v-for="item in expenseTree" :key="item.id" class="tree-card">
- <!-- Level 1 Header -->
- <div class="tree-head" @click="toggleExpand(item.id)">
- <div style="display: flex; align-items: center; gap: 8px;">
- <span :class="['tree-node', '', expandedIds.includes(String(item.id)) ? 'expanded' : '']" style="padding: 0;">
- <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;">
- <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>
- </span>
- </span>
- <span class="title">{{ item.itemName }}</span>
- </div>
- <div style="display: flex; gap: 4px; align-items: center;">
- <span class="tag tag-neutral">一级</span>
- <StatusTag :text="statusLabel(item.status)" :variant="item.status === 1 ? 'success' : 'muted'" />
- </div>
- </div>
- <!-- Level 1 Details -->
- <div v-show="expandedIds.includes(String(item.id))">
- <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 8px 14px; background: var(--bg);">
- <div>
- <div style="font-size: 11px; color: var(--text-muted);">项目编码</div>
- <div style="font-size: 13px; font-weight: 500; color: var(--text); margin-top: 2px;">{{ item.itemCode || '-' }}</div>
- </div>
- <div>
- <div style="font-size: 11px; color: var(--text-muted);">排序号</div>
- <div style="font-size: 13px; font-weight: 500; color: var(--text); margin-top: 2px;">{{ item.sortOrder }}</div>
- </div>
- <div>
- <div style="font-size: 11px; color: var(--text-muted);">子项数量</div>
- <div style="font-size: 13px; font-weight: 500; color: var(--text); margin-top: 2px;">{{ item.childCount ?? (item.children?.length ?? 0) }}</div>
- </div>
- </div>
- <div class="op-card-body" style="padding: 10px 14px 10px;">
- <div class="op-actions">
- <button class="btn btn-sm btn-primary" @click="openAddChildDialog(item)">新增子项</button>
- <button class="btn btn-sm btn-primary" @click="openEditDialog(item)">编辑</button>
- <button class="btn btn-sm btn-danger" :disabled="loadingMap[`delete-${item.id}`]" @click="handleDelete(item)">
- {{ loadingMap[`delete-${item.id}`] ? '删除中...' : '删除' }}
- </button>
- </div>
- </div>
- <!-- Children (Level 2) -->
- <template v-if="item.children && item.children.length > 0">
- <div
- v-for="child in item.children"
- :key="child.id"
- class="op-card"
- style="margin: 0 14px 10px; border-left: 3px solid var(--primary);"
- >
- <div class="op-card-head">
- <div class="op-card-title" style="font-size: 14px;">{{ item.itemName }}/{{ child.itemName }}</div>
- <div style="display: flex; gap: 4px;">
- <span class="tag tag-neutral">二级</span>
- <StatusTag :text="statusLabel(child.status)" :variant="child.status === 1 ? 'success' : 'muted'" />
- </div>
- </div>
- <div class="op-card-sub">
- <span>编码:{{ child.itemCode || '-' }}</span>
- <span>排序:{{ child.sortOrder }}</span>
- </div>
- <div class="op-card-body">
- <div class="op-actions">
- <button class="btn btn-sm btn-primary" @click="openEditDialog(child)">编辑</button>
- <button class="btn btn-sm btn-danger" :disabled="loadingMap[`delete-${child.id}`]" @click="handleDelete(child)">
- {{ loadingMap[`delete-${child.id}`] ? '删除中...' : '删除' }}
- </button>
- </div>
- </div>
- </div>
- </template>
- </div>
- </div>
- </div>
- </template>
- <div v-if="!loading && expenseTree.length === 0" class="empty-state empty-state--fill">
- <van-icon name="gold-coin-o" :size="56" color="var(--text-muted)" />
- <p>暂无费用项目数据</p>
- </div>
- </van-pull-refresh>
- </div><!-- /page-padded -->
- <!-- Add/Edit Popup -->
- <van-popup
- v-model:show="showForm"
- position="bottom"
- round
- :style="{ maxHeight: '85vh' }"
- >
- <div class="popup-sheet-title-wrap">
- <div class="section-title">{{ dialogTitle }}</div>
- </div>
- <div class="form-card" style="margin: 0 16px;">
- <div class="field">
- <label class="field-label">项目名称<span class="req">*</span></label>
- <input v-model="form.itemName" class="form-input" placeholder="请输入项目名称" />
- </div>
- <div class="field">
- <label class="field-label">项目编码</label>
- <input v-model="form.itemCode" class="form-input" placeholder="请输入项目编码" />
- </div>
- <div v-if="form.level === 2" class="field">
- <label class="field-label">上级项目</label>
- <div class="form-input-icon">
- <input
- :value="parentDisplay"
- class="form-input"
- readonly
- placeholder="请选择上级项目"
- @click="showParentPicker = true"
- />
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
- </div>
- </div>
- <div class="field">
- <label class="field-label">排序号</label>
- <input v-model="form.sortOrder" type="number" class="form-input" placeholder="请输入排序号" />
- </div>
- <div class="field">
- <label class="field-label">备注</label>
- <textarea v-model="form.remark" class="form-textarea" rows="2" placeholder="请输入备注(可选)" maxlength="200"></textarea>
- <div class="form-textarea-counter">{{ form.remark.length }}/200</div>
- </div>
- <div class="field">
- <label class="field-label">状态</label>
- <div style="display: flex; align-items: center; gap: 10px;">
- <van-switch v-model="statusSwitch" size="20px" />
- <span style="font-size: 13px; color: var(--text-sec);">{{ form.status === 1 ? '启用' : '禁用' }}</span>
- </div>
- </div>
- </div>
- <div class="bottom-action-bar" style="position: static; box-shadow: none; padding: 12px 16px; padding-bottom: calc(12px + env(safe-area-inset-bottom));">
- <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>
- </div>
- </van-popup>
- <!-- Parent Picker -->
- <van-popup v-model:show="showParentPicker" position="bottom" round>
- <van-picker
- :columns="parentColumns"
- @confirm="onParentConfirm"
- @cancel="showParentPicker = false"
- />
- </van-popup>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, reactive, computed, onMounted } from 'vue'
- import { useRouter } from 'vue-router'
- import { showToast, showDialog } from 'vant'
- import { expenseItemApi } from '@/api'
- import { DELETE_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
- import { statusLabel } from '@/utils/format'
- import StatusTag from '@/components/mobile/StatusTag.vue'
- const router = useRouter()
- const expenseTree = ref<any[]>([])
- const loading = ref(false)
- const refreshing = ref(false)
- const expandedIds = ref<string[]>([])
- const showForm = ref(false)
- const showParentPicker = ref(false)
- const isEdit = ref(false)
- const loadingMap = reactive<Record<string, boolean>>({})
- const dialogMode = ref<'add' | 'addChild' | 'edit'>('add')
- const form = reactive({
- id: '' as string,
- itemName: '',
- itemCode: '',
- level: 1,
- parentId: '0' as string,
- sortOrder: '1',
- status: 1,
- remark: '',
- })
- const statusSwitch = computed({
- get: () => form.status === 1 || form.status === ('1' as any),
- set: (v: boolean) => { form.status = v ? 1 : 0 },
- })
- const parentOptions = computed(() => expenseTree.value.filter((r: any) => Number(r.level) === 1))
- const parentColumns = computed(() =>
- parentOptions.value.map((p: any) => ({ text: p.itemName, value: String(p.id) })),
- )
- const parentDisplay = computed(() => {
- if (!form.parentId || form.parentId === '0') return ''
- return parentOptions.value.find((p: any) => String(p.id) === form.parentId)?.itemName ?? ''
- })
- const dialogTitle = computed(() => {
- if (dialogMode.value === 'add') return '新增一级项目'
- if (dialogMode.value === 'addChild') return '新增二级项目'
- return '编辑项目'
- })
- function toggleExpand(id: string | number) {
- 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: any) {
- dialogMode.value = 'addChild'
- isEdit.value = false
- resetForm()
- form.level = 2
- form.parentId = String(parent.id)
- showForm.value = true
- }
- function openEditDialog(item: any) {
- 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 onParentConfirm({ selectedOptions }: any) {
- if (selectedOptions.length > 0) {
- form.parentId = String(selectedOptions[0].value)
- }
- showParentPicker.value = false
- }
- async function loadTree() {
- loading.value = true
- try {
- const res: any = await expenseItemApi.tree()
- expenseTree.value = res.data ?? []
- expandedIds.value = expenseTree.value.map((m: any) => String(m.id))
- } catch (e: any) {
- showToast(e?.message || '加载数据失败')
- } finally {
- loading.value = false
- refreshing.value = false
- }
- }
- function onRefresh() {
- loadTree()
- }
- function validateExpenseItemForm(): boolean {
- if (!String(form.itemName ?? '').trim()) {
- showToast('请输入项目名称')
- return false
- }
- if (form.level === 2 && (!form.parentId || form.parentId === '0')) {
- showToast('请选择上级项目')
- return false
- }
- return true
- }
- async function handleSubmit() {
- if (!validateExpenseItemForm()) 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)
- showToast('编辑成功')
- } else {
- await expenseItemApi.create(payload)
- showToast('新增成功')
- }
- showForm.value = false
- await loadTree()
- } catch (e: any) {
- showToast(e?.message || '操作失败')
- } finally {
- loadingMap.submit = false
- }
- }
- async function handleDelete(item: any) {
- const key = `delete-${item.id}`
- try {
- await showDialog({
- title: '删除确认',
- message: `确定要删除费用项目「${item.itemName || item.itemCode}」吗?`,
- showCancelButton: true,
- confirmButtonText: '确定删除',
- confirmButtonColor: DELETE_CONFIRM_BUTTON_COLOR,
- cancelButtonText: '取消',
- })
- loadingMap[key] = true
- await expenseItemApi.delete(item.id)
- showToast('删除成功')
- await loadTree()
- } catch (e: any) {
- if (e !== 'cancel') {
- showToast(e?.message || '删除失败')
- }
- } finally {
- loadingMap[key] = false
- }
- }
- onMounted(loadTree)
- </script>
|