zhangwencui
2026-05-15 429b6e4d00594183bbcf02aba24d2df2d3f4c95b
src/pages/cooperativeOffice/collaborativeApproval/approve.vue
@@ -1,8 +1,7 @@
<template>
  <view class="approve-page">
    <PageHeader title="审核" @back="goBack" />
    <PageHeader title="审核"
                @back="goBack" />
    <!-- 申请信息 -->
    <view class="application-info">
      <view class="info-header">
@@ -25,7 +24,6 @@
          <text class="info-label">申请日期</text>
          <text class="info-value">{{ approvalData.approveTime }}</text>
        </view>
        <!-- approveType=2 请假相关字段 -->
        <template v-if="approvalData.approveType === 2">
          <view class="info-row">
@@ -37,462 +35,472 @@
            <text class="info-value">{{ approvalData.endDate || '-' }}</text>
          </view>
        </template>
        <!-- approveType=3 出差相关字段 -->
        <view v-if="approvalData.approveType === 3" class="info-row">
        <view v-if="approvalData.approveType === 3"
              class="info-row">
          <text class="info-label">出差地点</text>
          <text class="info-value">{{ approvalData.location || '-' }}</text>
        </view>
        <!-- approveType=4 报销相关字段 -->
        <view v-if="approvalData.approveType === 4" class="info-row">
        <view v-if="approvalData.approveType === 4"
              class="info-row">
          <text class="info-label">报销金额</text>
          <text class="info-value">{{ approvalData.price ? `¥${approvalData.price}` : '-' }}</text>
        </view>
      </view>
    </view>
    <!-- 审批流程 -->
    <view class="approval-process">
      <view class="process-header">
        <text class="process-title">审批流程</text>
      </view>
      <view class="process-steps">
        <view
          v-for="(step, index) in approvalSteps"
          :key="index"
          class="process-step"
          :class="{
        <view v-for="(step, index) in approvalSteps"
              :key="index"
              class="process-step"
              :class="{
            'completed': step.status === 'completed',
            'current': step.status === 'current',
            'pending': step.status === 'pending',
            'rejected': step.status === 'rejected'
          }"
        >
          }">
          <view class="step-indicator">
            <view class="step-dot">
              <text v-if="step.status === 'completed'" class="step-icon">✓</text>
              <text v-else-if="step.status === 'rejected'" class="step-icon">✗</text>
              <text v-else class="step-number">{{ index + 1 }}</text>
              <text v-if="step.status === 'completed'"
                    class="step-icon">✓</text>
              <text v-else-if="step.status === 'rejected'"
                    class="step-icon">✗</text>
              <text v-else
                    class="step-number">{{ index + 1 }}</text>
            </view>
            <view v-if="index < approvalSteps.length - 1" class="step-line"></view>
            <view v-if="index < approvalSteps.length - 1"
                  class="step-line"></view>
          </view>
          <view class="step-content">
            <view class="step-info">
              <text class="step-title">{{ step.title }}</text>
              <text class="step-approver">{{ step.approverName }}</text>
              <text v-if="step.approveTime" class="step-time">{{ step.approveTime }}</text>
              <text v-if="step.approveTime"
                    class="step-time">{{ step.approveTime }}</text>
            </view>
            <view v-if="step.opinion" class="step-opinion">
            <view v-if="step.opinion"
                  class="step-opinion">
              <text class="opinion-label">审批意见:</text>
              <text class="opinion-content">{{ step.opinion }}</text>
            </view>
            <!-- 签名展示 -->
            <view v-if="step.urlTem" class="step-opinion" style="margin-top:8px;">
            <view v-if="step.urlTem"
                  class="step-opinion"
                  style="margin-top:8px;">
              <text class="opinion-label">签名:</text>
              <image :src="step.urlTem" mode="widthFix" style="width:180px;border-radius:6px;border:1px solid #eee;" />
              <image :src="step.urlTem"
                     mode="widthFix"
                     style="width:180px;border-radius:6px;border:1px solid #eee;" />
            </view>
          </view>
        </view>
      </view>
    </view>
    <!-- 审核意见输入 -->
    <view v-if="canApprove" class="approval-input">
    <view v-if="canApprove"
          class="approval-input">
      <view class="input-header">
        <text class="input-title">审核意见</text>
      </view>
      <view class="input-content">
        <u-textarea
          v-model="approvalOpinion"
          rows="4"
          placeholder="请输入审核意见"
          maxlength="200"
          count
        />
        <u-textarea v-model="approvalOpinion"
                    rows="4"
                    placeholder="请输入审核意见"
                    maxlength="200"
                    count />
      </view>
    </view>
    <!-- 底部操作按钮 -->
    <view v-if="canApprove" class="footer-actions">
      <u-button class="reject-btn" @click="handleReject">驳回</u-button>
      <u-button class="approve-btn" @click="handleApprove">通过</u-button>
    <view v-if="canApprove"
          class="footer-actions">
      <u-button class="reject-btn"
                @click="handleReject">驳回</u-button>
      <u-button class="approve-btn"
                @click="handleApprove">通过</u-button>
    </view>
  </view>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { approveProcessGetInfo, approveProcessDetails, updateApproveNode } from '@/api/collaborativeApproval/approvalProcess'
import useUserStore from '@/store/modules/user'
const showToast = (message) => {
   uni.showToast({
      title: message,
      icon: 'none'
   })
}
import PageHeader from "@/components/PageHeader.vue";
  import { ref, onMounted, computed } from "vue";
  import {
    approveProcessGetInfo,
    approveProcessDetails,
    updateApproveNode,
  } from "@/api/collaborativeApproval/approvalProcess";
  import useUserStore from "@/store/modules/user";
  const showToast = message => {
    uni.showToast({
      title: message,
      icon: "none",
    });
  };
  import PageHeader from "@/components/PageHeader.vue";
const userStore = useUserStore()
const approvalData = ref({})
const approvalSteps = ref([])
const approvalOpinion = ref('')
const approveId = ref('')
  const userStore = useUserStore();
  const approvalData = ref({});
  const approvalSteps = ref([]);
  const approvalOpinion = ref("");
  const approveId = ref("");
// 从详情接口字段对齐 canApprove:仅当有 isShen 的节点时可审批
const canApprove = computed(() => {
  return approvalSteps.value.some(step => step.isShen === true)
})
  // 从详情接口字段对齐 canApprove:仅当有 isShen 的节点时可审批
  const canApprove = computed(() => {
    return approvalSteps.value.some(step => step.isShen === true);
  });
onMounted(() => {
  approveId.value = uni.getStorageSync('approveId')
  if (approveId.value) {
    loadApprovalData()
  }
})
const loadApprovalData = () => {
  // 基本申请信息
  approveProcessGetInfo({ id: approveId.value }).then(res => {
    approvalData.value = res.data || {}
  })
  // 审批节点详情
  approveProcessDetails(approveId.value).then(res => {
    const list = Array.isArray(res.data) ? res.data : []
    // 保存原始节点数据供提交使用
    activities.value = list
    approvalSteps.value = list.map((it, idx) => {
      // 节点状态映射:1=通过,2=不通过,否则看是否当前(isShen),再默认为待处理
      let status = 'pending'
      if (it.approveNodeStatus === 1) status = 'completed'
      else if (it.approveNodeStatus === 2) status = 'rejected'
      else if (it.isShen) status = 'current'
      return {
        title: `第${idx + 1}步审批`,
        approverName: it.approveNodeUser || '未知用户',
        status,
        approveTime: it.approveTime || null,
        opinion: it.approveNodeReason || '',
        urlTem: it.urlTem || '',
        isShen: !!it.isShen
      }
    })
  })
}
const goBack = () => {
  uni.removeStorageSync('approveId');
  uni.navigateBack()
}
const submitForm = (status) => {
  // 可选:校验审核意见
  if (!approvalOpinion.value?.trim()) {
    showToast('请输入审核意见')
    return
  }
  // 找到当前可审批节点
  const filteredActivities = activities.value.filter(activity => activity.isShen)
  if (!filteredActivities.length) {
    showToast('当前无可审批节点')
    return
  }
  // 写入状态和意见
  filteredActivities[0].approveNodeStatus = status
  filteredActivities[0].approveNodeReason = approvalOpinion.value || ''
  // 计算是否为最后一步
  const isLast = activities.value.findIndex(a => a.isShen) === activities.value.length - 1
  // 调用后端
  updateApproveNode({ ...filteredActivities[0], isLast }).then(() => {
    const msg = status === 1 ? '审批通过' : '审批已驳回'
    showToast(msg)
    // 提示后返回上一个页面
    setTimeout(() => {
      goBack() // 内部是 uni.navigateBack()
    }, 800)
  })
}
const handleApprove = () => {
  uni.showModal({
    title: '确认操作',
    content: '确定要通过此审批吗?',
    success: (res) => {
      if (res.confirm) submitForm(1)
  onMounted(() => {
    approveId.value = uni.getStorageSync("approveId");
    if (approveId.value) {
      loadApprovalData();
    }
  })
}
  });
const handleReject = () => {
  uni.showModal({
    title: '确认操作',
    content: '确定要驳回此审批吗?',
    success: (res) => {
      if (res.confirm) submitForm(2)
  const loadApprovalData = () => {
    // 基本申请信息
    approveProcessGetInfo({ id: approveId.value }).then(res => {
      approvalData.value = res.data || {};
    });
    // 审批节点详情
    approveProcessDetails(approveId.value).then(res => {
      const list = Array.isArray(res.data) ? res.data : [];
      // 保存原始节点数据供提交使用
      activities.value = list;
      approvalSteps.value = list.map((it, idx) => {
        // 节点状态映射:1=通过,2=不通过,否则看是否当前(isShen),再默认为待处理
        let status = "pending";
        if (it.approveNodeStatus === 1) status = "completed";
        else if (it.approveNodeStatus === 2) status = "rejected";
        else if (it.isShen) status = "current";
        return {
          title: `第${idx + 1}步审批`,
          approverName: it.approveNodeUser || "未知用户",
          status,
          approveTime: it.approveTime || null,
          opinion: it.approveNodeReason || "",
          urlTem: it.urlTem || "",
          isShen: !!it.isShen,
        };
      });
    });
  };
  const goBack = () => {
    uni.removeStorageSync("approveId");
    uni.navigateBack();
  };
  const submitForm = status => {
    // 可选:校验审核意见
    if (!approvalOpinion.value?.trim()) {
      showToast("请输入审核意见");
      return;
    }
  })
}
// 原始节点数据(用于提交逻辑)
const activities = ref([])
    // 找到当前可审批节点
    const filteredActivities = activities.value.filter(
      activity => activity.isShen
    );
    if (!filteredActivities.length) {
      showToast("当前无可审批节点");
      return;
    }
    // 写入状态和意见
    filteredActivities[0].approveNodeStatus = status;
    filteredActivities[0].approveNodeReason = approvalOpinion.value || "";
    // 计算是否为最后一步
    const isLast =
      activities.value.findIndex(a => a.isShen) === activities.value.length - 1;
    // 调用后端
    updateApproveNode({ ...filteredActivities[0], isLast }).then(() => {
      const msg = status === 1 ? "审批通过" : "审批已驳回";
      showToast(msg);
      // 提示后返回上一个页面
      setTimeout(() => {
        goBack(); // 内部是 uni.navigateBack()
      }, 800);
    });
  };
  const handleApprove = () => {
    uni.showModal({
      title: "确认操作",
      content: "确定要通过此审批吗?",
      success: res => {
        if (res.confirm) submitForm(1);
      },
    });
  };
  const handleReject = () => {
    uni.showModal({
      title: "确认操作",
      content: "确定要驳回此审批吗?",
      success: res => {
        if (res.confirm) submitForm(2);
      },
    });
  };
  // 原始节点数据(用于提交逻辑)
  const activities = ref([]);
</script>
<style scoped lang="scss">
.approve-page {
  min-height: 100vh;
  background: #f8f9fa;
  padding-bottom: 80px;
}
.header {
  display: flex;
  align-items: center;
  background: #fff;
  padding: 16px 20px;
  border-bottom: 1px solid #f0f0f0;
  position: sticky;
  top: 0;
  z-index: 100;
}
.title {
  flex: 1;
  text-align: center;
  font-size: 18px;
  font-weight: 600;
  color: #333;
}
.application-info {
  background: #fff;
  margin: 16px;
  border-radius: 12px;
  overflow: hidden;
}
.info-header {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
  background: #f8f9fa;
}
.info-title {
  font-size: 16px;
  font-weight: 600;
  color: #333;
}
.info-content {
  padding: 16px;
}
.info-row {
  display: flex;
  align-items: center;
  margin-bottom: 12px;
  &:last-child {
    margin-bottom: 0;
  .approve-page {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 80px;
  }
}
.info-label {
  font-size: 14px;
  color: #666;
  width: 80px;
  flex-shrink: 0;
}
  .header {
    display: flex;
    align-items: center;
    background: #fff;
    padding: 16px 20px;
    border-bottom: 1px solid #f0f0f0;
    position: sticky;
    top: 0;
    z-index: 100;
  }
.info-value {
  font-size: 14px;
  color: #333;
  flex: 1;
}
  .title {
    flex: 1;
    text-align: center;
    font-size: 18px;
    font-weight: 600;
    color: #333;
  }
.approval-process {
  background: #fff;
  margin: 16px;
  border-radius: 12px;
  overflow: hidden;
}
  .application-info {
    background: #fff;
    margin: 16px;
    border-radius: 12px;
    overflow: hidden;
  }
.process-header {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
  background: #f8f9fa;
}
  .info-header {
    padding: 16px;
    border-bottom: 1px solid #f0f0f0;
    background: #f8f9fa;
  }
.process-title {
  font-size: 16px;
  font-weight: 600;
  color: #333;
}
  .info-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
.process-steps {
  padding: 20px;
}
  .info-content {
    padding: 16px;
  }
.process-step {
  display: flex;
  position: relative;
  margin-bottom: 24px;
  &:last-child {
    margin-bottom: 0;
    .step-line {
      display: none;
  .info-row {
    display: flex;
    align-items: center;
    margin-bottom: 12px;
    &:last-child {
      margin-bottom: 0;
    }
  }
}
.step-indicator {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-right: 16px;
}
  .info-label {
    font-size: 14px;
    color: #666;
    width: 80px;
    flex-shrink: 0;
  }
.step-dot {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  font-weight: 600;
  position: relative;
  z-index: 2;
}
  .info-value {
    font-size: 14px;
    color: #333;
    flex: 1;
  }
.process-step.completed .step-dot {
  background: #52c41a;
  color: #fff;
}
  .approval-process {
    background: #fff;
    margin: 16px;
    border-radius: 12px;
    overflow: hidden;
  }
.process-step.current .step-dot {
  background: #1890ff;
  color: #fff;
  animation: pulse 2s infinite;
}
  .process-header {
    padding: 16px;
    border-bottom: 1px solid #f0f0f0;
    background: #f8f9fa;
  }
.process-step.pending .step-dot {
  background: #d9d9d9;
  color: #999;
}
  .process-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
.step-line {
  width: 2px;
  height: 40px;
  background: #d9d9d9;
  margin-top: 8px;
}
  .process-steps {
    padding: 20px;
  }
.process-step.completed .step-line {
  background: #52c41a;
}
  .process-step {
    display: flex;
    position: relative;
    margin-bottom: 24px;
.process-step.rejected .step-dot {
  background: #ff4d4f;
  color: #fff;
}
.process-step.rejected .step-line {
  background: #ff4d4f;
}
    &:last-child {
      margin-bottom: 0;
.step-content {
  flex: 1;
  padding-top: 4px;
}
      .step-line {
        display: none;
      }
    }
  }
.step-info {
  margin-bottom: 8px;
}
  .step-indicator {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-right: 16px;
  }
.step-title {
  font-size: 16px;
  font-weight: 600;
  color: #333;
  display: block;
  margin-bottom: 4px;
}
  .step-dot {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 14px;
    font-weight: 600;
    position: relative;
    z-index: 2;
  }
.step-approver {
  font-size: 14px;
  color: #666;
  display: block;
  margin-bottom: 4px;
}
  .process-step.completed .step-dot {
    background: #52c41a;
    color: #fff;
  }
.step-time {
  font-size: 12px;
  color: #999;
  display: block;
}
  .process-step.current .step-dot {
    background: #1890ff;
    color: #fff;
    animation: pulse 2s infinite;
  }
.step-opinion {
  background: #f8f9fa;
  padding: 12px;
  border-radius: 8px;
  border-left: 4px solid #52c41a;
}
  .process-step.pending .step-dot {
    background: #d9d9d9;
    color: #999;
  }
.opinion-label {
  font-size: 12px;
  color: #666;
  display: block;
  margin-bottom: 4px;
}
  .step-line {
    width: 2px;
    height: 40px;
    background: #d9d9d9;
    margin-top: 8px;
  }
.opinion-content {
  font-size: 14px;
  color: #333;
  line-height: 1.5;
}
  .process-step.completed .step-line {
    background: #52c41a;
  }
.approval-input {
  background: #fff;
  margin: 16px;
  border-radius: 12px;
  overflow: hidden;
}
  .process-step.rejected .step-dot {
    background: #ff4d4f;
    color: #fff;
  }
  .process-step.rejected .step-line {
    background: #ff4d4f;
  }
.input-header {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
  background: #f8f9fa;
}
  .step-content {
    flex: 1;
    padding-top: 4px;
  }
.input-title {
  font-size: 16px;
  font-weight: 600;
  color: #333;
}
  .step-info {
    margin-bottom: 8px;
  }
.input-content {
  padding: 16px;
}
  .step-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
    display: block;
    margin-bottom: 4px;
  }
.footer-actions {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  background: #fff;
  display: flex;
  justify-content: space-around;
  align-items: center;
  padding: 16px;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
  z-index: 1000;
}
  .step-approver {
    font-size: 14px;
    color: #666;
    display: block;
    margin-bottom: 4px;
  }
.reject-btn {
  .step-time {
    font-size: 12px;
    color: #999;
    display: block;
  }
  .step-opinion {
    background: #f8f9fa;
    padding: 12px;
    border-radius: 8px;
    border-left: 4px solid #52c41a;
  }
  .opinion-label {
    font-size: 12px;
    color: #666;
    display: block;
    margin-bottom: 4px;
  }
  .opinion-content {
    font-size: 14px;
    color: #333;
    line-height: 1.5;
  }
  .approval-input {
    background: #fff;
    margin: 16px;
    border-radius: 12px;
    overflow: hidden;
  }
  .input-header {
    padding: 16px;
    border-bottom: 1px solid #f0f0f0;
    background: #f8f9fa;
  }
  .input-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
  .input-content {
    padding: 16px;
  }
  .footer-actions {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    background: #fff;
    display: flex;
    justify-content: space-around;
    align-items: center;
    padding: 16px;
    box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
    z-index: 1000;
  }
  .reject-btn {
    width: 120px;
    background: #ff4d4f;
    color: #fff;
@@ -503,47 +511,47 @@
    background: #52c41a;
    color: #fff;
  }
  /* 适配u-button样式 */
  :deep(.u-button) {
    border-radius: 6px;
  }
@keyframes pulse {
  0% {
    box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.7);
  @keyframes pulse {
    0% {
      box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.7);
    }
    70% {
      box-shadow: 0 0 0 10px rgba(24, 144, 255, 0);
    }
    100% {
      box-shadow: 0 0 0 0 rgba(24, 144, 255, 0);
    }
  }
  70% {
    box-shadow: 0 0 0 10px rgba(24, 144, 255, 0);
  .signature-section {
    background: #fff;
    padding: 12px 16px 16px;
    border-top: 1px solid #f0f0f0;
  }
  100% {
    box-shadow: 0 0 0 0 rgba(24, 144, 255, 0);
  .signature-header {
    margin-bottom: 8px;
  }
}
.signature-section {
  background: #fff;
  padding: 12px 16px 16px;
  border-top: 1px solid #f0f0f0;
}
.signature-header {
  margin-bottom: 8px;
}
.signature-title {
  font-size: 14px;
  font-weight: 600;
  color: #333;
}
.signature-box {
  width: 100%;
  height: 180px;
  background: #fff;
  border: 1px dashed #d9d9d9;
  border-radius: 8px;
  overflow: hidden;
}
.signature-actions {
  margin-top: 8px;
  display: flex;
  justify-content: flex-end;
}
  .signature-title {
    font-size: 14px;
    font-weight: 600;
    color: #333;
  }
  .signature-box {
    width: 100%;
    height: 180px;
    background: #fff;
    border: 1px dashed #d9d9d9;
    border-radius: 8px;
    overflow: hidden;
  }
  .signature-actions {
    margin-top: 8px;
    display: flex;
    justify-content: flex-end;
  }
</style>