huminmin
2026-06-01 a563ea879ef5fb6897e76d2df661e465dce2ab9b
src/views/collaborativeApproval/approvalManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,881 @@
<template>
  <div class="app-container">
    <!-- é¡µé¢æ ‡é¢˜ -->
    <div class="page-header">
      <div class="header-title">
        <el-icon class="title-icon"><Setting /></el-icon>
        <span>审批流程配置</span>
      </div>
      <div class="header-desc">为不同审批类型配置审批流程和审批人</div>
    </div>
    <!-- å®¡æ‰¹ç±»åž‹åˆ‡æ¢ - ç´§å‡‘标签式 -->
    <div class="type-tabs">
      <div
        v-for="type in approveTypes"
        :key="type.value"
        class="type-tab"
        :class="{ active: activeTab === type.value }"
        @click="activeTab = type.value; handleTabChange()"
      >
        <el-icon :size="14" :style="{ color: activeTab === type.value ? type.color : '#909399' }">
          <component :is="type.icon" />
        </el-icon>
        <span class="tab-name">{{ type.label }}</span>
      </div>
    </div>
    <!-- ä¸»è¦å†…容区域 -->
    <el-card class="config-card" shadow="hover" v-loading="loading">
      <template #header>
        <div class="card-header">
          <div class="header-left">
            <div class="type-icon" :style="{ backgroundColor: getTypeColor(currentApproveType) }">
              <el-icon :size="20" color="#fff"><component :is="getTypeIcon(currentApproveType)" /></el-icon>
            </div>
            <div class="header-info">
              <span class="type-name">{{ currentApproveTypeName }}</span>
              <el-tag :type="approverList.length > 0 ? 'success' : 'warning'" size="small" effect="light">
                {{ approverList.length > 0 ? `已配置 ${approverList.length} ä¸ªå®¡æ‰¹äºº` : '未配置审批人' }}
              </el-tag>
            </div>
          </div>
          <div class="header-actions" v-if="approverList.length > 0">
            <el-button @click="handleReset" size="default">
              <el-icon><RefreshLeft /></el-icon>
              é‡ç½®
            </el-button>
            <el-button type="primary" @click="handleSave" :loading="saveLoading" size="default">
              <el-icon><Check /></el-icon>
              ä¿å­˜é…ç½®
            </el-button>
          </div>
        </div>
      </template>
      <!-- å®¡æ‰¹æµç¨‹å±•示 -->
      <div class="flow-wrapper" v-if="approverList.length > 0">
        <div class="flow-container">
          <div
            v-for="(item, index) in approverList"
            :key="index"
            class="flow-item"
          >
            <!-- å®¡æ‰¹èŠ‚ç‚¹å¡ç‰‡ -->
            <div class="node-card" :class="{ 'empty': !item.approverId }">
              <!-- é¡¶éƒ¨åºå·å’Œçº§åˆ« -->
              <div class="node-badge">{{ index + 1 }}</div>
              <!-- å¤´åƒåŒºåŸŸ -->
              <div class="node-avatar-section">
                <div
                  class="node-avatar"
                  :class="{ 'has-user': item.approverId }"
                  :style="item.approverId ? { backgroundColor: getAvatarColor(item.approverName) } : {}"
                >
                  <span v-if="item.approverId">{{ item.approverName.charAt(0) }}</span>
                  <el-icon v-else :size="24"><User /></el-icon>
                </div>
                <div class="node-level">{{ getLevelText(index) }}</div>
              </div>
              <!-- é€‰æ‹©åŒºåŸŸ -->
              <div class="node-select-section">
                <el-select
                  v-model="item.approverId"
                  placeholder="选择审批人"
                  filterable
                  size="default"
                  @change="(val) => handleApproverChange(val, item)"
                >
                  <el-option
                    v-for="user in userList"
                    :key="user.userId"
                    :label="user.nickName"
                    :value="user.userId"
                  />
                </el-select>
              </div>
              <!-- æ“ä½œæŒ‰é’® -->
              <div class="node-actions">
                <el-button
                  type="primary"
                  circle
                  :disabled="index === 0"
                  @click="moveLeft(index)"
                  size="small"
                  class="action-btn"
                  title="前移"
                >
                  <el-icon><ArrowLeft /></el-icon>
                </el-button>
                <el-button
                  type="primary"
                  circle
                  :disabled="index === approverList.length - 1"
                  @click="moveRight(index)"
                  size="small"
                  class="action-btn"
                  title="后移"
                >
                  <el-icon><ArrowRight /></el-icon>
                </el-button>
                <el-button
                  type="danger"
                  circle
                  @click="handleDelete(index)"
                  size="small"
                  class="action-btn"
                >
                  <el-icon><Delete /></el-icon>
                </el-button>
              </div>
            </div>
            <!-- è¿žæŽ¥ç®­å¤´ -->
            <div class="arrow-connector" v-if="index < approverList.length - 1">
              <div class="arrow-line"></div>
              <el-icon class="arrow-icon"><ArrowRight /></el-icon>
            </div>
          </div>
          <!-- æ–°å¢žèŠ‚ç‚¹æŒ‰é’® - æ”¾åœ¨æµç¨‹æœ€åŽ -->
          <div class="add-node-item">
            <div class="arrow-connector" v-if="approverList.length > 0">
              <div class="arrow-line"></div>
              <el-icon class="arrow-icon"><ArrowRight /></el-icon>
            </div>
            <div class="add-node-card" @click="handleAdd">
              <div class="add-icon-wrapper">
                <el-icon :size="28"><Plus /></el-icon>
              </div>
              <span class="add-text">新增审批人</span>
            </div>
          </div>
        </div>
      </div>
      <!-- ç©ºçŠ¶æ€ -->
      <div class="empty-state" v-else>
        <div class="empty-content">
          <div class="empty-icon-wrapper">
            <el-icon :size="48" color="#c0c4cc"><User /></el-icon>
          </div>
          <div class="empty-text">暂无审批人配置</div>
          <div class="empty-subtext">点击下方按钮添加第一个审批人</div>
          <el-button type="primary" size="large" @click="handleAdd" class="empty-add-btn">
            <el-icon><Plus /></el-icon>
            æ–°å¢žå®¡æ‰¹äºº
          </el-button>
        </div>
      </div>
    </el-card>
    <!-- åº•部提示 -->
    <div class="bottom-tips">
      <el-icon><InfoFilled /></el-icon>
      <span>提示:每个流程至少配置一个审批人,审批按顺序流转,可通过箭头调整顺序</span>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
  Plus, ArrowLeft, Delete, Check, RefreshLeft, Setting,
  Suitcase, Calendar, Location, Money, ShoppingCart, DocumentChecked,
  Van, ArrowRight, User, InfoFilled
} from '@element-plus/icons-vue';
import { getApproveProcessConfigNodeList, addApproveProcessConfigNode } from '@/api/collaborativeApproval/approvalManagement';
import { userListNoPage } from '@/api/system/user';
// å½“前选中的标签页
const activeTab = ref('1');
// å®¡æ‰¹ç±»åž‹é…ç½®æ•°ç»„
const approveTypes = [
  { value: '1', label: '公出管理', icon: 'Suitcase', color: '#409EFF' },
  { value: '2', label: '请假管理', icon: 'Calendar', color: '#67C23A' },
  { value: '3', label: '出差管理', icon: 'Location', color: '#E6A23C' },
  { value: '4', label: '报销管理', icon: 'Money', color: '#F56C6C' },
  { value: '5', label: '采购审批', icon: 'ShoppingCart', color: '#909399' },
  { value: '6', label: '报价审批', icon: 'DocumentChecked', color: '#9B59B6' },
  { value: '7', label: '发货审批', icon: 'Van', color: '#1ABC9C' },
];
// å®¡æ‰¹ç±»åž‹åç§°æ˜ å°„
const approveTypeNameMap = {
  1: '公出管理',
  2: '请假管理',
  3: '出差管理',
  4: '报销管理',
  5: '采购审批',
  6: '报价审批',
  7: '发货审批',
};
// å®¡æ‰¹ç±»åž‹å›¾æ ‡æ˜ å°„
const typeIconMap = {
  1: 'Suitcase',
  2: 'Calendar',
  3: 'Location',
  4: 'Money',
  5: 'ShoppingCart',
  6: 'DocumentChecked',
  7: 'Van',
};
// å®¡æ‰¹ç±»åž‹é¢œè‰²æ˜ å°„
const typeColorMap = {
  1: '#409EFF',
  2: '#67C23A',
  3: '#E6A23C',
  4: '#F56C6C',
  5: '#909399',
  6: '#9B59B6',
  7: '#1ABC9C',
};
// å¤´åƒé¢œè‰²æ± 
const avatarColors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#9B59B6', '#1ABC9C', '#FF6B6B', '#4ECDC4'];
// å½“前审批类型名称
const currentApproveTypeName = computed(() => {
  return approveTypeNameMap[activeTab.value] || '未知类型';
});
// å½“前审批类型
const currentApproveType = computed(() => {
  return Number(activeTab.value);
});
// èŽ·å–ç±»åž‹å›¾æ ‡
const getTypeIcon = (type) => typeIconMap[type] || 'Setting';
// èŽ·å–ç±»åž‹é¢œè‰²
const getTypeColor = (type) => typeColorMap[type] || '#409EFF';
// èŽ·å–å¤´åƒé¢œè‰²
const getAvatarColor = (name) => {
  if (!name) return '#C0C4CC';
  let hash = 0;
  for (let i = 0; i < name.length; i++) {
    hash = name.charCodeAt(i) + ((hash << 5) - hash);
  }
  return avatarColors[Math.abs(hash) % avatarColors.length];
};
// èŽ·å–çº§åˆ«æ–‡æœ¬
const getLevelText = (index) => {
  const texts = ['第一级', '第二级', '第三级', '第四级', '第五级', '第六级', '第七级', '第八级'];
  return texts[index] || `第${index + 1}级`;
};
// å®¡æ‰¹äººåˆ—表(真实接口)
const userList = ref([]);
// å®¡æ‰¹äººåˆ—表
const approverList = ref([]);
// åŽŸå§‹æ•°æ®ï¼Œç”¨äºŽé‡ç½®
const originalList = ref([]);
// åŠ è½½çŠ¶æ€
const loading = ref(false);
const saveLoading = ref(false);
// æ ‡ç­¾é¡µåˆ‡æ¢å¤„理
const handleTabChange = () => {
  loadData();
};
// åŠ è½½å®¡æ‰¹é…ç½®æ•°æ®ï¼ˆæ¨¡æ‹Ÿï¼‰
const loadData = async () => {
  loading.value = true;
  try {
    const res = await getApproveProcessConfigNodeList(currentApproveType.value);
    const source = Array.isArray(res?.data)
      ? res.data
      : Array.isArray(res?.rows)
        ? res.rows
        : Array.isArray(res?.data?.records)
          ? res.data.records
          : [];
    const data = source.map((item, index) => ({
      ...item,
      sortOrder: item.nodeOrder ?? item.sortOrder ?? index + 1,
    }));
    approverList.value = data.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
    originalList.value = JSON.parse(JSON.stringify(approverList.value));
  } catch (error) {
    approverList.value = [];
    originalList.value = [];
    ElMessage.error('加载审批配置失败');
  } finally {
    loading.value = false;
  }
};
const loadUserList = async () => {
  try {
    const res = await userListNoPage();
    userList.value = Array.isArray(res?.data) ? res.data : [];
  } catch (error) {
    userList.value = [];
    ElMessage.error('加载人员列表失败');
  }
};
// å®¡æ‰¹äººé€‰æ‹©å˜åŒ–
const handleApproverChange = (userId, row) => {
  const user = userList.value.find((u) => u.userId === userId);
  if (user) {
    row.approverName = user.nickName;
  }
};
// æ–°å¢žå®¡æ‰¹äºº
const handleAdd = () => {
  const newOrder = approverList.value.length + 1;
  approverList.value.push({
    id: null,
    approveType: currentApproveType.value,
    approverId: null,
    approverName: '',
    sortOrder: newOrder,
  });
};
// åˆ é™¤å®¡æ‰¹äºº
const handleDelete = (index) => {
  ElMessageBox.confirm('确定删除该审批人吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning',
  })
    .then(() => {
      approverList.value.splice(index, 1);
      approverList.value.forEach((item, idx) => {
        item.sortOrder = idx + 1;
      });
      ElMessage.success('删除成功');
    })
    .catch(() => {});
};
// å‰ç§»
const moveLeft = (index) => {
  if (index === 0) return;
  const temp = approverList.value[index];
  approverList.value[index] = approverList.value[index - 1];
  approverList.value[index - 1] = temp;
  approverList.value[index].sortOrder = index + 1;
  approverList.value[index - 1].sortOrder = index;
};
// åŽç§»
const moveRight = (index) => {
  if (index === approverList.value.length - 1) return;
  const temp = approverList.value[index];
  approverList.value[index] = approverList.value[index + 1];
  approverList.value[index + 1] = temp;
  approverList.value[index].sortOrder = index + 1;
  approverList.value[index + 1].sortOrder = index + 2;
};
// ä¿å­˜é…ç½®
const handleSave = async () => {
  if (approverList.value.length === 0) {
    ElMessage.warning('请至少配置一个审批人');
    return;
  }
  const hasEmptyApprover = approverList.value.some((item) => !item.approverId);
  if (hasEmptyApprover) {
    ElMessage.warning('请选择所有审批人');
    return;
  }
  const approverIds = approverList.value.map((item) => item.approverId);
  const uniqueIds = [...new Set(approverIds)];
  if (uniqueIds.length !== approverIds.length) {
    ElMessage.warning('审批人不能重复');
    return;
  }
  saveLoading.value = true;
  try {
    const payload = approverList.value.map((item, index) => ({
      approveType: currentApproveType.value,
      nodeOrder: index + 1,
      approverId: item.approverId,
      approverName: item.approverName,
    }));
    await addApproveProcessConfigNode(payload);
    ElMessage.success('保存成功');
    await loadData();
  } catch (error) {
    ElMessage.error('保存失败');
  } finally {
    saveLoading.value = false;
  }
};
// é‡ç½®
const handleReset = () => {
  if (originalList.value.length === 0) {
    approverList.value = [];
    return;
  }
  ElMessageBox.confirm('确定要重置当前配置吗?未保存的更改将丢失。', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning',
  })
    .then(() => {
      approverList.value = JSON.parse(JSON.stringify(originalList.value));
      ElMessage.success('已重置');
    })
    .catch(() => {});
};
onMounted(async () => {
  await loadUserList();
  await loadData();
});
</script>
<style scoped>
.page-header {
  margin-bottom: 20px;
}
.header-title {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 20px;
  font-weight: 600;
  color: var(--el-text-color-primary, #303133);
  margin-bottom: 6px;
}
.title-icon {
  font-size: 24px;
  color: var(--el-color-primary, #409EFF);
}
.header-desc {
  font-size: 13px;
  color: var(--el-text-color-secondary, #909399);
  margin-left: 34px;
}
/* å®¡æ‰¹ç±»åž‹åˆ‡æ¢ - ç´§å‡‘标签式 */
.type-tabs {
  display: flex;
  gap: 4px;
  margin-bottom: 16px;
  padding: 4px;
  background: var(--el-fill-color-light, #f5f7fa);
  border-radius: 8px;
  overflow-x: auto;
}
.type-tab {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 14px;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.2s ease;
  white-space: nowrap;
  font-size: 13px;
  color: var(--el-text-color-regular, #606266);
}
.type-tab:hover {
  background: var(--el-color-primary-light-9, rgba(64, 158, 255, 0.1));
  color: var(--el-color-primary, #409EFF);
}
.type-tab.active {
  background: var(--el-bg-color, #fff);
  color: var(--el-color-primary, #409EFF);
  font-weight: 600;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.tab-name {
  font-size: 13px;
}
.tab-count {
  min-width: 16px;
  height: 16px;
  padding: 0 5px;
  background: var(--el-color-success, #67C23A);
  color: #fff;
  border-radius: 8px;
  font-size: 11px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
}
.config-card {
  margin-bottom: 16px;
  border-radius: 12px;
}
:deep(.el-card__header) {
  padding: 16px 20px;
  border-bottom: 1px solid var(--el-border-color-light, #ebeef5);
}
.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.header-left {
  display: flex;
  align-items: center;
  gap: 14px;
}
.type-icon {
  width: 44px;
  height: 44px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-info {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.type-name {
  font-size: 16px;
  font-weight: 600;
  color: var(--el-text-color-primary, #303133);
}
.header-actions {
  display: flex;
  gap: 10px;
}
.flow-wrapper {
  overflow-x: auto;
  padding: 8px 4px;
}
.flow-container {
  display: flex;
  align-items: center;
  gap: 0;
  min-width: min-content;
}
.flow-item {
  display: flex;
  align-items: center;
}
.node-card {
  width: 200px;
  background: var(--el-bg-color, #fff);
  border: 2px solid var(--el-border-color, #e4e7ed);
  border-radius: 12px;
  padding: 16px;
  position: relative;
  transition: all 0.3s ease;
  flex-shrink: 0;
}
.node-card:hover {
  border-color: var(--el-color-primary, #409EFF);
  box-shadow: 0 4px 16px rgba(64, 158, 255, 0.15);
  transform: translateY(-2px);
}
.node-card.empty {
  border-style: dashed;
  border-color: var(--el-border-color, #c0c4cc);
  background: var(--el-fill-color-light, #fafbfc);
}
.node-card.empty:hover {
  border-color: var(--el-color-primary, #409EFF);
  background: var(--el-fill-color-light, #f5f7fa);
}
.node-badge {
  position: absolute;
  top: -10px;
  left: 16px;
  width: 24px;
  height: 24px;
  background: var(--el-color-primary, #409EFF);
  color: #fff;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 700;
  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.4);
}
.node-avatar-section {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-bottom: 12px;
  margin-top: 4px;
}
.node-avatar {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--el-fill-color, #f0f2f5);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 8px;
  color: var(--el-text-color-placeholder, #c0c4cc);
  transition: all 0.3s ease;
}
.node-avatar.has-user {
  color: #fff;
  font-size: 22px;
  font-weight: 600;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.node-level {
  font-size: 12px;
  color: var(--el-text-color-secondary, #909399);
  font-weight: 500;
}
.node-select-section {
  margin-bottom: 12px;
}
.node-select-section :deep(.el-select) {
  width: 100%;
}
.node-actions {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding-top: 12px;
  border-top: 1px solid var(--el-border-color-light, #ebeef5);
}
.action-btn {
  transition: all 0.2s;
}
.action-btn:hover:not(:disabled) {
  transform: scale(1.1);
}
.arrow-connector {
  display: flex;
  align-items: center;
  width: 50px;
  position: relative;
}
.arrow-line {
  flex: 1;
  height: 2px;
  background: var(--el-border-color, #c0c4cc);
}
.arrow-icon {
  color: var(--el-text-color-placeholder, #c0c4cc);
  font-size: 14px;
  margin-left: -2px;
}
/* æ–°å¢žèŠ‚ç‚¹æ ·å¼ */
.add-node-item {
  display: flex;
  align-items: center;
}
.add-node-card {
  width: 140px;
  height: 200px;
  border: 2px dashed var(--el-border-color, #c0c4cc);
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  cursor: pointer;
  transition: all 0.3s ease;
  background: var(--el-fill-color-light, #fafbfc);
  flex-shrink: 0;
  margin-left: 0;
}
.add-node-card:hover {
  border-color: var(--el-color-primary, #409EFF);
  background: var(--el-color-primary-light-9, #f0f7ff);
  transform: translateY(-2px);
  box-shadow: 0 4px 16px rgba(64, 158, 255, 0.15);
}
.add-icon-wrapper {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--el-color-primary, #409EFF);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
  transition: all 0.3s ease;
}
.add-node-card:hover .add-icon-wrapper {
  transform: scale(1.1);
  box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
}
.add-text {
  font-size: 14px;
  color: var(--el-text-color-regular, #606266);
  font-weight: 500;
}
.add-node-card:hover .add-text {
  color: var(--el-color-primary, #409EFF);
}
/* ç©ºçŠ¶æ€ */
.empty-state {
  padding: 50px 20px;
}
.empty-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}
.empty-icon-wrapper {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: var(--el-fill-color-light, #f5f7fa);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 8px;
}
.empty-text {
  font-size: 16px;
  font-weight: 600;
  color: var(--el-text-color-regular, #606266);
}
.empty-subtext {
  font-size: 13px;
  color: var(--el-text-color-secondary, #909399);
}
.empty-add-btn {
  margin-top: 8px;
  padding: 12px 28px;
}
/* åº•部提示 */
.bottom-tips {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 12px 20px;
  background: var(--el-fill-color-light, #f5f7fa);
  border-radius: 8px;
  color: var(--el-text-color-regular, #606266);
  font-size: 13px;
}
.bottom-tips .el-icon {
  color: var(--el-color-primary, #409EFF);
  font-size: 16px;
}
@media (max-width: 768px) {
  .type-tabs {
    padding: 3px;
  }
  .type-tab {
    padding: 6px 10px;
    font-size: 12px;
  }
  .tab-name {
    font-size: 12px;
  }
  .flow-container {
    flex-wrap: wrap;
    justify-content: center;
  }
  .arrow-connector {
    width: 100%;
    height: 30px;
    flex-direction: row;
    justify-content: center;
  }
  .arrow-line {
    width: 2px;
    height: 30px;
  }
  .arrow-icon {
    right: auto;
    top: auto;
    bottom: -5px;
    transform: rotate(90deg);
  }
  .add-node-item {
    width: 100%;
    justify-content: center;
    margin-top: 10px;
  }
  .add-node-item .arrow-connector {
    display: none;
  }
}
</style>