style(@vben/web-antd): 保洁/安保工单详情组件重构及样式优化

- cleaning-detail-ext: 精简为 Descriptions 表格展示,移除冗余的工牌状态面板和作业进度
- cleaning-actions: 按钮改为 size=small 行内样式,移除 block 布局
- security-detail-ext: 事件信息改为 Descriptions 表格,统一告警截图展示
- AreaTree: 支持 title 插槽透传
- 保洁工单列表移除 PAUSED Tab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-15 16:51:42 +08:00
parent 5fa437d9d0
commit 813e84cff1
5 changed files with 167 additions and 446 deletions

View File

@@ -48,8 +48,7 @@ async function handleManualComplete() {
<Button
v-if="order.status === 'DISPATCHED'"
type="primary"
block
class="action-btn"
size="small"
@click="handleVoiceNotify"
>
<IconifyIcon icon="solar:volume-loud-bold-duotone" class="mr-1" />
@@ -60,14 +59,14 @@ async function handleManualComplete() {
<template v-if="order.status === 'ARRIVED'">
<Button
type="primary"
block
class="action-btn action-btn-success"
size="small"
class="action-btn-success"
@click="handleManualComplete"
>
<IconifyIcon icon="solar:check-circle-bold-duotone" class="mr-1" />
手动完成
完成
</Button>
<Button block class="action-btn" @click="handleVoiceNotify">
<Button size="small" @click="handleVoiceNotify">
<IconifyIcon icon="solar:volume-loud-bold-duotone" class="mr-1" />
语音提醒
</Button>

View File

@@ -5,20 +5,8 @@ import type { OpsOrderCenterApi } from '#/api/ops/order-center';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import {
Alert,
Button,
Card,
Col,
Descriptions,
Progress,
Row,
Tag,
} from 'ant-design-vue';
import { CLEANING_TYPE_TEXT_MAP } from '../config';
import { Alert, Button, Card, Descriptions } from 'ant-design-vue';
defineOptions({ name: 'CleaningDetailExt' });
@@ -31,39 +19,6 @@ const emit = defineEmits<{
voiceNotify: [];
}>();
/** 保洁扩展信息(类型安全) */
const extInfo = computed(() => {
return props.order.extInfo as OpsOrderCenterApi.CleaningExtInfo | undefined;
});
/** 计算作业时长 */
const workDuration = computed(() => {
if (!extInfo.value?.arrivedTime) return 0;
const arrived = new Date(extInfo.value.arrivedTime).getTime();
const endTime = props.order.endTime
? new Date(props.order.endTime).getTime()
: Date.now();
return Math.floor((endTime - arrived) / 60_000);
});
/** 计算作业进度 */
const workProgress = computed(() => {
if (!extInfo.value?.expectedDuration) return 0;
return Math.min(
Math.round((workDuration.value / extInfo.value.expectedDuration) * 100),
100,
);
});
/** 是否超时 */
const isOvertime = computed(() => {
if (props.order.status === 'COMPLETED') return false;
return (
extInfo.value?.expectedDuration &&
workDuration.value > extInfo.value.expectedDuration
);
});
/** 是否显示离岗警告 */
const showLeaveWarning = computed(() => {
return (
@@ -73,48 +28,20 @@ const showLeaveWarning = computed(() => {
);
});
/** 获取电池颜色 */
function getBatteryColor(level: null | number) {
if (level === null) return '#d9d9d9';
if (level <= 20) return '#f5222d';
if (level <= 50) return '#fa8c16';
return '#52c41a';
}
/** 保洁扩展信息 */
const extInfo = computed(() => {
return props.order.extInfo as OpsOrderCenterApi.CleaningExtInfo | undefined;
});
/** 获取工牌状态文本 */
function getBadgeStatusText(status: string) {
const s = status?.toUpperCase();
switch (s) {
case 'BUSY': {
return '作业中';
}
case 'IDLE': {
return '空闲';
}
case 'OFFLINE': {
return '离线';
}
case 'PAUSED': {
return '暂停';
}
default: {
return status || '未知';
}
}
}
function isBusyStatus(status: string) {
return status?.toUpperCase() === 'BUSY';
}
function formatRelativeTime(time: string) {
const now = Date.now();
const target = new Date(time).getTime();
const diff = now - target;
if (diff < 60_000) return '刚刚';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
return formatDateTime(time);
/** 格式化秒数为可读时长 */
function formatDuration(seconds?: number) {
if (!seconds || seconds <= 0) return null;
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
return remainSeconds > 0
? `${minutes}${remainSeconds}`
: `${minutes} 分钟`;
}
</script>
@@ -139,254 +66,96 @@ function formatRelativeTime(time: string) {
</template>
</Alert>
<!-- 作业进度卡片 -->
<Card
v-if="extInfo && ['ARRIVED', 'COMPLETED'].includes(order.status)"
class="work-progress-card mb-3"
>
<!-- 保洁扩展信息卡片 -->
<Card v-if="extInfo" class="info-card mb-3">
<template #title>
<div class="flex items-center gap-2">
<IconifyIcon icon="solar:chart-2-bold-duotone" class="text-blue-500" />
<span>作业进度</span>
<IconifyIcon icon="solar:broom-bold-duotone" class="text-green-500" />
<span>保洁信息</span>
</div>
</template>
<Row :gutter="16" align="middle">
<Col :span="8">
<div class="progress-circle-wrapper">
<Progress
type="circle"
:percent="workProgress"
:stroke-color="
isOvertime
? '#ff4d4f'
: workProgress >= 100
? '#52c41a'
: { '0%': '#1677ff', '100%': '#52c41a' }
"
:stroke-width="6"
:size="100"
>
<template #format>
<div class="progress-inner">
<div
class="progress-value"
:style="{
color: isOvertime
? '#ff4d4f'
: workProgress >= 100
? '#52c41a'
: '#1677ff',
}"
>
{{ workProgress }}%
</div>
<div class="progress-label">完成度</div>
</div>
</template>
</Progress>
</div>
</Col>
<Col :span="16">
<div class="work-stats-grid">
<div class="stat-item">
<div class="stat-icon bg-blue-50 dark:bg-blue-900/30">
<IconifyIcon
icon="solar:clipboard-list-bold-duotone"
class="text-blue-500"
/>
</div>
<div class="stat-content">
<div class="stat-label">作业类型</div>
<div class="stat-value">
{{ CLEANING_TYPE_TEXT_MAP[extInfo.cleaningType!] || '-' }}
</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon bg-green-50 dark:bg-green-900/30">
<IconifyIcon
icon="solar:clock-circle-bold-duotone"
class="text-green-500"
/>
</div>
<div class="stat-content">
<div class="stat-label">预计时长</div>
<div class="stat-value">{{ extInfo.expectedDuration }} 分钟</div>
</div>
</div>
<div class="stat-item">
<div
class="stat-icon"
:class="
isOvertime
? 'bg-red-50 dark:bg-red-900/30'
: 'bg-orange-50 dark:bg-orange-900/30'
"
>
<IconifyIcon
icon="solar:stopwatch-bold-duotone"
:class="isOvertime ? 'text-red-500' : 'text-orange-500'"
/>
</div>
<div class="stat-content">
<div class="stat-label">已用时长</div>
<div class="stat-value" :class="{ 'text-red-500': isOvertime }">
{{ workDuration }} 分钟
<Tag v-if="isOvertime" color="error" size="small" class="ml-1">
超时
</Tag>
</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon bg-purple-50 dark:bg-purple-900/30">
<IconifyIcon
icon="solar:hourglass-line-bold-duotone"
class="text-purple-500"
/>
</div>
<div class="stat-content">
<div class="stat-label">剩余时间</div>
<div class="stat-value" :class="{ 'text-red-500': isOvertime }">
{{
isOvertime
? `已超时 ${workDuration - (extInfo.expectedDuration || 0)} 分钟`
: `${(extInfo.expectedDuration || 0) - workDuration} 分钟`
}}
</div>
</div>
</div>
</div>
</Col>
</Row>
<Descriptions :column="3" bordered size="small" class="custom-descriptions">
<Descriptions.Item v-if="extInfo.isAuto !== undefined" label="创建方式">
<span class="flex items-center gap-1">
<IconifyIcon
icon="solar:rocket-2-bold-duotone"
:class="extInfo.isAuto ? 'text-cyan-400' : 'text-gray-400'"
/>
{{ extInfo.isAuto ? '自动创建' : '手动创建' }}
</span>
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.expectedDuration" label="预计时长">
<span class="flex items-center gap-1">
<IconifyIcon
icon="solar:clock-circle-bold-duotone"
class="text-green-400"
/>
{{ extInfo.expectedDuration }} 分钟
</span>
</Descriptions.Item>
<Descriptions.Item
v-if="formatDuration(extInfo.totalPauseSeconds)"
label="累计暂停"
>
<span class="flex items-center gap-1">
<IconifyIcon
icon="solar:pause-circle-bold-duotone"
class="text-yellow-500"
/>
{{ formatDuration(extInfo.totalPauseSeconds) }}
</span>
</Descriptions.Item>
<Descriptions.Item
v-if="formatDuration(order.responseSeconds)"
label="响应时长"
>
<span class="flex items-center gap-1">
<IconifyIcon
icon="solar:stopwatch-bold-duotone"
class="text-blue-400"
/>
{{ formatDuration(order.responseSeconds) }}
</span>
</Descriptions.Item>
<Descriptions.Item
v-if="formatDuration(order.completionSeconds)"
label="完成耗时"
>
<span class="flex items-center gap-1">
<IconifyIcon
icon="solar:hourglass-line-bold-duotone"
class="text-purple-400"
/>
{{ formatDuration(order.completionSeconds) }}
</span>
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 保洁专有基础信息字段 -->
<template v-if="extInfo?.arrivedTime">
<Descriptions.Item label="到岗时间">
<span class="flex items-center gap-1">
<IconifyIcon
icon="solar:user-rounded-bold-duotone"
class="text-green-400"
/>
{{ formatDateTime(extInfo.arrivedTime) }}
</span>
</Descriptions.Item>
</template>
<template v-if="extInfo?.cleaningType">
<Descriptions.Item label="作业类型">
<span>{{ CLEANING_TYPE_TEXT_MAP[extInfo.cleaningType] }}</span>
</Descriptions.Item>
</template>
<!-- 工牌状态面板(右侧面板内容) -->
<template v-if="badgeStatus">
<div class="badge-status-panel">
<div class="panel-header">
<IconifyIcon
icon="solar:bluetooth-wave-bold-duotone"
class="text-blue-500"
/>
<span>工牌实时状态</span>
<div class="pulse-dot"></div>
</div>
<div class="badge-stats">
<div class="badge-stat-item">
<div class="badge-stat-icon">
<IconifyIcon
icon="solar:map-point-wave-bold-duotone"
:class="
badgeStatus.isInArea ? 'text-green-500' : 'text-orange-500'
"
/>
</div>
<div class="badge-stat-content">
<div class="badge-stat-label">位置状态</div>
<div class="flex items-center gap-2">
<Tag
:color="badgeStatus.isInArea ? 'success' : 'warning'"
size="small"
>
{{ badgeStatus.isInArea ? '在区域内' : '已离开' }}
</Tag>
<span v-if="badgeStatus.areaName" class="text-xs text-gray-500">
{{ badgeStatus.areaName }}
</span>
</div>
</div>
</div>
<div class="badge-stat-item">
<div class="badge-stat-icon">
<IconifyIcon
icon="solar:battery-charge-bold-duotone"
:style="{ color: getBatteryColor(badgeStatus.batteryLevel) }"
/>
</div>
<div class="badge-stat-content">
<div class="badge-stat-label">电池电量</div>
<div v-if="badgeStatus.batteryLevel != null">
<div class="battery-bar">
<div
class="battery-fill"
:style="{
width: `${badgeStatus.batteryLevel}%`,
backgroundColor: getBatteryColor(badgeStatus.batteryLevel),
}"
></div>
</div>
<span
class="battery-text"
:style="{ color: getBatteryColor(badgeStatus.batteryLevel) }"
>
{{ badgeStatus.batteryLevel }}%
</span>
</div>
<span v-else class="text-xs text-gray-400">未知</span>
</div>
</div>
<div class="badge-stat-item">
<div class="badge-stat-icon">
<IconifyIcon
icon="solar:wifi-router-bold-duotone"
class="text-blue-500"
/>
</div>
<div class="badge-stat-content">
<div class="badge-stat-label">信号强度</div>
<div v-if="badgeStatus.rssi != null" class="signal-strength">
<div
v-for="i in 4"
:key="i"
class="signal-bar"
:class="{ active: badgeStatus.rssi > -70 + (i - 1) * 15 }"
></div>
<span class="signal-value">{{ badgeStatus.rssi }} dBm</span>
</div>
<span v-else class="text-xs text-gray-400">未知</span>
</div>
</div>
<div class="badge-stat-item">
<div class="badge-stat-icon">
<IconifyIcon
icon="solar:pulse-2-bold-duotone"
class="text-green-500"
/>
</div>
<div class="badge-stat-content">
<div class="badge-stat-label">最后心跳</div>
<div class="heartbeat-time">
{{ formatRelativeTime(badgeStatus.lastHeartbeatTime) }}
</div>
</div>
</div>
</div>
</div>
<!-- 执行人卡片中工牌状态Tag -->
<Tag
:color="isBusyStatus(badgeStatus.status) ? 'processing' : 'default'"
class="status-badge"
>
{{ getBadgeStatusText(badgeStatus.status) }}
</Tag>
</template>
</template>
<style scoped>
/* 离岗警告 */
.leave-warning {
border-left: 3px solid #faad14;
border-radius: 8px;
}
.leave-warning :deep(.ant-alert-message) {
font-size: 14px;
}
.leave-warning :deep(.ant-alert-description) {
font-size: 13px;
}
/* 表格样式与基础信息统一 */
.info-card :deep(.ant-descriptions-item-label) {
font-size: 13px;
font-weight: 500;
background: #fafafa;
}
.info-card :deep(.ant-descriptions-item-content) {
font-size: 13px;
}
</style>

View File

@@ -55,7 +55,6 @@ const tabCounts = ref<Record<string, number>>({
ALL: 0,
PENDING: 0,
IN_PROGRESS: 0,
PAUSED: 0,
COMPLETED: 0,
CANCELLED: 0,
});

View File

@@ -194,7 +194,11 @@ onMounted(loadTree);
}"
@select="handleSelect"
@check="handleCheck"
/>
>
<template v-if="$slots.title" #title="nodeData">
<slot name="title" v-bind="nodeData" />
</template>
</Tree>
<div v-else-if="!loading" class="py-4 text-center text-gray-500">
暂无区域数据
</div>

View File

@@ -6,7 +6,7 @@ import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import { Card, Divider, Image, Tag } from 'ant-design-vue';
import { Card, Descriptions, Image, Tag } from 'ant-design-vue';
import {
SECURITY_ALARM_TYPE_COLOR_MAP,
@@ -44,22 +44,8 @@ const alarmImages = computed(() => {
<template>
<div v-if="extInfo" class="security-detail-ext">
<!-- 工单描述 -->
<Card v-if="order.description" class="mb-3">
<template #title>
<div class="flex items-center gap-2">
<IconifyIcon
icon="solar:document-text-bold-duotone"
class="text-blue-500"
/>
<span>工单描述</span>
</div>
</template>
<div class="desc-content">{{ order.description }}</div>
</Card>
<!-- 事件信息 + 告警图片 -->
<Card class="mb-3">
<Card class="info-card mb-3">
<template #title>
<div class="flex items-center gap-2">
<IconifyIcon
@@ -70,10 +56,8 @@ const alarmImages = computed(() => {
</div>
</template>
<!-- 基本信息行 -->
<div class="event-meta">
<div v-if="extInfo.alarmType" class="meta-item">
<span class="meta-label">告警类型</span>
<Descriptions :column="3" bordered size="small" class="custom-descriptions">
<Descriptions.Item v-if="extInfo.alarmType" label="告警类型">
<Tag
:color="
SECURITY_ALARM_TYPE_COLOR_MAP[extInfo.alarmType] || '#8c8c8c'
@@ -83,57 +67,47 @@ const alarmImages = computed(() => {
SECURITY_ALARM_TYPE_MAP[extInfo.alarmType] || extInfo.alarmType
}}
</Tag>
</div>
<div v-if="extInfo.cameraId" class="meta-item">
<span class="meta-label">摄像头</span>
<code class="meta-code">{{ extInfo.cameraId }}</code>
</div>
<div v-if="extInfo.alarmId" class="meta-item">
<span class="meta-label">告警ID</span>
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.alarmId" label="告警ID">
<code class="meta-code">{{ extInfo.alarmId }}</code>
</div>
<div v-if="extInfo.assignedUserName" class="meta-item">
<span class="meta-label">处理人</span>
<span>{{ extInfo.assignedUserName }}</span>
</div>
<div v-if="extInfo.dispatchedTime" class="meta-item">
<span class="meta-label">派单时间</span>
<span>{{ formatDateTime(extInfo.dispatchedTime) }}</span>
</div>
<div v-if="extInfo.confirmedTime" class="meta-item">
<span class="meta-label">确认时间</span>
<span>{{ formatDateTime(extInfo.confirmedTime) }}</span>
</div>
<div v-if="extInfo.completedTime" class="meta-item">
<span class="meta-label">完成时间</span>
<span>{{ formatDateTime(extInfo.completedTime) }}</span>
</div>
</div>
<!-- 告警截图 -->
<div v-if="alarmImages.length > 0" class="alarm-images-section">
<Divider class="my-3" />
<div class="section-label mb-2">
<IconifyIcon
icon="solar:camera-bold-duotone"
class="mr-1 text-gray-500"
/>
告警截图
</div>
<div class="image-gallery">
<Image.PreviewGroup>
<Image
v-for="(url, idx) in alarmImages"
:key="idx"
:src="url"
:alt="`告警截图 ${idx + 1}`"
class="gallery-image"
width="100%"
:style="{ maxHeight: '360px', objectFit: 'contain' }"
/>
</Image.PreviewGroup>
</div>
</div>
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.cameraId" label="摄像头">
<code class="meta-code">{{ extInfo.cameraId }}</code>
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.roiId" label="ROI区域">
<code class="meta-code">{{ extInfo.roiId }}</code>
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.assignedUserName" label="处理人">
{{ extInfo.assignedUserName }}
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.dispatchedTime" label="派单时间">
{{ formatDateTime(extInfo.dispatchedTime) }}
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.confirmedTime" label="确认时间">
{{ formatDateTime(extInfo.confirmedTime) }}
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.completedTime" label="完成时间">
{{ formatDateTime(extInfo.completedTime) }}
</Descriptions.Item>
<Descriptions.Item
v-if="alarmImages.length > 0"
label="告警截图"
:span="3"
>
<div class="image-gallery">
<Image.PreviewGroup>
<Image
v-for="(url, idx) in alarmImages"
:key="idx"
:src="url"
:alt="`告警截图 ${idx + 1}`"
class="gallery-image"
:style="{ maxHeight: '360px', objectFit: 'contain' }"
/>
</Image.PreviewGroup>
</div>
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 处理结果 -->
@@ -187,42 +161,7 @@ const alarmImages = computed(() => {
width: 100%;
}
/* 工单描述 */
.desc-content {
font-size: 14px;
line-height: 1.8;
color: rgb(0 0 0 / 85%);
overflow-wrap: break-word;
white-space: pre-wrap;
}
:deep(.dark) .desc-content {
color: rgb(255 255 255 / 85%);
}
/* 事件信息元数据 */
.event-meta {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
}
.meta-item {
display: flex;
gap: 8px;
align-items: center;
font-size: 13px;
}
.meta-label {
color: rgb(0 0 0 / 45%);
white-space: nowrap;
}
:deep(.dark) .meta-label {
color: rgb(255 255 255 / 45%);
}
/* 代码标签 */
.meta-code {
padding: 1px 6px;
font-size: 12px;
@@ -285,4 +224,15 @@ const alarmImages = computed(() => {
:deep(.dark) .result-content {
color: rgb(255 255 255 / 85%);
}
/* 表格样式与基础信息统一 */
.info-card :deep(.ant-descriptions-item-label) {
font-size: 13px;
font-weight: 500;
background: #fafafa;
}
.info-card :deep(.ant-descriptions-item-content) {
font-size: 13px;
}
</style>