src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,399 @@
<!-- å®¡æ‰¹æ¨¡æ¿ï¼šå¯é…ç½®èŠ‚ç‚¹æ•°ï¼Œæ¯èŠ‚ç‚¹å¤šäºº + ä¼šç­¾/或签 -->
<template>
  <div class="tfe">
    <div v-if="innerList.length" class="tfe-flow">
      <div v-for="(item, index) in innerList" :key="item._uid" class="tfe-flow-item">
        <div class="tfe-card" :class="{ 'tfe-card--empty': !item.approvers?.length }">
          <div class="tfe-badge">{{ index + 1 }}</div>
          <div class="tfe-head">
            <span class="tfe-level">{{ levelText(index) }}</span>
            <el-radio-group
              v-if="!readonly"
              v-model="item.signMode"
              size="small"
              @change="emitOut"
            >
              <el-radio-button value="countersign">会签</el-radio-button>
              <el-radio-button value="or_sign">或签</el-radio-button>
            </el-radio-group>
            <el-tag v-else size="small" type="info" effect="plain">
              {{ signModeLabel(item.signMode) }}
            </el-tag>
          </div>
          <p class="tfe-mode-tip">{{ signModeTip(item.signMode) }}</p>
          <div v-if="!readonly" class="tfe-select">
            <el-select
              v-model="item.approverIds"
              multiple
              collapse-tags
              collapse-tags-tooltip
              :max-collapse-tags="2"
              filterable
              placeholder="请选择审批人(可多选)"
              style="width: 100%"
              @change="(ids) => onApproversChange(ids, item)"
            >
              <el-option
                v-for="u in userOptions"
                :key="String(u.userId ?? u.id)"
                :label="optionLabel(u)"
                :value="u.userId ?? u.id"
              />
            </el-select>
          </div>
          <div v-if="item.approvers?.length" class="tfe-chips" :class="{ 'tfe-chips--readonly': readonly }">
            <el-tag
              v-for="a in item.approvers"
              :key="String(a.approverId)"
              size="small"
              type="info"
              effect="plain"
            >
              {{ a.approverName || "—" }}
            </el-tag>
          </div>
          <div v-if="!readonly" class="tfe-actions">
            <el-button type="primary" circle size="small" :disabled="index === 0" title="前移" @click="moveLeft(index)">
              <el-icon><ArrowLeft /></el-icon>
            </el-button>
            <el-button
              type="primary"
              circle
              size="small"
              :disabled="index === innerList.length - 1"
              title="后移"
              @click="moveRight(index)"
            >
              <el-icon><ArrowRight /></el-icon>
            </el-button>
            <el-button type="danger" circle size="small" title="删除节点" @click="remove(index)">
              <el-icon><Delete /></el-icon>
            </el-button>
          </div>
          <p v-else-if="!item.approvers?.length" class="tfe-empty-approver">暂无审批人</p>
        </div>
        <div v-if="index < innerList.length - 1" class="tfe-conn">
          <div class="tfe-conn-line"></div>
          <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon>
        </div>
      </div>
      <div v-if="!readonly" class="tfe-add-wrap">
        <div v-if="innerList.length" class="tfe-conn">
          <div class="tfe-conn-line"></div>
          <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon>
        </div>
        <div class="tfe-add-card" @click="addNode">
          <div class="tfe-add-icon"><el-icon :size="26"><Plus /></el-icon></div>
          <span>新增节点</span>
        </div>
      </div>
    </div>
    <div v-else class="tfe-empty">
      <el-icon :size="44" color="#c0c4cc"><User /></el-icon>
      <p>暂无审批节点</p>
      <el-button v-if="!readonly" type="primary" @click="addNode">添加第一个节点</el-button>
    </div>
  </div>
</template>
<script setup>
import { ArrowLeft, ArrowRight, Delete, Plus, User } from "@element-plus/icons-vue";
import { ref, watch } from "vue";
import { NODE_SIGN_MODE_OPTIONS, normalizeFlowNodes } from "../approveTemplateConstants.js";
const props = defineProps({
  modelValue: { type: Array, default: () => [] },
  userOptions: { type: Array, default: () => [] },
  /** é€‰æ‹©æ¨¡æ¿åŽç”³è¯·åœºæ™¯ï¼šä»…展示,不可改审批人/节点 */
  readonly: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const innerList = ref([]);
function signModeTip(mode) {
  return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.desc || "";
}
function signModeLabel(mode) {
  return (
    NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.label ||
    (mode === "or_sign" ? "或签" : "会签")
  );
}
function levelText(i) {
  const t = ["第一级", "第二级", "第三级", "第四级", "第五级", "第六级", "第七级", "第八级"];
  return t[i] || `第${i + 1}级`;
}
function optionLabel(u) {
  const nick = u.nickName || "";
  const un = u.userName || "";
  if (nick && un && nick !== un) return `${nick}(${un})`;
  return nick || un || `用户${u.userId ?? u.id ?? ""}`;
}
function newUid() {
  return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
function mapIn(rows) {
  const normalized = normalizeFlowNodes(rows);
  return normalized.map((n) => ({
    _uid: newUid(),
    id: n.id,
    templateId: n.templateId,
    nodeOrder: n.nodeOrder,
    signMode: n.signMode,
    approverIds: n.approvers.map((a) => a.approverId),
    approvers: [...n.approvers],
  }));
}
function publicShape(rows) {
  return normalizeFlowNodes(
    (rows || []).map((r) => ({
      id: r.id,
      templateId: r.templateId,
      nodeOrder: r.nodeOrder,
      signMode: r.signMode,
      approvers: r.approvers || [],
    }))
  );
}
function emitOut() {
  emit("update:modelValue", publicShape(innerList.value));
}
watch(
  () => props.modelValue,
  (v) => {
    const next = publicShape(v || []);
    if (JSON.stringify(next) === JSON.stringify(publicShape(innerList.value))) return;
    innerList.value = mapIn(v || []);
  },
  { deep: true, immediate: true }
);
function findUser(id) {
  if (id == null || id === "") return null;
  return props.userOptions.find((u) => String(u.userId ?? u.id) === String(id)) ?? null;
}
function onApproversChange(ids, row) {
  const idList = Array.isArray(ids) ? ids : [];
  const prevById = new Map((row.approvers || []).map((a) => [String(a.approverId), a]));
  row.approverIds = idList;
  row.approvers = idList.map((id) => {
    const prev = prevById.get(String(id));
    const u = findUser(id);
    const item = {
      approverId: id,
      approverName: u ? u.nickName || u.userName || "" : prev?.approverName || "",
    };
    if (prev?.id != null) item.id = prev.id;
    if (prev?.nodeId != null) item.nodeId = prev.nodeId;
    else if (row.id != null) item.nodeId = row.id;
    if (prev?.templateId != null) item.templateId = prev.templateId;
    else if (row.templateId != null) item.templateId = row.templateId;
    return item;
  });
  emitOut();
}
function addNode() {
  if (props.readonly) return;
  innerList.value.push({
    _uid: newUid(),
    nodeOrder: innerList.value.length + 1,
    signMode: "countersign",
    approverIds: [],
    approvers: [],
  });
  emitOut();
}
function remove(index) {
  if (props.readonly) return;
  innerList.value.splice(index, 1);
  emitOut();
}
function moveLeft(index) {
  if (props.readonly) return;
  if (index < 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index - 1];
  innerList.value[index - 1] = t;
  emitOut();
}
function moveRight(index) {
  if (props.readonly) return;
  if (index >= innerList.value.length - 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index + 1];
  innerList.value[index + 1] = t;
  emitOut();
}
</script>
<style scoped>
.tfe {
  width: 100%;
}
.tfe-flow {
  display: flex;
  align-items: flex-start;
  flex-wrap: nowrap;
  overflow-x: auto;
  padding: 6px 0 10px;
}
.tfe-flow-item {
  display: flex;
  align-items: center;
}
.tfe-card {
  width: 248px;
  flex-shrink: 0;
  border: 2px solid var(--el-border-color);
  border-radius: 12px;
  padding: 14px 12px 12px;
  position: relative;
  background: var(--el-bg-color);
}
.tfe-card--empty {
  border-style: dashed;
  background: var(--el-fill-color-lighter);
}
.tfe-badge {
  position: absolute;
  top: -8px;
  left: 12px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tfe-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin: 8px 0 4px;
}
.tfe-level {
  font-size: 13px;
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.tfe-mode-tip {
  font-size: 11px;
  color: var(--el-text-color-secondary);
  margin: 0 0 10px;
  line-height: 1.4;
  min-height: 30px;
}
.tfe-select {
  margin-bottom: 8px;
}
.tfe-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-bottom: 8px;
  min-height: 24px;
}
.tfe-chips--readonly {
  margin-top: 4px;
  margin-bottom: 0;
}
.tfe-empty-approver {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
  margin: 4px 0 0;
}
.tfe-actions {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding-top: 10px;
  border-top: 1px solid var(--el-border-color-lighter);
}
.tfe-conn {
  display: flex;
  align-items: center;
  width: 40px;
  flex-shrink: 0;
  align-self: center;
}
.tfe-conn-line {
  flex: 1;
  height: 2px;
  background: var(--el-border-color);
}
.tfe-conn-icon {
  font-size: 14px;
  color: var(--el-text-color-placeholder);
  margin-left: -2px;
}
.tfe-add-wrap {
  display: flex;
  align-items: center;
}
.tfe-add-card {
  width: 120px;
  min-height: 200px;
  flex-shrink: 0;
  border: 2px dashed var(--el-border-color);
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  cursor: pointer;
  color: var(--el-text-color-regular);
  font-size: 13px;
  background: var(--el-fill-color-lighter);
  transition: border-color 0.2s, background 0.2s;
}
.tfe-add-card:hover {
  border-color: var(--el-color-primary);
  background: var(--el-color-primary-light-9);
  color: var(--el-color-primary);
}
.tfe-add-icon {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tfe-empty {
  text-align: center;
  padding: 28px 16px;
  border: 1px dashed var(--el-border-color);
  border-radius: 12px;
  background: var(--el-fill-color-lighter);
}
.tfe-empty p {
  margin: 10px 0 14px;
  color: var(--el-text-color-secondary);
  font-size: 14px;
}
</style>