| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- å®¡æ¹æ¨¡æ¿ï¼å¯é
ç½®èç¹æ°ï¼æ¯èç¹å¤äºº + ä¼ç¾/æç¾ --> |
| | | <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> |