feat(@vben/web-antd): 新增安保工单模块,重点展示工单描述和处理结果

新增安保工单详情扩展组件和配置文件,详情页以独立卡片形式突出展示
工单描述、告警截图(支持点击预览)、处理结果描述和处理图片。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-13 11:18:47 +08:00
parent ab0c7c53b0
commit 3801a06e98
3 changed files with 320 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
defineOptions({ name: 'SecurityActions' });
defineProps<{
order: OpsOrderCenterApi.OrderDetail;
}>();
// 安保工单暂无专有操作按钮
// 通用操作(派单、升级、取消)由详情页统一处理
// 后续可扩展:出警、联动设备等
</script>
<template>
<div><!-- 安保专有操作预留位 --></div>
</template>

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import { Card, Divider, Image, Tag } from 'ant-design-vue';
import {
SECURITY_ALARM_TYPE_COLOR_MAP,
SECURITY_ALARM_TYPE_MAP,
} from '../config';
defineOptions({ name: 'SecurityDetailExt' });
const props = defineProps<{
order: OpsOrderCenterApi.OrderDetail;
}>();
/** 安保扩展信息(类型安全) */
const extInfo = computed(() => {
return props.order.extInfo as OpsOrderCenterApi.SecurityExtInfo | undefined;
});
/** 解析处理结果图片 */
const resultImages = computed(() => {
const urls = extInfo.value?.resultImgUrls;
if (!urls) return [];
try {
return JSON.parse(urls) as string[];
} catch {
return [];
}
});
/** 告警图片列表(单张也包装成数组供 Image.PreviewGroup 使用) */
const alarmImages = computed(() => {
const url = extInfo.value?.imageUrl;
return url ? [url] : [];
});
</script>
<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">
<template #title>
<div class="flex items-center gap-2">
<IconifyIcon
icon="solar:shield-warning-bold-duotone"
class="text-red-500"
/>
<span>事件信息</span>
</div>
</template>
<!-- 基本信息行 -->
<div class="event-meta">
<div v-if="extInfo.alarmType" class="meta-item">
<span class="meta-label">告警类型</span>
<Tag
:color="
SECURITY_ALARM_TYPE_COLOR_MAP[extInfo.alarmType] || '#8c8c8c'
"
>
{{
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>
<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>
</Card>
<!-- 处理结果 -->
<Card v-if="extInfo.result || resultImages.length > 0" class="mb-3">
<template #title>
<div class="flex items-center gap-2">
<IconifyIcon
icon="solar:check-read-bold-duotone"
class="text-green-500"
/>
<span>处理结果</span>
</div>
</template>
<!-- 结果描述 -->
<div v-if="extInfo.result" class="result-content">
{{ extInfo.result }}
</div>
<!-- 结果图片 -->
<div v-if="resultImages.length > 0" class="result-images-section">
<Divider v-if="extInfo.result" class="my-3" />
<div class="section-label mb-2">
<IconifyIcon
icon="solar:gallery-bold-duotone"
class="mr-1 text-gray-500"
/>
处理图片{{ resultImages.length }}
</div>
<div class="image-gallery image-gallery--grid">
<Image.PreviewGroup>
<Image
v-for="(url, idx) in resultImages"
:key="idx"
:src="url"
:alt="`处理结果图片 ${idx + 1}`"
class="gallery-image-thumb"
:width="160"
:height="120"
:style="{ objectFit: 'cover' }"
/>
</Image.PreviewGroup>
</div>
</div>
</Card>
</div>
</template>
<style scoped>
.security-detail-ext {
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;
color: #1677ff;
background: #f0f5ff;
border-radius: 4px;
}
/* 区块标签 */
.section-label {
display: flex;
align-items: center;
font-size: 13px;
font-weight: 500;
color: rgb(0 0 0 / 65%);
}
:deep(.dark) .section-label {
color: rgb(255 255 255 / 65%);
}
/* 图片画廊 */
.image-gallery {
overflow: hidden;
border-radius: 8px;
}
.image-gallery--grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.gallery-image {
overflow: hidden;
border-radius: 8px;
}
.gallery-image-thumb {
overflow: hidden;
cursor: pointer;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: transform 0.2s;
}
.gallery-image-thumb:hover {
transform: scale(1.03);
}
/* 处理结果 */
.result-content {
font-size: 14px;
line-height: 1.8;
color: rgb(0 0 0 / 85%);
overflow-wrap: break-word;
white-space: pre-wrap;
}
:deep(.dark) .result-content {
color: rgb(255 255 255 / 85%);
}
</style>

View File

@@ -0,0 +1,15 @@
/** 安保告警类型文本映射 */
export const SECURITY_ALARM_TYPE_MAP: Record<string, string> = {
intrusion: '入侵检测',
leave_post: '离岗检测',
fire: '火焰检测',
fence: '电子围栏',
};
/** 安保告警类型颜色映射 */
export const SECURITY_ALARM_TYPE_COLOR_MAP: Record<string, string> = {
intrusion: '#f5222d',
leave_post: '#fa8c16',
fire: '#ff4d4f',
fence: '#faad14',
};