Files
zkxw/web-admin/src/components/oa-approval/OaProgressChain.vue
T
2026-08-13 14:31:57 +08:00

557 lines
14 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.
<!-- 审批详情 · 审批进度(视觉对齐 OaFlowPreview / 发起审批弹窗底部) -->
<template>
<div class="oa-flow-preview">
<div class="oa-flow-preview__title">
<span>审批进度</span>
</div>
<div class="oa-flow-preview__chain">
<div v-if="!items.length" class="oa-flow-preview__empty">暂无任务记录</div>
<div v-for="(item, idx) in items" :key="item.key" class="oa-flow-step">
<div class="oa-flow-rail">
<div class="oa-flow-dot" :class="dotClass(item)" />
<div v-if="idx < items.length - 1" class="oa-flow-line" />
</div>
<div class="oa-flow-card">
<div class="oa-flow-card__row">
<span class="oa-flow-card__name">{{ item.title }}</span>
<span v-if="statusBadge(item)" class="oa-flow-badge" :class="statusBadge(item)">
{{ nodeStatusText(item) }}
</span>
<span v-if="signBadge(item)" class="oa-flow-badge" :class="signBadge(item)">
{{ signBadgeText(item) }}
</span>
<span v-else-if="item.taskType === 'cc'" class="oa-flow-badge cc">抄送</span>
<span v-else-if="item.taskType === 'end'" class="oa-flow-badge mute">结束</span>
<template v-if="showNodeTimestamp(item)">
<span class="oa-flow-card__sep">·</span>
<span class="oa-flow-card__meta" :title="item.timestamp">{{ item.timestamp }}</span>
</template>
<template v-if="item.resolveSource && item.kind === 'task'">
<span class="oa-flow-card__sep">·</span>
<span class="oa-flow-card__meta" :title="resolveSourceText(item.resolveSource)">
{{ resolveSourceText(item.resolveSource) }}
</span>
</template>
<span
v-if="displayPeople(item).length && item.kind !== 'terminal'"
class="oa-flow-card__people"
>
<span
v-for="p in displayPeople(item)"
:key="p.id || p.nickName"
class="oa-person-chip"
:class="personChipClass(p.status)"
:title="personTitle(p)"
>
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
{{ p.nickName }}
<span v-if="personStatusHint(p.status)" class="oa-person-chip__st">
{{ personStatusHint(p.status) }}
</span>
</span>
</span>
</div>
<div v-if="personLines(item).length" class="oa-person-lines">
<p
v-for="(line, li) in personLines(item)"
:key="`${item.key}-pl-${li}`"
class="oa-person-line"
:class="{
'is-ok': line.tone === 'ok',
'is-pending': line.tone === 'pending',
'is-danger': line.tone === 'danger',
'is-mute': line.tone === 'mute'
}"
>
{{ line.text }}
</p>
</div>
<div
v-else-if="item.kind === 'terminal' && item.status === 'cancelled' && terminalWithdrawText(item)"
class="oa-person-lines"
>
<p class="oa-person-line is-danger">{{ terminalWithdrawText(item) }}</p>
</div>
<div v-else-if="showNodeComment(item)" class="oa-flow-card__comment">
意见:{{ item.commentText }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
formatProgressPersonLines,
type ProgressPersonLine
} from '@/utils/oa-progress-person-lines'
defineOptions({ name: 'OaProgressChain' })
export type OaProgressPerson = {
id?: string
nickName?: string
status?: string
timestamp?: string
commentText?: string
taskId?: string
transferToUserId?: string
transferToUserName?: string
}
export type OaProgressChainItem = {
key: string
title: string
status: string
timestamp?: string
assignee?: string
resolveSource?: string
commentText?: string
taskType?: string
kind?: string
signMode?: string
people?: OaProgressPerson[]
}
withDefaults(
defineProps<{
items?: OaProgressChainItem[]
}>(),
{
items: () => []
}
)
function statusText(s: string) {
const map: Record<string, string> = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
submitted: '已提交',
not_reached: '未到达',
returned: '已打回',
transferred: '已转交'
}
return map[s] || s
}
/** 任务节点 cancelled=系统清待办 →「无需处理」;终态 cancelled 仍为「已撤销」 */
function nodeStatusText(item: OaProgressChainItem) {
const s = item.status || ''
if (item.kind !== 'terminal' && s === 'cancelled') return '无需处理'
return statusText(s)
}
/** 徽章色:与 Fresh Mint 语义一致 */
function statusBadge(item: OaProgressChainItem) {
const s = item.status || ''
if (item.kind !== 'terminal' && s === 'cancelled') return 'mute'
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'ok'
if (s === 'pending' || s === 'running') return 'or'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
if (s === 'not_reached') return 'mute'
return ''
}
function signBadge(item: OaProgressChainItem) {
if (item.taskType === 'cc' || item.signMode === 'cc') return 'cc'
if (item.signMode === 'and') return 'and'
if (item.signMode === 'or') return 'or'
return ''
}
function signBadgeText(item: OaProgressChainItem) {
const k = signBadge(item)
if (k === 'cc') return '抄送'
if (k === 'and') return '会签'
if (k === 'or') return '或签'
return ''
}
function displayPeople(item: OaProgressChainItem): OaProgressPerson[] {
const list = Array.isArray(item.people) ? item.people.filter((p) => p?.nickName || p?.id) : []
if (list.length) {
return list.map((p) => ({
...p,
nickName: (p.nickName || p.id || '').trim()
}))
}
const name = (item.assignee || '').trim()
if (!name) return []
return [{ nickName: name, status: item.status }]
}
function personLines(item: OaProgressChainItem): ProgressPersonLine[] {
if (item.kind === 'terminal' || item.kind === 'submit') return []
if (item.taskType === 'cc' || item.signMode === 'cc') return []
const list = displayPeople(item)
if (!list.length) return []
return formatProgressPersonLines(list)
}
function showNodeComment(item: OaProgressChainItem): boolean {
// 有按人行时不再展示节点级拼接意见,避免双份
if (personLines(item).length) return false
if (item.kind === 'terminal' && item.status === 'cancelled') return false
return Boolean(item.commentText)
}
function terminalWithdrawText(item: OaProgressChainItem): string {
const name = (item.assignee || '').trim()
const tip = (item.commentText || '').trim() || '发起人撤销'
return name ? `${name} ${tip}` : tip
}
/** 任务节点 cancelled 为系统清待办,不展示时间(时间归终态「已撤销」) */
function showNodeTimestamp(item: OaProgressChainItem): boolean {
const ts = (item.timestamp || '').trim()
if (!ts) return false
if (item.kind !== 'terminal' && item.status === 'cancelled') return false
return true
}
function personChipClass(status?: string) {
const s = status || ''
if (s === 'approved' || s === 'completed') return 'is-ok'
if (s === 'pending' || s === 'running') return 'is-pending'
if (s === 'rejected' || s === 'returned') return 'is-danger'
if (s === 'done' || s === 'not_reached' || s === 'transferred' || s === 'cancelled')
return 'is-mute'
return ''
}
function personStatusHint(status?: string) {
const s = status || ''
if (s === 'approved') return '已同意'
if (s === 'rejected') return '已拒绝'
if (s === 'returned') return '已打回'
if (s === 'pending' || s === 'running') return '待处理'
if (s === 'done' || s === 'cancelled') return '无需处理'
if (s === 'not_reached') return '未到达'
if (s === 'transferred') return '已转交'
return ''
}
function personTitle(p: OaProgressPerson) {
const target = (p.transferToUserName || p.transferToUserId || '').trim()
const statusLabel =
p.status === 'transferred' && target
? `转交给${target}`
: personStatusHint(p.status) || statusText(p.status || '')
// cancelled/done 人员行不展示操作时间
const ts =
p.status === 'cancelled' || p.status === 'done' ? '' : String(p.timestamp || '').trim()
const parts = [p.nickName || '', statusLabel, ts].filter(Boolean)
return parts.join(' · ')
}
function dotClass(item: OaProgressChainItem) {
const s = item.status || ''
if (item.kind !== 'terminal' && s === 'cancelled') return ''
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'ok'
if (s === 'pending' || s === 'running') return 'pending'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
return ''
}
function resolveSourceText(src: string) {
const map: Record<string, string> = {
users: '指定成员',
posts: '职位',
dept_leader: '部门负责人',
direct_supervisor: '直属主管',
supervisor_fallback_dept_leader: '部门负责人(主管回退)',
initiator_select: '发起人自选',
multi: '多种选人方式'
}
return map[src] || src
}
function avatarChar(name: string) {
const n = (name || '').trim()
return n ? n.slice(0, 1) : '?'
}
</script>
<style scoped lang="scss">
.oa-flow-preview {
margin: 0;
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__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;
&.ok {
background: var(--primary, #10b981);
box-shadow: 0 0 0 3px var(--primary-bg, #ecfdf5);
}
&.pending {
background: #d97706;
box-shadow: 0 0 0 3px #fff7ed;
}
&.danger {
background: #ef4444;
box-shadow: 0 0 0 3px #fef2f2;
}
}
.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__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__comment {
margin-top: 6px;
color: var(--mint-text, #334155);
font-size: 12px;
line-height: 1.4;
}
.oa-person-lines {
margin-top: 8px;
display: grid;
gap: 4px;
}
.oa-person-line {
margin: 0;
color: var(--mint-text, #334155);
font-size: 12px;
line-height: 1.55;
word-break: break-word;
&.is-ok {
color: #059669;
font-weight: 500;
}
&.is-pending {
color: #d97706;
}
&.is-danger {
color: #dc2626;
}
&.is-mute {
color: #94a3b8;
}
}
.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;
&.is-ok {
color: #059669;
background: #ecfdf5;
.oa-person-chip__dot {
background: var(--primary, #10b981);
}
}
&.is-pending {
color: #d97706;
background: #fff7ed;
.oa-person-chip__dot {
background: #d97706;
}
}
&.is-danger {
color: #dc2626;
background: #fef2f2;
.oa-person-chip__dot {
background: #ef4444;
}
}
&.is-mute {
color: #94a3b8;
background: #f8fafc;
.oa-person-chip__dot {
background: #94a3b8;
}
}
}
.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__st {
margin-left: 2px;
font-size: 10px;
opacity: 0.85;
}
.oa-flow-badge {
flex-shrink: 0;
padding: 0 5px;
font-size: 10px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
border-radius: 4px;
&.ok {
color: #059669;
background: #ecfdf5;
}
&.or {
color: #d97706;
background: #fff7ed;
}
&.and {
color: #2563eb;
background: #eff6ff;
}
&.danger {
color: #dc2626;
background: #fef2f2;
}
&.mute {
color: #64748b;
background: #f1f5f9;
}
&.cc {
color: #0891b2;
background: #ecfeff;
}
}
</style>