Files
zkxw/web-admin/src/components/oa-approval/OaProgressChain.vue
T
2026-07-21 15:23:59 +08:00

448 lines
11 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)">
{{ statusText(item.status) }}
</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="item.timestamp">
<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="item.commentText" class="oa-flow-card__comment">
意见:{{ item.commentText }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'OaProgressChain' })
export type OaProgressPerson = {
id?: string
nickName?: string
status?: string
timestamp?: string
commentText?: string
taskId?: 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: '已打回'
}
return map[s] || s
}
/** 徽章色:与 Fresh Mint 语义一致 */
function statusBadge(item: OaProgressChainItem) {
const s = item.status || ''
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 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' || s === 'cancelled') return 'is-danger'
if (s === 'done' || s === 'not_reached') 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') return '无需处理'
if (s === 'not_reached') return '未到达'
return ''
}
function personTitle(p: OaProgressPerson) {
const parts = [p.nickName || '', statusText(p.status || ''), p.timestamp || ''].filter(Boolean)
return parts.join(' · ')
}
function dotClass(item: OaProgressChainItem) {
const s = item.status || ''
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: '指定成员',
oa_roles: '工作流角色',
dept_leader: '部门负责人',
direct_supervisor: '直属主管',
supervisor_fallback_dept_leader: '部门负责人(主管回退)',
initiator_select: '发起人自选'
}
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-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>