feat(@vben/web-antd): 安保工单告警图片优化及专用派单表单

- 告警图片改为缩略图展示(200x150),添加圆角和悬停放大效果
- 新增安保专用派单表单,基于分页用户列表选择执行人
- 工单中心列表页和详情页均根据工单类型路由到对应派单表单
- 搜索防抖300ms,组件卸载时清理定时器防止内存泄漏

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-18 15:21:29 +08:00
parent 5e053c6366
commit ed50dc3f7e
4 changed files with 405 additions and 27 deletions

View File

@@ -96,7 +96,7 @@ const alarmImages = computed(() => {
</Descriptions.Item>
<Descriptions.Item
v-if="alarmImages.length > 0"
label="告警图"
label="告警图"
:span="3"
>
<div class="image-gallery">
@@ -105,9 +105,13 @@ const alarmImages = computed(() => {
v-for="(url, idx) in alarmImages"
:key="idx"
:src="url"
:alt="`告警图 ${idx + 1}`"
class="gallery-image"
:style="{ maxHeight: '360px', objectFit: 'contain' }"
:alt="`告警图 ${idx + 1}`"
:width="200"
:height="150"
:style="{
objectFit: 'cover',
borderRadius: '8px',
}"
/>
</Image.PreviewGroup>
</div>
@@ -142,17 +146,19 @@ const alarmImages = computed(() => {
/>
处理图片{{ resultImages.length }}
</div>
<div class="image-gallery image-gallery--grid">
<div class="image-gallery">
<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' }"
:style="{
objectFit: 'cover',
borderRadius: '8px',
}"
/>
</Image.PreviewGroup>
</div>
@@ -190,33 +196,26 @@ const alarmImages = computed(() => {
/* 图片画廊 */
.image-gallery {
overflow: hidden;
border-radius: 8px;
}
.image-gallery--grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.gallery-image {
.image-gallery :deep(.ant-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 {
.image-gallery :deep(.ant-image:hover) {
transform: scale(1.03);
}
.image-gallery :deep(.ant-image-img) {
border-radius: 8px;
}
/* 处理结果 */
.result-content {
font-size: 14px;

View File

@@ -55,6 +55,7 @@ import {
} from '../data';
import AssignForm from '../modules/assign-form.vue';
import CancelForm from '../modules/cancel-form.vue';
import SecurityAssignForm from '../modules/security-assign-form.vue';
import UpgradePriorityForm from '../modules/upgrade-priority-form.vue';
defineOptions({ name: 'WorkOrderDetail' });
@@ -286,6 +287,10 @@ const [AssignFormModal, assignFormModalApi] = useVbenModal({
connectedComponent: AssignForm,
destroyOnClose: true,
});
const [SecurityAssignFormModal, securityAssignFormModalApi] = useVbenModal({
connectedComponent: SecurityAssignForm,
destroyOnClose: true,
});
const [UpgradePriorityFormModal, upgradePriorityFormModalApi] = useVbenModal({
connectedComponent: UpgradePriorityForm,
destroyOnClose: true,
@@ -317,7 +322,7 @@ const orderImages = computed(() => {
const ext = order.value.extInfo;
if (!ext) return [];
const images: string[] = [];
// 安保告警图已在事件信息卡片中展示,此处不再重复
// 安保告警图已在事件信息卡片中展示,此处不再重复
if (
!isSecurityOrder.value &&
(ext as OpsOrderCenterApi.SecurityExtInfo).imageUrl
@@ -489,9 +494,23 @@ async function handleBack() {
}, 100);
}
function handleAssign() {
assignFormModalApi
.setData({ orderId: order.value.id, orderCode: order.value.orderCode })
.open();
if (isSecurityOrder.value) {
securityAssignFormModalApi
.setData({
orderId: order.value.id,
orderCode: order.value.orderCode,
location: order.value.location,
description: order.value.description,
})
.open();
} else {
assignFormModalApi
.setData({
orderId: order.value.id,
orderCode: order.value.orderCode,
})
.open();
}
}
function handleUpgrade() {
upgradePriorityFormModalApi
@@ -588,6 +607,7 @@ onUnmounted(stopPolling);
<template>
<Page>
<AssignFormModal @success="handleRefresh" />
<SecurityAssignFormModal @success="handleRefresh" />
<UpgradePriorityFormModal @success="handleRefresh" />
<CancelFormModal @success="handleRefresh" />

View File

@@ -36,6 +36,7 @@ import {
import AssignForm from './modules/assign-form.vue';
import CancelForm from './modules/cancel-form.vue';
import CardView from './modules/card-view.vue';
import SecurityAssignForm from './modules/security-assign-form.vue';
import StatsBar from './modules/stats-bar.vue';
import UpgradePriorityForm from './modules/upgrade-priority-form.vue';
@@ -64,6 +65,10 @@ const [AssignFormModal, assignFormModalApi] = useVbenModal({
connectedComponent: AssignForm,
destroyOnClose: true,
});
const [SecurityAssignFormModal, securityAssignFormModalApi] = useVbenModal({
connectedComponent: SecurityAssignForm,
destroyOnClose: true,
});
const [UpgradePriorityFormModal, upgradePriorityFormModalApi] = useVbenModal({
connectedComponent: UpgradePriorityForm,
@@ -143,9 +148,20 @@ function handleDetail(id: number) {
/** 打开派单表单 */
function handleAssign(row: OpsOrderCenterApi.OrderItem) {
assignFormModalApi
.setData({ orderId: row.id, orderCode: row.orderCode })
.open();
if (row.orderType === 'SECURITY') {
securityAssignFormModalApi
.setData({
orderId: row.id,
orderCode: row.orderCode,
location: row.location,
description: row.title,
})
.open();
} else {
assignFormModalApi
.setData({ orderId: row.id, orderCode: row.orderCode })
.open();
}
}
/** 升级优先级 */
@@ -300,6 +316,7 @@ onActivated(() => {
<Page auto-content-height>
<!-- 模态框 -->
<AssignFormModal @success="handleRefresh" />
<SecurityAssignFormModal @success="handleRefresh" />
<UpgradePriorityFormModal @success="handleRefresh" />
<CancelFormModal @success="handleRefresh" />

View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
import type { SystemUserApi } from '#/api/system/user';
import { onUnmounted, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Avatar, Empty, Input, message, Pagination, Spin } from 'ant-design-vue';
import { assignOrder } from '#/api/ops/order-center';
import { getUserPage } from '#/api/system/user';
defineOptions({ name: 'SecurityAssignForm' });
const emit = defineEmits<{ success: [] }>();
interface ModalData {
orderId: number;
orderCode: string;
location?: string;
description?: string;
}
const modalData = ref<ModalData>({
orderId: 0,
orderCode: '',
});
const loading = ref(false);
const userLoading = ref(false);
const userList = ref<SystemUserApi.User[]>([]);
const total = ref(0);
const currentPage = ref(1);
const pageSize = ref(20);
const selectedUserId = ref<number>();
const searchValue = ref('');
const remark = ref('');
const [Modal, modalApi] = useVbenModal({
onOpenChange: async (isOpen) => {
if (isOpen) {
const data = modalApi.getData<ModalData>();
if (data) {
modalData.value = data;
}
selectedUserId.value = undefined;
searchValue.value = '';
remark.value = '';
currentPage.value = 1;
await loadUsers();
}
},
onConfirm: handleSubmit,
});
/** 加载用户分页 */
async function loadUsers() {
userLoading.value = true;
try {
const res = await getUserPage({
pageNo: currentPage.value,
pageSize: pageSize.value,
nickname: searchValue.value.trim() || undefined,
});
userList.value = res?.list || [];
total.value = res?.total || 0;
} catch {
userList.value = [];
total.value = 0;
} finally {
userLoading.value = false;
}
}
/** 搜索防抖 */
let searchTimer: ReturnType<typeof setTimeout>;
watch(searchValue, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
currentPage.value = 1;
loadUsers();
}, 300);
});
/** 翻页 */
function handlePageChange(page: number) {
currentPage.value = page;
loadUsers();
}
/** 选择用户 */
function selectUser(user: SystemUserApi.User) {
selectedUserId.value = user.id;
}
onUnmounted(() => {
clearTimeout(searchTimer);
});
/** 提交 */
async function handleSubmit() {
if (!selectedUserId.value) {
message.warning('请选择执行人');
return;
}
loading.value = true;
modalApi.setState({ confirmLoading: true });
try {
await assignOrder({
orderId: modalData.value.orderId,
assigneeId: selectedUserId.value,
remark: remark.value.trim() || undefined,
});
message.success('派单成功');
modalApi.close();
emit('success');
} finally {
loading.value = false;
modalApi.setState({ confirmLoading: false });
}
}
</script>
<template>
<Modal title="分配安保执行人" class="w-[580px]">
<!-- 工单信息 -->
<div class="sa-order-info">
<div class="sa-info-row">
<IconifyIcon icon="solar:document-text-bold-duotone" class="sa-info-icon" />
<span class="sa-info-label">工单编号</span>
<span class="sa-info-value">{{ modalData.orderCode }}</span>
</div>
<div v-if="modalData.location" class="sa-info-row">
<IconifyIcon icon="solar:map-point-bold-duotone" class="sa-info-icon" />
<span class="sa-info-label">位置</span>
<span class="sa-info-value">{{ modalData.location }}</span>
</div>
<div v-if="modalData.description" class="sa-info-row">
<IconifyIcon icon="solar:notes-bold-duotone" class="sa-info-icon" />
<span class="sa-info-label">描述</span>
<span class="sa-info-value sa-info-desc">{{ modalData.description }}</span>
</div>
</div>
<!-- 人员选择 -->
<div class="sa-section">
<div class="sa-section-title">选择执行人</div>
<Input
v-model:value="searchValue"
placeholder="搜索人员姓名"
allow-clear
class="mb-2"
>
<template #prefix>
<IconifyIcon icon="lucide:search" class="size-4 text-gray-400" />
</template>
</Input>
<Spin :spinning="userLoading">
<div v-if="userList.length > 0" class="sa-list">
<div
v-for="user in userList"
:key="user.id"
class="sa-item"
:class="{ 'sa-item--active': selectedUserId === user.id }"
@click="selectUser(user)"
>
<Avatar :size="28" class="sa-avatar">
{{ user.nickname?.charAt(0) || '?' }}
</Avatar>
<span class="sa-name">{{ user.nickname }}</span>
<IconifyIcon
v-if="selectedUserId === user.id"
icon="lucide:check"
class="sa-check"
/>
</div>
</div>
<Empty
v-else-if="!userLoading"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无匹配人员"
/>
</Spin>
<div v-if="total > pageSize" class="sa-pagination">
<Pagination
:current="currentPage"
:page-size="pageSize"
:total="total"
size="small"
simple
@change="handlePageChange"
/>
</div>
</div>
<!-- 派单备注 -->
<div class="sa-section">
<div class="sa-section-title">派单备注</div>
<Input.TextArea
v-model:value="remark"
placeholder="请输入派单备注(选填)"
:rows="3"
/>
</div>
</Modal>
</template>
<style scoped>
.sa-order-info {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: #fafafa;
border-radius: 8px;
}
.sa-info-row {
display: flex;
gap: 6px;
align-items: flex-start;
font-size: 13px;
line-height: 20px;
}
.sa-info-icon {
flex-shrink: 0;
margin-top: 2px;
color: #8c8c8c;
}
.sa-info-label {
flex-shrink: 0;
color: #8c8c8c;
}
.sa-info-value {
flex: 1;
min-width: 0;
color: #333;
word-break: break-all;
}
.sa-info-desc {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sa-section {
margin-bottom: 12px;
}
.sa-section-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
}
.sa-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
max-height: 240px;
overflow-y: auto;
}
.sa-item {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 10px;
cursor: pointer;
border: 1px solid transparent;
border-radius: 6px;
transition: all 0.15s;
}
.sa-item:hover {
background: #f5f5f5;
}
.sa-item--active {
font-weight: 600;
color: #1677ff;
background: #e6f4ff;
border-color: #91caff;
}
.sa-avatar {
flex-shrink: 0;
font-size: 12px;
background: #1677ff;
}
.sa-item--active .sa-avatar {
background: #0958d9;
}
.sa-name {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.sa-check {
flex-shrink: 0;
color: #1677ff;
}
.sa-pagination {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
/* dark mode */
html.dark .sa-order-info {
background: rgb(255 255 255 / 6%);
}
html.dark .sa-info-value {
color: rgb(255 255 255 / 85%);
}
html.dark .sa-item:hover {
background: rgb(255 255 255 / 8%);
}
html.dark .sa-item--active {
color: #69b1ff;
background: rgb(22 119 255 / 15%);
border-color: rgb(22 119 255 / 40%);
}
</style>