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