Files
zkxw/web-admin/src/components/oa-approval/OaFlowPreview.vue
T

857 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 发起审批 · 表单底部审批流预览(对齐 docs/mockups/oa-approval-start-flow-preview-demo.html) -->
<template>
<div v-loading="loading" class="oa-flow-preview">
<div class="oa-flow-preview__title">
<span>审批流程</span>
<span v-if="resolved && hint" class="oa-flow-preview__hint">{{ hint }}</span>
</div>
<div v-if="!resolved" class="oa-flow-preview__pending">
{{ pendingMessage || '必填信息填写后,流程将自动显示' }}
</div>
<div v-else class="oa-flow-preview__chain">
<div v-if="!loading && !steps.length" class="oa-flow-preview__empty">暂无流程配置</div>
<div v-for="(s, idx) in steps" :key="s.id || idx" class="oa-flow-step">
<div class="oa-flow-rail">
<div class="oa-flow-dot" :class="{ end: s.type === 'end' }" />
<div v-if="idx < steps.length - 1" class="oa-flow-line" />
</div>
<div class="oa-flow-card">
<div class="oa-flow-card__row">
<span class="oa-flow-card__name">{{ s.name || '-' }}</span>
<span v-if="signBadge(s)" class="oa-flow-badge" :class="signBadge(s)">
{{ signBadgeText(s) }}
</span>
<template v-if="s.meta">
<span class="oa-flow-card__sep">·</span>
<span class="oa-flow-card__meta" :title="s.meta">{{ s.meta }}</span>
</template>
<span v-if="s.resolveError" class="oa-flow-card__err" :title="s.resolveError">
{{ s.resolveError }}
</span>
<template v-if="isInitiatorSelect(s)">
<span class="oa-flow-card__select">
<span class="oa-flow-card__people">
<template v-if="displayPeople(s).length">
<span v-for="p in displayPeople(s)" :key="p.id" class="oa-person-chip">
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
{{ p.nickName }}
<button
v-if="isLocalSelected(s, p.id)"
type="button"
class="oa-person-chip__x"
title="移除"
@click="removeUser(s.id, p.id)"
>
×
</button>
</span>
</template>
<span v-else class="oa-flow-card__placeholder">
{{ s.type === 'cc' ? '请选择抄送人' : '请选择审批人' }}
</span>
</span>
<button type="button" class="oa-flow-add-btn" title="添加" @click="openPicker(s)">
<el-icon><Plus /></el-icon>
</button>
</span>
</template>
<span v-else-if="(s.people || []).length" class="oa-flow-card__people">
<span v-for="p in s.people" :key="p.id" class="oa-person-chip">
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
{{ p.nickName || p.id }}
</span>
</span>
</div>
</div>
</div>
</div>
<el-dialog
v-model="pickerVisible"
:title="pickerTitle"
width="520px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="protocol-dialog-mask"
@closed="onPickerClosed"
>
<div class="member-picker">
<el-select
v-model="memberPickBuffer"
filterable
clearable
placeholder="搜索姓名 / 工号,点击添加"
style="width: 100%"
popper-class="oa-flow-member-select-popper"
@change="onMemberPick"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="memberOptionLabel(u)"
:value="u.id"
:disabled="pickerIds.includes(u.id)"
>
<div class="member-option">
<span class="member-avatar" :style="{ background: avatarColor(u.id) }">
{{ avatarInitial(u.nickName) }}
</span>
<span class="member-option__meta">
<span class="member-option__name">{{ u.nickName || '未命名' }}</span>
<span v-if="memberOptionSub(u)" class="member-option__sub">
{{ memberOptionSub(u) }}
</span>
</span>
<el-tag
v-if="pickerIds.includes(u.id)"
size="small"
type="info"
effect="plain"
class="member-option__tag"
>
已选
</el-tag>
</div>
</el-option>
</el-select>
<div class="member-chips" :class="{ 'is-empty': !pickerIds.length }">
<template v-if="pickerIds.length">
<span v-for="id in pickerIds" :key="id" class="member-chip">
<span
class="member-avatar member-avatar--sm"
:style="{ background: avatarColor(id) }"
>
{{ avatarInitial(resolveUser(id)?.nickName) }}
</span>
<span class="member-chip__name" :title="chipTitle(id)">
{{ resolveUser(id)?.nickName || id }}
</span>
<button
type="button"
class="member-chip__rm"
:aria-label="`移除 ${resolveUser(id)?.nickName || id}`"
@click="removePickerMember(id)"
>
×
</button>
</span>
</template>
<template v-else>暂未选择成员,请从上方搜索添加</template>
</div>
<p class="member-picked-count">已选 {{ pickerIds.length }} 人</p>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="pickerVisible = false">
<el-icon><Close /></el-icon>
<span>取消</span>
</el-button>
<el-button type="primary" @click="confirmPicker">
<el-icon><Check /></el-icon>
<span>确定</span>
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { Check, Close, Plus } from '@element-plus/icons-vue'
import type { OaFlowPreviewStep } from '@/api/oaApproval'
defineOptions({ name: 'OaFlowPreview' })
type UserOption = { id: string; nickName: string; userNo?: string }
const AVATAR_COLORS = [
'#059669',
'#0ea5e9',
'#8b5cf6',
'#d97706',
'#ef4444',
'#14b8a6',
'#6366f1',
'#ec4899'
]
const props = withDefaults(
defineProps<{
steps?: OaFlowPreviewStep[]
loading?: boolean
hint?: string
resolved?: boolean
pendingMessage?: string
selectByNode?: Record<string, string[]>
userOptions?: UserOption[]
}>(),
{
steps: () => [],
loading: false,
hint: '按当前发起人解析审批人',
resolved: true,
pendingMessage: '必填信息填写后,流程将自动显示',
selectByNode: () => ({}),
userOptions: () => []
}
)
const emit = defineEmits<{
'update:selectByNode': [Record<string, string[]>]
'select-change': []
}>()
const steps = computed(() => props.steps || [])
const resolved = computed(() => props.resolved !== false)
const userOptions = computed(() => props.userOptions || [])
const pickerVisible = ref(false)
const pickerNodeId = ref('')
const pickerNodeType = ref('')
const pickerIds = ref<string[]>([])
const memberPickBuffer = ref('')
const pickerTitle = computed(() =>
pickerNodeType.value === 'cc' ? '选择抄送人' : '选择审批人'
)
function stepAssigneeTypes(s: OaFlowPreviewStep): string[] {
if (Array.isArray(s.assigneeTypes) && s.assigneeTypes.length) return s.assigneeTypes
return s.assigneeType ? [s.assigneeType] : []
}
function isInitiatorSelect(s: OaFlowPreviewStep) {
if (!(s.type === 'approval' || s.type === 'cc')) return false
return stepAssigneeTypes(s).includes('initiator_select')
}
function localPeople(s: OaFlowPreviewStep) {
const ids = (props.selectByNode || {})[s.id] || []
const byId = new Map(userOptions.value.map((u) => [u.id, u]))
return ids.map((id) => {
const u = byId.get(id)
return { id, nickName: u?.nickName || id }
})
}
function displayPeople(s: OaFlowPreviewStep) {
if (!isInitiatorSelect(s)) return s.people || []
const fromLocal = localPeople(s)
if (s.type !== 'cc') return fromLocal
const map = new Map<string, { id: string; nickName: string }>()
for (const p of s.people || []) {
if (p?.id) map.set(p.id, { id: p.id, nickName: p.nickName || p.id })
}
for (const p of fromLocal) map.set(p.id, p)
return [...map.values()]
}
function isLocalSelected(s: OaFlowPreviewStep, userId: string) {
return ((props.selectByNode || {})[s.id] || []).includes(userId)
}
function avatarColor(id: string) {
let hash = 0
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0
return AVATAR_COLORS[hash % AVATAR_COLORS.length]
}
function avatarInitial(name?: string) {
const t = (name || '?').trim()
return t ? t.slice(0, 1) : '?'
}
function resolveUser(id: string) {
return userOptions.value.find((u) => u.id === id)
}
function memberOptionLabel(u: UserOption) {
const name = (u.nickName || '').trim() || '未命名'
const no = (u.userNo || '').trim()
if (no && no !== name) return `${name}(${no})`
return name
}
function memberOptionSub(u: UserOption) {
const name = (u.nickName || '').trim()
const no = (u.userNo || '').trim()
if (!no || no === name) return ''
return `账号 ${no}`
}
function chipTitle(id: string) {
const u = resolveUser(id)
return u ? memberOptionLabel(u) : id
}
function onMemberPick(id: string) {
if (!id) return
if (!pickerIds.value.includes(id)) {
pickerIds.value = [...pickerIds.value, id]
}
nextTick(() => {
memberPickBuffer.value = ''
})
}
function removePickerMember(id: string) {
pickerIds.value = pickerIds.value.filter((x) => x !== id)
}
function updateSelect(nodeId: string, ids: string[]) {
const next: Record<string, string[]> = { ...(props.selectByNode || {}) }
const uniq: string[] = []
const seen = new Set<string>()
for (const x of ids) {
if (!x || seen.has(x)) continue
seen.add(x)
uniq.push(x)
}
if (uniq.length) next[nodeId] = uniq
else delete next[nodeId]
emit('update:selectByNode', next)
emit('select-change')
}
function removeUser(nodeId: string, userId: string) {
const cur = (props.selectByNode || {})[nodeId] || []
updateSelect(
nodeId,
cur.filter((id) => id !== userId)
)
}
function openPicker(s: OaFlowPreviewStep) {
pickerNodeId.value = s.id
pickerNodeType.value = s.type || ''
pickerIds.value = [...((props.selectByNode || {})[s.id] || [])]
memberPickBuffer.value = ''
pickerVisible.value = true
}
function confirmPicker() {
if (pickerNodeId.value) {
updateSelect(pickerNodeId.value, [...pickerIds.value])
}
pickerVisible.value = false
}
function onPickerClosed() {
pickerNodeId.value = ''
pickerNodeType.value = ''
pickerIds.value = []
memberPickBuffer.value = ''
}
function signBadge(s: OaFlowPreviewStep) {
if (s.type === 'cc' || s.signMode === 'cc') return 'cc'
if (s.signMode === 'and') return 'and'
if (s.signMode === 'or') return 'or'
return ''
}
function signBadgeText(s: OaFlowPreviewStep) {
const k = signBadge(s)
if (k === 'cc') return '抄送'
if (k === 'and') return '会签'
if (k === 'or') return '或签'
return ''
}
function avatarChar(name: string) {
const n = (name || '').trim()
return n ? n.slice(0, 1) : '?'
}
</script>
<style scoped lang="scss">
.oa-flow-preview {
margin: 8px 0 12px;
padding: 12px 12px 8px;
background: #f8fafc;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
}
.oa-flow-preview__title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
color: var(--mint-text, #334155);
font-size: 13px;
font-weight: 600;
}
.oa-flow-preview__hint {
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
font-weight: 400;
}
.oa-flow-preview__pending {
padding: 16px 8px 20px;
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.oa-flow-preview__empty {
padding: 8px 0;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
.oa-flow-preview__chain {
min-height: 24px;
}
.oa-flow-step {
display: flex;
gap: 8px;
align-items: stretch;
}
.oa-flow-rail {
display: flex;
flex-direction: column;
align-items: center;
width: 16px;
flex-shrink: 0;
}
.oa-flow-dot {
width: 10px;
height: 10px;
margin-top: 12px;
background: #94a3b8;
border-radius: 50%;
box-shadow: 0 0 0 3px #e2e8f0;
flex-shrink: 0;
&.end {
background: #cbd5e1;
box-shadow: 0 0 0 3px #f1f5f9;
}
}
.oa-flow-line {
width: 2px;
flex: 1;
min-height: 10px;
margin: 4px 0;
background: #e2e8f0;
}
.oa-flow-card {
flex: 1;
min-width: 0;
margin-bottom: 6px;
padding: 9px 12px;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 6px;
}
.oa-flow-card__row {
display: flex;
flex-wrap: wrap;
gap: 6px 8px;
align-items: center;
min-width: 0;
}
.oa-flow-card__name {
flex-shrink: 0;
color: var(--mint-text-strong, #0f172a);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.oa-flow-card__meta {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
color: var(--mint-text-muted, #94a3b8);
font-size: 11px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-flow-card__sep {
flex-shrink: 0;
color: #cbd5e1;
font-size: 11px;
}
.oa-flow-card__err {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
color: #d97706;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-flow-card__select {
display: inline-flex;
flex: 1 1 140px;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
min-width: 0;
margin-left: auto;
}
.oa-flow-card__people {
display: inline-flex;
flex: 1 1 140px;
flex-wrap: wrap;
gap: 4px;
align-items: center;
justify-content: flex-end;
min-width: 0;
margin-left: auto;
}
.oa-flow-card__select .oa-flow-card__people {
flex: 0 1 auto;
margin-left: 0;
}
.oa-flow-card__placeholder {
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
line-height: 1.2;
}
.oa-flow-add-btn {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-left: 0;
padding: 0;
color: var(--mint-text, #334155);
font-size: 14px;
line-height: 1;
cursor: pointer;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
&:hover {
color: var(--primary, #10b981);
border-color: #a7f3d0;
background: var(--primary-bg, #ecfdf5);
}
}
.oa-flow-card__select .oa-flow-add-btn {
margin-left: 0;
}
.oa-person-chip {
display: inline-flex;
gap: 3px;
align-items: center;
padding: 1px 6px 1px 2px;
color: var(--mint-text, #334155);
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
background: #f1f5f9;
border-radius: 999px;
}
.oa-person-chip__dot {
display: grid;
place-items: center;
width: 16px;
height: 16px;
color: #fff;
font-size: 9px;
font-weight: 700;
background: var(--primary, #10b981);
border-radius: 50%;
}
.oa-person-chip__x {
margin: 0;
padding: 0 2px;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
line-height: 1;
cursor: pointer;
background: transparent;
border: none;
&:hover {
color: #dc2626;
}
}
.oa-flow-badge {
flex-shrink: 0;
padding: 0 5px;
font-size: 10px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
border-radius: 4px;
&.or {
color: #d97706;
background: #fff7ed;
}
&.and {
color: #2563eb;
background: #eff6ff;
}
&.cc {
color: #0891b2;
background: #ecfeff;
}
}
.dialog-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
.el-button .el-icon {
margin-right: 6px;
}
}
.member-picker {
width: 100%;
}
.member-option {
display: flex;
gap: 12px;
align-items: center;
width: 100%;
min-height: 40px;
padding: 4px 0;
}
.member-option__meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
justify-content: center;
min-width: 0;
line-height: 1.35;
}
.member-option__name {
overflow: hidden;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.member-option__sub {
overflow: hidden;
color: var(--mint-text-muted, #64748b);
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.member-option__tag {
flex-shrink: 0;
margin-left: auto;
}
.member-avatar {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: #fff;
font-size: 13px;
font-weight: 700;
line-height: 1;
text-align: center;
border-radius: 50%;
&--sm {
width: 28px;
height: 28px;
font-size: 11px;
}
}
.member-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 44px;
margin-top: 12px;
padding: 12px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 8px;
&.is-empty {
align-items: center;
justify-content: center;
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
}
}
.member-chip {
display: inline-flex;
gap: 8px;
align-items: center;
max-width: 100%;
padding: 4px 8px 4px 4px;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 999px;
}
.member-chip__name {
max-width: 120px;
overflow: hidden;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.member-chip__rm {
display: grid;
flex-shrink: 0;
place-items: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--mint-text-muted, #64748b);
font-size: 14px;
line-height: 1;
cursor: pointer;
background: #f1f5f9;
border: 0;
border-radius: 50%;
&:hover {
color: #dc2626;
background: #fee2e2;
}
}
.member-picked-count {
margin: 8px 0 0;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
</style>
<!-- 下拉挂 body,须非 scoped -->
<style lang="scss">
.oa-flow-member-select-popper {
.el-select-dropdown__list {
padding: 8px;
}
.el-select-dropdown__item {
height: auto !important;
min-height: 56px;
margin-bottom: 8px;
padding: 10px 12px !important;
line-height: normal !important;
border-radius: 8px;
&:last-child {
margin-bottom: 0;
}
&.is-disabled {
opacity: 0.55;
}
&.hover,
&:hover {
background: var(--primary-bg, #ecfdf5);
}
}
.member-option {
display: flex;
gap: 12px;
align-items: center;
width: 100%;
min-height: 36px;
}
.member-option__meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
justify-content: center;
min-width: 0;
}
.member-option__name {
overflow: hidden;
color: var(--mint-text, #0f172a);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.member-option__sub {
overflow: hidden;
color: var(--mint-text-muted, #64748b);
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.member-option__tag {
flex-shrink: 0;
margin-left: auto;
}
.member-avatar {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: #fff;
font-size: 13px;
font-weight: 700;
line-height: 1;
text-align: center;
border-radius: 50%;
}
}
</style>