feat(@vben/web-antd): 工单详情取消状态进度条改造及代码质量优化

- 取消状态下进度条展示 timeline 实际节点,末尾追加"已取消"终态
- 已走过节点灰色实心,取消终态节点深灰,进度线灰色
- 移除底部红色 cancelled-banner,改为进度条内联展示
- 进行中节点蓝色标签改为显示到达时间
- 提取 isCurrentActive / getStepTime 简化模板逻辑
- timeline 去重防御异常数据,取消终态改用索引判断
- 移除 progress-steps-wrapper 的 overflow:hidden 避免裁切光晕
- 清理无引用的 cancelled-banner CSS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-25 11:36:19 +08:00
parent d0395ba40a
commit 0772a12074

View File

@@ -343,8 +343,51 @@ const orderImages = computed(() => {
return images;
});
/** 是否已取消 */
const isCancelled = computed(() => order.value.status === 'CANCELLED');
/** 是否为当前进行中(非终态) */
const isCurrentActive = computed(
() =>
!isCancelled.value &&
!['COMPLETED', 'CANCELLED'].includes(order.value.status),
);
/** 根据 step.key 查找 timeline 中对应的时间 */
function getStepTime(key: string): string {
const t = timeline.value.find((item) => item.status === key);
if (t?.time) return formatRelativeTime(t.time);
if (key === 'PENDING') return formatRelativeTime(order.value.createTime);
if (key === 'COMPLETED' && order.value.endTime)
return formatRelativeTime(order.value.endTime);
return '';
}
/** 动态生成应该显示的状态步骤(根据 timeline 过滤) */
const visibleSteps = computed(() => {
// 取消状态:只展示 timeline 中已发生的节点(按时间顺序,去重)
if (isCancelled.value) {
const seen = new Set<string>();
return timeline.value
.filter((t) => {
if (seen.has(t.status)) return false;
seen.add(t.status);
return true;
})
.map((t) => {
const step = STATUS_STEPS.find((s) => s.key === t.status);
return {
key: t.status,
title:
t.status === 'CANCELLED'
? '已取消'
: (step?.title || t.statusName || t.status),
icon: step?.icon || 'solar:close-circle-bold-duotone',
desc: t.description || step?.desc || '',
};
});
}
// 必须显示的节点(主流程)
const requiredSteps = new Set([
'ARRIVED',
@@ -377,7 +420,10 @@ const visibleSteps = computed(() => {
/** 计算当前状态步骤索引 */
const currentStepIndex = computed(() => {
if (order.value.status === 'CANCELLED') return -1;
// 取消状态:所有节点都视为"已走过",索引指向最后一个
if (isCancelled.value) {
return visibleSteps.value.length - 1;
}
if (order.value.status === 'PAUSED') {
// 暂停状态显示在到岗后
return visibleSteps.value.findIndex((s) => s.key === 'ARRIVED');
@@ -625,10 +671,15 @@ onUnmounted(stopPolling);
<div class="flex items-center gap-2">
<span class="text-base font-semibold">{{ order.title }}</span>
<!-- 工单类型标签 -->
<Tag
v-if="order.orderType"
:color="ORDER_TYPE_COLOR_MAP[order.orderType]"
class="status-tag"
<span
v-if="
order.orderType && ORDER_TYPE_COLOR_MAP[order.orderType]
"
class="status-tag inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:style="{
backgroundColor: ORDER_TYPE_COLOR_MAP[order.orderType]?.bg,
color: ORDER_TYPE_COLOR_MAP[order.orderType]?.text,
}"
>
<IconifyIcon
:icon="
@@ -641,18 +692,25 @@ onUnmounted(stopPolling);
class="mr-1"
/>
{{ ORDER_TYPE_TEXT_MAP[order.orderType] }}
</Tag>
</span>
<!-- 状态标签 -->
<Tag :color="STATUS_COLOR_MAP[order.status]" class="status-tag">
<span
v-if="STATUS_COLOR_MAP[order.status]"
class="status-tag inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:style="{
backgroundColor: STATUS_COLOR_MAP[order.status]?.bg,
color: STATUS_COLOR_MAP[order.status]?.text,
}"
>
<IconifyIcon
:icon="STATUS_ICON_MAP[order.status] || 'solar:circle-bold'"
class="mr-1"
/>
{{ STATUS_TEXT_MAP[order.status] }}
</Tag>
</span>
<!-- 优先级标签 -->
<span
v-if="order.priority != null"
v-if="order.priority !== null && order.priority !== undefined"
class="priority-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
:class="{
'animate-pulse': Number(order.priority) === 0,
@@ -789,7 +847,10 @@ onUnmounted(stopPolling);
<div class="progress-line">
<div
class="progress-line-fill"
:class="{ 'line-all-completed': order.status === 'COMPLETED' }"
:class="{
'line-all-completed': order.status === 'COMPLETED',
'line-cancelled': isCancelled,
}"
:style="{
width: `${Math.max(0, (currentStepIndex / (visibleSteps.length - 1)) * 100)}%`,
}"
@@ -800,14 +861,20 @@ onUnmounted(stopPolling);
:key="step.key"
class="progress-node"
:class="{
'node-completed': index < currentStepIndex,
'node-completed':
!isCancelled && index < currentStepIndex,
'node-current':
index === currentStepIndex &&
!['CANCELLED', 'COMPLETED'].includes(order.status),
isCurrentActive && index === currentStepIndex,
'node-completed-current':
index === currentStepIndex && order.status === 'COMPLETED',
'node-pending': index > currentStepIndex,
'node-cancelled': order.status === 'CANCELLED',
!isCancelled &&
index === currentStepIndex &&
order.status === 'COMPLETED',
'node-pending':
!isCancelled && index > currentStepIndex,
'node-cancelled-done':
isCancelled && index < currentStepIndex,
'node-cancelled-end':
isCancelled && index === currentStepIndex,
}"
>
<Tooltip :title="step.desc">
@@ -816,46 +883,28 @@ onUnmounted(stopPolling);
</div>
</Tooltip>
<div class="node-label">{{ step.title }}</div>
<!-- 已到达节点的时间(进行中节点除外,由 badge 显示) -->
<div
v-if="
index < currentStepIndex ||
(index === currentStepIndex && order.status === 'COMPLETED')
index <= currentStepIndex &&
!(isCurrentActive && index === currentStepIndex)
"
class="node-time"
>
{{
timeline.find((t) => t.status === step.key)?.time
? formatRelativeTime(
timeline.find((t) => t.status === step.key)?.time || '',
)
: step.key === 'PENDING'
? formatRelativeTime(order.createTime)
: step.key === 'COMPLETED' && order.endTime
? formatRelativeTime(order.endTime)
: ''
}}
{{ getStepTime(step.key) }}
</div>
<!-- 当前进行中节点:蓝色标签显示时间 -->
<div
v-else-if="
index === currentStepIndex &&
order.status !== 'CANCELLED' &&
order.status !== 'COMPLETED'
"
v-if="isCurrentActive && index === currentStepIndex"
class="node-badge"
>
<span class="badge-dot"></span>
进行中
{{ getStepTime(step.key) }}
</div>
</div>
</div>
</div>
<!-- 已取消状态提示 -->
<div v-if="order.status === 'CANCELLED'" class="cancelled-banner">
<IconifyIcon icon="solar:close-circle-bold-duotone" />
<span>工单已取消</span>
</div>
<!-- 展开的业务日志列表 -->
<div v-if="showLogs" class="logs-expand-section">
<div v-if="businessLogs.length > 0" class="logs-simple-list">
@@ -1318,7 +1367,6 @@ onUnmounted(stopPolling);
.progress-steps-wrapper {
position: relative;
padding: 0 72px;
overflow: hidden;
}
.progress-nodes {
@@ -1347,6 +1395,10 @@ onUnmounted(stopPolling);
&.line-all-completed {
background: #52c41a;
}
&.line-cancelled {
background: #d9d9d9;
}
}
.progress-node {
@@ -1403,10 +1455,37 @@ onUnmounted(stopPolling);
background: #fafafa;
}
.node-cancelled .node-icon {
/* 取消流程 - 已走过的节点(灰色实心) */
.node-cancelled-done .node-icon {
color: #fff;
background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%);
border-color: #ff4d4f;
background: #bfbfbf;
border-color: #bfbfbf;
box-shadow: none;
}
.node-cancelled-done .node-label {
color: #8c8c8c;
}
.node-cancelled-done .node-time {
color: #bfbfbf;
}
/* 取消流程 - 最终取消节点(红灰色调) */
.node-cancelled-end .node-icon {
color: #fff;
background: #8c8c8c;
border-color: #8c8c8c;
box-shadow: none;
}
.node-cancelled-end .node-label {
font-weight: 600;
color: #8c8c8c;
}
.node-cancelled-end .node-time {
color: #bfbfbf;
}
.node-label {
@@ -1461,21 +1540,6 @@ onUnmounted(stopPolling);
animation: pulse 1.5s infinite;
}
.cancelled-banner {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 10px;
margin-top: 16px;
font-size: 13px;
font-weight: 500;
color: #ff4d4f;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 6px;
}
/* ========== 基础信息行:左右等高 ========== */
.info-row :deep(> .ant-col) {
display: flex;
@@ -1811,10 +1875,6 @@ onUnmounted(stopPolling);
color: rgb(255 255 255 / 75%);
}
.cancelled-banner {
background: rgb(255 77 79 / 10%);
border-color: rgb(255 77 79 / 30%);
}
}
/* ========== 通用卡片样式 ========== */