feat(@vben/web-antd): 安保工单详情左图右表布局改造及工单模块代码质量优化

- 安保详情事件信息改为左图右表布局,新增摄像头名称和处理人手机号字段
- 工单卡片展示处理人手机号,时间字段类型兼容时间戳和格式化字符串
- 工单详情页安保扩展区位置下移,安保工单隐藏重复的执行人行
- 登录后默认设置访问租户为登录租户
- 修复 scoped 暗色模式选择器不生效问题,移除 as any 类型断言

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-25 15:43:24 +08:00
parent de95c707a0
commit 506625164c
6 changed files with 221 additions and 84 deletions

View File

@@ -78,16 +78,18 @@ export namespace OpsOrderCenterApi {
alarmId?: string; // 关联告警ID
alarmType?: string; // 告警类型: intrusion/leave_post/fire/fence
cameraId?: string; // 摄像头ID
cameraName?: string; // 摄像头名称
roiId?: string; // ROI区域ID
imageUrl?: string; // 告警截图URL
assignedUserId?: number; // 处理人user_id
assignedUserName?: string; // 处理人姓名
assignedUserPhone?: string; // 处理人手机号
assignedTeamId?: number; // 班组ID
result?: string; // 处理结果描述
resultImgUrls?: string; // 处理结果图片URLJSON数组
dispatchedTime?: string; // 派单时间
confirmedTime?: string; // 确认时间
completedTime?: string; // 完成时间
dispatchedTime?: number | string; // 派单时间number=毫秒时间戳string=格式化时间)
confirmedTime?: number | string; // 确认时间number=毫秒时间戳string=格式化时间)
completedTime?: number | string; // 完成时间number=毫秒时间戳string=格式化时间)
}
/** 工单列表项 */

View File

@@ -70,6 +70,11 @@ export const useAuthStore = defineStore('auth', () => {
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken);
// 登录成功后,如果未设置访问租户,默认使用登录时选择的租户
if (accessStore.tenantId && !accessStore.visitTenantId) {
accessStore.setVisitTenantId(accessStore.tenantId);
}
// 获取用户信息并存储到 userStore、accessStore 中
// TODO @芋艿:清理掉 accessCodes 相关的逻辑
// const [fetchUserInfoResult, accessCodes] = await Promise.all([

View File

@@ -367,6 +367,12 @@ onMounted(() => {
class="info-text assignee-text"
>
{{ item.assigneeName }}
<span
v-if="(item.extInfo as OpsOrderCenterApi.SecurityExtInfo)?.assignedUserPhone"
class="assignee-phone"
>
({{ (item.extInfo as OpsOrderCenterApi.SecurityExtInfo).assignedUserPhone }})
</span>
</span>
<span v-else class="info-text info-text--muted">待分配</span>
</div>
@@ -420,9 +426,9 @@ onMounted(() => {
</Button>
</Tooltip>
<!-- 已推送提醒 -->
<!-- 已推送提醒暂不开放 -->
<Dropdown
v-if="item.status === 'DISPATCHED'"
v-if="false && item.status === 'DISPATCHED'"
placement="topRight"
>
<Button size="small" class="action-btn action-btn--notify">
@@ -682,6 +688,11 @@ onMounted(() => {
color: #1677ff;
}
.assignee-phone {
font-weight: 400;
color: #8c8c8c;
}
.assignee-avatar {
flex-shrink: 0;
font-size: 10px !important;

View File

@@ -6,7 +6,7 @@ import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import { Card, Descriptions, Image, Tag } from 'ant-design-vue';
import { Card, Descriptions, Divider, Image, Tag } from 'ant-design-vue';
import {
SECURITY_ALARM_TYPE_COLOR_MAP,
@@ -35,16 +35,19 @@ const resultImages = computed(() => {
}
});
/** 告警图片列表(单张也包装成数组供 Image.PreviewGroup 使用) */
/** 告警图片列表 */
const alarmImages = computed(() => {
const url = extInfo.value?.imageUrl;
return url ? [url] : [];
});
/** 是否有告警图片 */
const hasAlarmImage = computed(() => alarmImages.value.length > 0);
</script>
<template>
<div v-if="extInfo" class="security-detail-ext">
<!-- 事件信息 + 告警图片 -->
<!-- 事件信息 -->
<Card class="info-card mb-3">
<template #title>
<div class="flex items-center gap-2">
@@ -56,67 +59,94 @@ const alarmImages = computed(() => {
</div>
</template>
<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'
"
>
{{
SECURITY_ALARM_TYPE_MAP[extInfo.alarmType] || extInfo.alarmType
}}
</Tag>
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.alarmId" label="告警ID">
<code class="meta-code">{{ extInfo.alarmId }}</code>
</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">
<!-- 左图右表布局 -->
<div class="event-layout" :class="{ 'has-image': hasAlarmImage }">
<!-- 左侧告警截图 -->
<div v-if="hasAlarmImage" class="event-image">
<div class="alarm-img-wrapper">
<Image.PreviewGroup>
<Image
v-for="(url, idx) in alarmImages"
:key="idx"
:src="url"
:alt="`告警图 ${idx + 1}`"
:width="200"
:height="150"
:style="{
objectFit: 'cover',
borderRadius: '8px',
}"
:alt="`告警图 ${idx + 1}`"
:width="'100%'"
:height="'100%'"
:style="{ objectFit: 'cover' }"
/>
</Image.PreviewGroup>
</div>
</Descriptions.Item>
</Descriptions>
<div class="image-caption">
<IconifyIcon icon="solar:videocamera-bold-duotone" />
{{ extInfo.cameraName || extInfo.cameraId || '监控截图' }}
</div>
</div>
<!-- 右侧信息表格 -->
<div class="event-info">
<Descriptions
:column="2"
bordered
size="small"
class="custom-descriptions"
>
<Descriptions.Item v-if="extInfo.alarmType" label="告警类型">
<Tag
:color="
SECURITY_ALARM_TYPE_COLOR_MAP[extInfo.alarmType] || '#8c8c8c'
"
>
{{
SECURITY_ALARM_TYPE_MAP[extInfo.alarmType] ||
extInfo.alarmType
}}
</Tag>
</Descriptions.Item>
<Descriptions.Item
v-if="extInfo.cameraName || extInfo.cameraId"
label="摄像头"
>
{{ extInfo.cameraName || '' }}
<code v-if="extInfo.cameraId" class="meta-code ml-1">
{{ extInfo.cameraId }}
</code>
</Descriptions.Item>
<Descriptions.Item v-if="extInfo.alarmId" label="告警ID">
<code class="meta-code">{{ extInfo.alarmId }}</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 }}
<span v-if="extInfo.assignedUserPhone" class="phone-text">
({{ extInfo.assignedUserPhone }})
</span>
</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>
</div>
</div>
</Card>
<!-- 处理结果 -->
@@ -131,12 +161,10 @@ const alarmImages = computed(() => {
</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">
@@ -167,12 +195,70 @@ const alarmImages = computed(() => {
</div>
</template>
<style scoped>
<style scoped lang="scss">
.security-detail-ext {
width: 100%;
}
/* 代码标签 */
/* ========== 左图右表布局 ========== */
.event-layout {
display: flex;
gap: 16px;
/* 无图片时表格占满 */
&:not(.has-image) .event-info {
width: 100%;
}
}
/* 左侧告警图片 */
.event-image {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 6px;
width: 280px;
.alarm-img-wrapper {
width: 100%;
height: 190px;
overflow: hidden;
border: 1px solid #f0f0f0;
border-radius: 8px;
:deep(.ant-image) {
display: block;
width: 100%;
height: 100%;
}
:deep(.ant-image-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.image-caption {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
font-size: 12px;
color: #8c8c8c;
}
}
/* 右侧表格 */
.event-info {
flex: 1;
min-width: 0;
}
.phone-text {
color: #8c8c8c;
}
.meta-code {
padding: 1px 6px;
font-size: 12px;
@@ -181,7 +267,7 @@ const alarmImages = computed(() => {
border-radius: 4px;
}
/* 区块标签 */
/* ========== 区块标签 ========== */
.section-label {
display: flex;
align-items: center;
@@ -190,11 +276,7 @@ const alarmImages = computed(() => {
color: rgb(0 0 0 / 65%);
}
:deep(.dark) .section-label {
color: rgb(255 255 255 / 65%);
}
/* 图片画廊 */
/* ========== 图片画廊 ========== */
.image-gallery {
display: flex;
flex-wrap: wrap;
@@ -216,7 +298,7 @@ const alarmImages = computed(() => {
border-radius: 8px;
}
/* 处理结果 */
/* ========== 处理结果 ========== */
.result-content {
font-size: 14px;
line-height: 1.8;
@@ -225,11 +307,7 @@ const alarmImages = computed(() => {
white-space: pre-wrap;
}
:deep(.dark) .result-content {
color: rgb(255 255 255 / 85%);
}
/* 表格样式与基础信息统一 */
/* ========== 表格样式 ========== */
.info-card :deep(.ant-descriptions-item-label) {
font-size: 13px;
font-weight: 500;
@@ -239,4 +317,34 @@ const alarmImages = computed(() => {
.info-card :deep(.ant-descriptions-item-content) {
font-size: 13px;
}
/* ========== 暗色模式 ========== */
:global(html.dark) {
.event-image {
.alarm-img-wrapper {
border-color: #303030;
}
.image-caption {
color: rgb(255 255 255 / 45%);
}
}
.meta-code {
color: #4096ff;
background: rgb(22 119 255 / 10%);
}
.section-label {
color: rgb(255 255 255 / 65%);
}
.result-content {
color: rgb(255 255 255 / 85%);
}
.image-gallery :deep(.ant-image) {
border-color: #303030;
}
}
</style>

View File

@@ -947,9 +947,6 @@ onUnmounted(stopPolling);
</div>
</Card>
<!-- 安保扩展区 -->
<SecurityDetailExt v-if="isSecurityOrder" :order="order" />
<Row :gutter="12" class="info-row">
<!-- 左侧 -->
<Col :span="showWorkProgress ? 17 : 24">
@@ -990,8 +987,8 @@ onUnmounted(stopPolling);
<Descriptions.Item v-if="order.triggerDeviceKey" label="触发设备">
<code class="device-code">{{ order.triggerDeviceKey }}</code>
</Descriptions.Item>
<!-- 执行人 -->
<Descriptions.Item label="执行人">
<!-- 执行人(安保工单在事件信息中已展示,此处不重复) -->
<Descriptions.Item v-if="!isSecurityOrder" label="执行人">
<div v-if="order.assigneeId" class="flex items-center gap-2">
<Avatar :size="24" class="assignee-avatar">
{{ order.assigneeName?.charAt(0) || '?' }}
@@ -1099,6 +1096,9 @@ onUnmounted(stopPolling);
</Col>
</Row>
<!-- 安保扩展区 -->
<SecurityDetailExt v-if="isSecurityOrder" :order="order" />
<!-- 保洁扩展信息 + 工牌信息 -->
<Row v-if="isCleanOrder" :gutter="12">
<Col :span="badgeStatus ? 17 : 24">

View File

@@ -365,6 +365,12 @@ defineExpose({
class="info-text assignee-text"
>
{{ item.assigneeName }}
<span
v-if="(item.extInfo as OpsOrderCenterApi.SecurityExtInfo)?.assignedUserPhone"
class="assignee-phone"
>
({{ (item.extInfo as OpsOrderCenterApi.SecurityExtInfo).assignedUserPhone }})
</span>
</span>
<span v-else class="info-text info-text--muted">待分配</span>
</div>
@@ -418,9 +424,9 @@ defineExpose({
</Button>
</Tooltip>
<!-- 已推送提醒 -->
<!-- 已推送提醒暂不开放 -->
<Dropdown
v-if="item.status === 'DISPATCHED'"
v-if="false && item.status === 'DISPATCHED'"
placement="topRight"
>
<Button size="small" class="action-btn action-btn--notify">
@@ -680,6 +686,11 @@ defineExpose({
color: #1677ff;
}
.assignee-phone {
font-weight: 400;
color: #8c8c8c;
}
.assignee-avatar {
flex-shrink: 0;
font-size: 10px !important;