557 lines
14 KiB
Vue
557 lines
14 KiB
Vue
<!-- 审批详情 · 审批进度(视觉对齐 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>
|