Sfoglia il codice sorgente

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>
wuzilong 1 mese fa
parent
commit
f13eba5623

+ 39 - 2
miniprogram/api/bx.js

@@ -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
 }

+ 466 - 0
miniprogram/components/bx-ui/bx-wf-config-page.vue

@@ -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>

+ 426 - 2
miniprogram/pages/bx/admin/expense-item.vue

@@ -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>

+ 2 - 2
miniprogram/pages/bx/admin/wf-advance.vue

@@ -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>

+ 2 - 2
miniprogram/pages/bx/admin/wf-reimburse.vue

@@ -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>

+ 254 - 0
miniprogram/styles/bx-theme.scss

@@ -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;
+}

+ 20 - 0
miniprogram/utils/bxFormat.js

@@ -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}`