feat(miniprogram): migrate expense items and workflow admin from bx-mobile.
Enable mobile config of expense tree and advance/reimburse approval nodes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
1bd0503409
commit
f13eba5623
7 files changed
+1209
-8
No files matched your search
+39
-2
@@ -1,4 +1,5 @@
|
||||
import { bxRequest, bxUploadFile, bxUploadInvoice } from '@/utils/bxRequest'
|
||||
import { request, parseResponseJson } from '@/utils/request'
|
||||
|
||||
export const advanceApi = {
|
||||
list: (params) => bxRequest({ url: '/advances/', method: 'GET', params }),
|
||||
@@ -29,7 +30,41 @@ export const reimburseApi = {
|
||||
}
|
||||
|
||||
export const expenseItemApi = {
|
||||
tree: () => bxRequest({ url: '/expense-items/tree', method: 'GET' })
|
||||
tree: () => bxRequest({ url: '/expense-items/tree', method: 'GET' }),
|
||||
create: (data) => bxRequest({ url: '/expense-items/', method: 'POST', data }),
|
||||
update: (id, data) => bxRequest({ url: `/expense-items/${id}`, method: 'PUT', data }),
|
||||
delete: (id) => bxRequest({ url: `/expense-items/${id}`, method: 'DELETE' })
|
||||
}
|
||||
|
||||
export const workflowApi = {
|
||||
getByBizType: (bizType) =>
|
||||
bxRequest({ url: `/workflows/biz/${bizType}`, method: 'GET' }),
|
||||
create: (data) => bxRequest({ url: '/workflows/', method: 'POST', data }),
|
||||
update: (id, data) => bxRequest({ url: `/workflows/${id}`, method: 'PUT', data }),
|
||||
saveNodes: (id, nodes) =>
|
||||
bxRequest({ url: `/workflows/${id}/nodes`, method: 'PUT', data: nodes }),
|
||||
delete: (id) => bxRequest({ url: `/workflows/${id}`, method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 门户用户下拉(code===200 信封,非 bx code===0)
|
||||
* 对齐 bx-mobile portalAdminApi.userOptions → GET /web/admin/users/options
|
||||
*/
|
||||
export async function fetchPortalUserOptions() {
|
||||
const res = await request({
|
||||
url: '/web/admin/users/options',
|
||||
method: 'GET'
|
||||
})
|
||||
const result = parseResponseJson(res)
|
||||
if (result && typeof result === 'object' && 'code' in result) {
|
||||
if (result.code === 200 || result.code === 0) {
|
||||
const data = result.data !== undefined ? result.data : result
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
const msg = result.message || result.msg || '加载用户失败'
|
||||
throw new Error(msg)
|
||||
}
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
export const invoiceWalletApi = {
|
||||
@@ -77,7 +112,9 @@ export default {
|
||||
advanceApi,
|
||||
reimburseApi,
|
||||
expenseItemApi,
|
||||
workflowApi,
|
||||
invoiceWalletApi,
|
||||
invoiceApi,
|
||||
attachmentApi
|
||||
attachmentApi,
|
||||
fetchPortalUserOptions
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<view class="bx-page">
|
||||
<view class="topbar">
|
||||
<view class="topbar-left">
|
||||
<view class="topbar-back" @click="onBack">
|
||||
<text class="topbar-back-icon">‹</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="topbar-title">{{ pageTitle }}</text>
|
||||
<view class="topbar-right" />
|
||||
</view>
|
||||
|
||||
<view class="fab-add" @click="openAddDialog">+</view>
|
||||
|
||||
<view class="page-padded">
|
||||
<view class="detail-grid" style="margin-top: 12px">
|
||||
<view class="detail-item">
|
||||
<view class="di-label">流程名称</view>
|
||||
<view class="di-value">{{ wfDef.wfName || '-' }}</view>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<view class="di-label">状态</view>
|
||||
<view class="di-value">
|
||||
<text class="status-tag" :class="adminStatusTagClass(wfDef.status)">
|
||||
{{ statusLabel(wfDef.status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="page-body-loading">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<template v-if="!loading">
|
||||
<view v-if="nodes.length" class="section-title">审批节点</view>
|
||||
|
||||
<view v-if="nodes.length" class="flow-card">
|
||||
<view
|
||||
v-for="(node, index) in nodes"
|
||||
:key="node.id ?? index"
|
||||
class="flow-step"
|
||||
>
|
||||
<view class="flow-avatar av-done">{{ index + 1 }}</view>
|
||||
<view class="flow-info">
|
||||
<view class="flow-info__head">
|
||||
<text class="flow-name">{{ node.nodeName }}</text>
|
||||
<text
|
||||
class="status-tag"
|
||||
:class="
|
||||
node.approverId || node.approver_id ? 's-approved' : 's-pending'
|
||||
"
|
||||
>
|
||||
{{ node.approverId || node.approver_id ? '已配置' : '未配置' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="flow-role"
|
||||
>审批人:{{ node.approverName ?? node.approver_name ?? '-' }}</view
|
||||
>
|
||||
<view class="flow-time">节点顺序:{{ node.nodeOrder }}</view>
|
||||
<view class="op-actions" style="margin-top: 8px">
|
||||
<button class="btn btn-sm btn-primary" @click="openEditDialog(node)">
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
:disabled="!!loadingMap[`delete-${nodeKey(node)}`]"
|
||||
@click="handleDelete(node)"
|
||||
>
|
||||
{{ loadingMap[`delete-${nodeKey(node)}`] ? '删除中...' : '删除' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
:disabled="index === 0 || !!loadingMap[`up-${nodeKey(node)}`]"
|
||||
@click="moveUp(node)"
|
||||
>
|
||||
{{ loadingMap[`up-${nodeKey(node)}`] ? '...' : '上移' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
:disabled="
|
||||
index === nodes.length - 1 || !!loadingMap[`down-${nodeKey(node)}`]
|
||||
"
|
||||
@click="moveDown(node)"
|
||||
>
|
||||
{{ loadingMap[`down-${nodeKey(node)}`] ? '...' : '下移' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!nodes.length" class="empty-state empty-state--fill">
|
||||
<text>暂无流程节点,点击右下角新增</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 节点表单 -->
|
||||
<view v-if="showForm" class="bx-sheet-mask" @click="showForm = false">
|
||||
<view class="bx-sheet" @click.stop>
|
||||
<view class="bx-sheet__title">{{ isEdit ? '编辑节点' : '新增节点' }}</view>
|
||||
<view 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="formData.nodeName"
|
||||
class="form-input"
|
||||
placeholder="请输入节点名称"
|
||||
/>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="field-label">审批人</text>
|
||||
<view class="picker-trigger" @click="openApproverPicker">
|
||||
<text :class="approverDisplay ? 'value' : 'ph'">{{
|
||||
approverDisplay || '请选择审批人'
|
||||
}}</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
<view v-if="!userOptions.length && approverFallback" class="field-tip">
|
||||
用户列表不可用时,可手动填写审批人 ID / 姓名
|
||||
</view>
|
||||
<view v-if="!userOptions.length && approverFallback" class="field" style="margin-top: 10px">
|
||||
<input
|
||||
v-model="formData.approverId"
|
||||
class="form-input"
|
||||
placeholder="审批人 ID(可选)"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="!userOptions.length && approverFallback" class="field">
|
||||
<input
|
||||
v-model="formData.approverName"
|
||||
class="form-input"
|
||||
placeholder="审批人姓名(可选)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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="showApproverPicker" class="bx-sheet-mask" @click="closeApproverPicker">
|
||||
<view class="bx-sheet bx-sheet--tall" @click.stop>
|
||||
<view class="bx-sheet__title">选择审批人</view>
|
||||
<view class="search-wrap" style="padding: 8px 16px 0">
|
||||
<view class="search-box">
|
||||
<text class="search-box__icon">⌕</text>
|
||||
<input
|
||||
v-model="approverKeyword"
|
||||
class="search-input"
|
||||
placeholder="搜索姓名或账号"
|
||||
confirm-type="search"
|
||||
/>
|
||||
<view
|
||||
v-if="approverKeyword.trim()"
|
||||
class="search-box__clear"
|
||||
@click="approverKeyword = ''"
|
||||
>×</view
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view scroll-y class="bx-sheet__body">
|
||||
<view v-if="!filteredUsers.length" class="bx-sheet__empty">无匹配人员</view>
|
||||
<view
|
||||
v-for="u in filteredUsers"
|
||||
:key="u.id"
|
||||
class="bx-sheet__item"
|
||||
:class="{ 'is-selected': String(formData.approverId) === u.id }"
|
||||
@click="onApproverPick(u)"
|
||||
>
|
||||
{{ u.displayName }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { workflowApi, fetchPortalUserOptions } from '@/api/bx'
|
||||
import { statusLabel, adminStatusTagClass } from '@/utils/bxFormat'
|
||||
import { PAGE_PATHS } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
bizType: { type: String, required: true },
|
||||
pageTitle: { type: String, required: true }
|
||||
})
|
||||
|
||||
const wfDef = ref({})
|
||||
const nodes = computed(() => wfDef.value.nodes || [])
|
||||
const loading = ref(false)
|
||||
|
||||
const showForm = ref(false)
|
||||
const showApproverPicker = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const loadingMap = reactive({})
|
||||
const approverKeyword = ref('')
|
||||
const approverFallback = ref(false)
|
||||
|
||||
const userOptions = ref([])
|
||||
const filteredUsers = computed(() => {
|
||||
const kw = approverKeyword.value.trim().toLowerCase()
|
||||
if (!kw) return userOptions.value
|
||||
return userOptions.value.filter((u) => {
|
||||
const name = (u.realName || '').toLowerCase()
|
||||
const account = (u.account || '').toLowerCase()
|
||||
const display = (u.displayName || '').toLowerCase()
|
||||
return name.includes(kw) || account.includes(kw) || display.includes(kw)
|
||||
})
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
editingNodeId: null,
|
||||
nodeName: '',
|
||||
approverId: null,
|
||||
approverName: ''
|
||||
})
|
||||
|
||||
const approverDisplay = computed(() => {
|
||||
if (formData.approverId == null || formData.approverId === '') return ''
|
||||
return (
|
||||
userOptions.value.find((u) => u.id === String(formData.approverId))?.displayName ??
|
||||
formData.approverName
|
||||
)
|
||||
})
|
||||
|
||||
function nodeKey(node) {
|
||||
return String(node.id ?? node.nodeOrder ?? node.nodeName ?? Date.now())
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const data = await fetchPortalUserOptions()
|
||||
userOptions.value = (Array.isArray(data) ? data : []).map((u) => {
|
||||
const name = u.label || u.nickName || u.realName || ''
|
||||
const account = u.userNo || u.username || ''
|
||||
return {
|
||||
id: String(u.value ?? u.id ?? ''),
|
||||
realName: name,
|
||||
account,
|
||||
displayName: account ? `${name}(${account})` : name
|
||||
}
|
||||
}).filter((u) => u.id)
|
||||
approverFallback.value = userOptions.value.length === 0
|
||||
} catch (e) {
|
||||
userOptions.value = []
|
||||
approverFallback.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWfDef() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await workflowApi.getByBizType(props.bizType)
|
||||
wfDef.value = res.data || res || {}
|
||||
} catch (e) {
|
||||
wfDef.value = {}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildNodePayload(list) {
|
||||
return list.map((n, i) => ({
|
||||
nodeName: n.nodeName,
|
||||
nodeOrder: i + 1,
|
||||
approverId: n.approverId ?? n.approver_id ?? null,
|
||||
approverName: n.approverName ?? n.approver_name ?? ''
|
||||
}))
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
if (!wfDef.value?.id) {
|
||||
uni.showToast({ title: '工作流未初始化,无法保存节点', icon: 'none' })
|
||||
return
|
||||
}
|
||||
isEdit.value = false
|
||||
Object.assign(formData, {
|
||||
editingNodeId: null,
|
||||
nodeName: '',
|
||||
approverId: null,
|
||||
approverName: ''
|
||||
})
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(node) {
|
||||
isEdit.value = true
|
||||
const aid = node.approverId ?? node.approver_id ?? null
|
||||
Object.assign(formData, {
|
||||
editingNodeId: node.id != null ? String(node.id) : null,
|
||||
nodeName: node.nodeName ?? '',
|
||||
approverId: aid != null && aid !== '' ? String(aid) : null,
|
||||
approverName: node.approverName ?? node.approver_name ?? ''
|
||||
})
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function openApproverPicker() {
|
||||
if (!userOptions.value.length) {
|
||||
if (approverFallback.value) {
|
||||
uni.showToast({ title: '请手动填写审批人', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.showToast({ title: '暂无人员', icon: 'none' })
|
||||
return
|
||||
}
|
||||
approverKeyword.value = ''
|
||||
showApproverPicker.value = true
|
||||
}
|
||||
|
||||
function closeApproverPicker() {
|
||||
showApproverPicker.value = false
|
||||
approverKeyword.value = ''
|
||||
}
|
||||
|
||||
function onApproverPick(u) {
|
||||
formData.approverId = u.id
|
||||
formData.approverName = (u.displayName || '').split('(')[0]
|
||||
closeApproverPicker()
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!String(formData.nodeName ?? '').trim()) {
|
||||
uni.showToast({ title: '请输入节点名称', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validateForm()) return
|
||||
if (!wfDef.value?.id) {
|
||||
uni.showToast({ title: '工作流未初始化', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loadingMap.submit = true
|
||||
try {
|
||||
if (isEdit.value && formData.editingNodeId != null) {
|
||||
const newNodes = nodes.value.map((n) =>
|
||||
String(n.id) === formData.editingNodeId
|
||||
? {
|
||||
...n,
|
||||
nodeName: formData.nodeName,
|
||||
approverId: formData.approverId,
|
||||
approverName: formData.approverName
|
||||
}
|
||||
: n
|
||||
)
|
||||
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
|
||||
uni.showToast({ title: '编辑成功', icon: 'success' })
|
||||
} else {
|
||||
const newNodes = [
|
||||
...nodes.value,
|
||||
{
|
||||
nodeName: formData.nodeName,
|
||||
approverId: formData.approverId,
|
||||
approverName: formData.approverName
|
||||
}
|
||||
]
|
||||
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
|
||||
uni.showToast({ title: '新增成功', icon: 'success' })
|
||||
}
|
||||
showForm.value = false
|
||||
await loadWfDef()
|
||||
} catch (e) {
|
||||
// toasted
|
||||
} finally {
|
||||
loadingMap.submit = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(node) {
|
||||
const key = `delete-${nodeKey(node)}`
|
||||
uni.showModal({
|
||||
title: '删除确认',
|
||||
content: `确定要删除节点「${node.nodeName}」吗?`,
|
||||
confirmText: '确定删除',
|
||||
confirmColor: '#ef4444',
|
||||
success: async (r) => {
|
||||
if (!r.confirm) return
|
||||
loadingMap[key] = true
|
||||
try {
|
||||
const newNodes = nodes.value.filter((n) => n.id !== node.id)
|
||||
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
await loadWfDef()
|
||||
} catch (e) {
|
||||
// toasted
|
||||
} finally {
|
||||
loadingMap[key] = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function moveUp(node) {
|
||||
const idx = nodes.value.findIndex((n) => n.id === node.id)
|
||||
if (idx <= 0) return
|
||||
const arr = [...nodes.value]
|
||||
const temp = arr[idx]
|
||||
arr[idx] = arr[idx - 1]
|
||||
arr[idx - 1] = temp
|
||||
const key = `up-${nodeKey(node)}`
|
||||
try {
|
||||
loadingMap[key] = true
|
||||
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(arr))
|
||||
uni.showToast({ title: '上移成功', icon: 'success' })
|
||||
await loadWfDef()
|
||||
} catch (e) {
|
||||
// toasted
|
||||
} finally {
|
||||
loadingMap[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function moveDown(node) {
|
||||
const arr = [...nodes.value]
|
||||
const idx = arr.findIndex((n) => n.id === node.id)
|
||||
if (idx < 0 || idx >= arr.length - 1) return
|
||||
const temp = arr[idx]
|
||||
arr[idx] = arr[idx + 1]
|
||||
arr[idx + 1] = temp
|
||||
const key = `down-${nodeKey(node)}`
|
||||
try {
|
||||
loadingMap[key] = true
|
||||
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(arr))
|
||||
uni.showToast({ title: '下移成功', icon: 'success' })
|
||||
await loadWfDef()
|
||||
} catch (e) {
|
||||
// toasted
|
||||
} finally {
|
||||
loadingMap[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
uni.navigateBack({ fail: () => uni.switchTab({ url: PAGE_PATHS.workbench }) })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWfDef()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/styles/bx-theme.scss';
|
||||
</style>
|
||||
@@ -1,7 +1,431 @@
|
||||
<template>
|
||||
<bx-placeholder-page default-title="费用项目" />
|
||||
<view class="bx-page">
|
||||
<view class="topbar">
|
||||
<view class="topbar-left">
|
||||
<view class="topbar-back" @click="onBack">
|
||||
<text class="topbar-back-icon">‹</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="topbar-title">费用项目管理</text>
|
||||
<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 BxPlaceholderPage from '@/components/bx-ui/bx-placeholder-page.vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { expenseItemApi } from '@/api/bx'
|
||||
import { statusLabel, adminStatusTagClass } from '@/utils/bxFormat'
|
||||
import { PAGE_PATHS } from '@/config/api'
|
||||
|
||||
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 = 44 + (sys.statusBarHeight || 0)
|
||||
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.switchTab({ url: PAGE_PATHS.workbench }) })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
measureListHeight()
|
||||
loadTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/styles/bx-theme.scss';
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<bx-placeholder-page default-title="工作流(备用金)" />
|
||||
<bx-wf-config-page biz-type="advance" page-title="工作流配置(备用金)" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BxPlaceholderPage from '@/components/bx-ui/bx-placeholder-page.vue'
|
||||
import BxWfConfigPage from '@/components/bx-ui/bx-wf-config-page.vue'
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<bx-placeholder-page default-title="工作流(报销)" />
|
||||
<bx-wf-config-page biz-type="reimburse" page-title="工作流配置(报销单)" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BxPlaceholderPage from '@/components/bx-ui/bx-placeholder-page.vue'
|
||||
import BxWfConfigPage from '@/components/bx-ui/bx-wf-config-page.vue'
|
||||
</script>
|
||||
@@ -1513,3 +1513,257 @@
|
||||
.bx-page .bx-upload-sheet .form-input-prefix .form-input {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
/* ---- Batch6: expense tree + workflow config ---- */
|
||||
.bx-page .section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 14px 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.bx-page .section-title::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bx-page .card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.bx-page .tree-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bx-page .tree-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(135deg, var(--primary-bg), #fff);
|
||||
}
|
||||
|
||||
.bx-page .tree-head__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bx-page .tree-head__title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bx-page .tree-head__tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bx-page .tree-twisty {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--primary-dark);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.bx-page .tree-twisty.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.bx-page .tree-body {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.bx-page .tree-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.bx-page .tree-meta-cell .k {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bx-page .tree-meta-cell .v {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bx-page .tree-child-card {
|
||||
margin: 0 14px 10px;
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.bx-page .op-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.bx-page .op-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bx-page .op-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bx-page .op-card-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bx-page .op-card-body {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
.bx-page .tree-body > .op-card-body {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.bx-page .tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.bx-page .tag-neutral {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-color: rgba(100, 116, 139, 0.15);
|
||||
}
|
||||
|
||||
.bx-page .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bx-page .detail-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--rs);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.bx-page .detail-item .di-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.bx-page .detail-item .di-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bx-page .flow-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
padding: 16px;
|
||||
margin-bottom: 72px;
|
||||
}
|
||||
|
||||
.bx-page .flow-info__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bx-page .form-textarea-counter {
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bx-page .switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bx-page .switch-row__label {
|
||||
font-size: 13px;
|
||||
color: var(--text-sec);
|
||||
}
|
||||
|
||||
.bx-page .field-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--warning);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet--tall {
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet__body--form {
|
||||
padding: 8px 16px 4px;
|
||||
max-height: 60vh;
|
||||
}
|
||||
@@ -38,6 +38,26 @@ export function statusTone(status) {
|
||||
return map[status] ?? 'info'
|
||||
}
|
||||
|
||||
/** StatusTag variant(对齐 bx-mobile statusTagClass) */
|
||||
export function statusTagClass(status) {
|
||||
const tone = statusTone(status)
|
||||
const map = {
|
||||
info: 'info',
|
||||
warn: 'warning',
|
||||
done: 'success',
|
||||
error: 'danger',
|
||||
processing: 'processing'
|
||||
}
|
||||
return map[tone] || 'info'
|
||||
}
|
||||
|
||||
/** 小程序 status-tag class:启用/禁用或工作流状态 */
|
||||
export function adminStatusTagClass(status) {
|
||||
if (status === 1 || status === '1') return 's-approved'
|
||||
if (status === 0 || status === '0') return 's-draft'
|
||||
return workflowListStatusTagClass(status)
|
||||
}
|
||||
|
||||
export function workflowListStatusTagClass(status) {
|
||||
const raw = status == null || status === '' ? 'draft' : String(status)
|
||||
return `s-${raw}`
|
||||
|
||||
Reference in new issue
Block a user