feat(@vben/web-antd): 保洁工单模块增强,拆分组件并新增区域筛选
拆分保洁工单操作和详情扩展为独立组件,新增 config.ts 配置文件; 列表页增加区域筛选功能,详情页优化进度条和日志展示样式。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { sendDeviceMessage } from '#/api/iot/device/device';
|
||||
import { manualCompleteOrder } from '#/api/ops/cleaning';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'CleaningActions' });
|
||||
|
||||
const props = defineProps<{
|
||||
order: OpsOrderCenterApi.OrderDetail;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
async function handleVoiceNotify() {
|
||||
if (!props.order.assigneeId) return;
|
||||
try {
|
||||
await sendDeviceMessage({
|
||||
deviceId: props.order.assigneeId,
|
||||
method: 'voice.broadcast',
|
||||
params: { content: `请注意:${props.order.title}` },
|
||||
});
|
||||
message.success('语音提醒已发送');
|
||||
} catch {
|
||||
message.error('发送失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualComplete() {
|
||||
try {
|
||||
await manualCompleteOrder(props.order.id!);
|
||||
message.success('工单已完成');
|
||||
emit('refresh');
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 已推送状态:语音提醒 -->
|
||||
<Button
|
||||
v-if="order.status === 'DISPATCHED'"
|
||||
type="primary"
|
||||
block
|
||||
class="action-btn"
|
||||
@click="handleVoiceNotify"
|
||||
>
|
||||
<IconifyIcon icon="solar:volume-loud-bold-duotone" class="mr-1" />
|
||||
语音提醒
|
||||
</Button>
|
||||
|
||||
<!-- 作业中状态:手动完成 + 语音提醒 -->
|
||||
<template v-if="order.status === 'ARRIVED'">
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
class="action-btn action-btn-success"
|
||||
@click="handleManualComplete"
|
||||
>
|
||||
<IconifyIcon icon="solar:check-circle-bold-duotone" class="mr-1" />
|
||||
手动完成
|
||||
</Button>
|
||||
<Button block class="action-btn" @click="handleVoiceNotify">
|
||||
<IconifyIcon icon="solar:volume-loud-bold-duotone" class="mr-1" />
|
||||
语音提醒
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,396 @@
|
||||
<script setup lang="ts">
|
||||
import type { OpsCleaningApi } from '#/api/ops/cleaning';
|
||||
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';
|
||||
|
||||
defineOptions({ name: 'CleaningDetailExt' });
|
||||
|
||||
const props = defineProps<{
|
||||
badgeStatus: null | OpsCleaningApi.BadgeRealtimeStatus;
|
||||
order: OpsOrderCenterApi.OrderDetail;
|
||||
}>();
|
||||
|
||||
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 (
|
||||
props.order.status === 'ARRIVED' &&
|
||||
props.badgeStatus &&
|
||||
!props.badgeStatus.isInArea
|
||||
);
|
||||
});
|
||||
|
||||
/** 获取电池颜色 */
|
||||
function getBatteryColor(level: null | number) {
|
||||
if (level === null) return '#d9d9d9';
|
||||
if (level <= 20) return '#f5222d';
|
||||
if (level <= 50) return '#fa8c16';
|
||||
return '#52c41a';
|
||||
}
|
||||
|
||||
/** 获取工牌状态文本 */
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 离岗警告 -->
|
||||
<Alert
|
||||
v-if="showLeaveWarning"
|
||||
type="warning"
|
||||
show-icon
|
||||
class="leave-warning mb-3"
|
||||
>
|
||||
<template #message>
|
||||
<span class="font-medium">保洁员已离开作业区域</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>检测到保洁员不在指定区域,请及时确认情况</span>
|
||||
<Button size="small" type="primary" @click="emit('voiceNotify')">
|
||||
语音提醒
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<!-- 作业进度卡片 -->
|
||||
<Card
|
||||
v-if="extInfo && ['ARRIVED', 'COMPLETED'].includes(order.status)"
|
||||
class="work-progress-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>
|
||||
</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>
|
||||
</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>
|
||||
15
apps/web-antd/src/views/ops/cleaning/work-order/config.ts
Normal file
15
apps/web-antd/src/views/ops/cleaning/work-order/config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { OpsOrderCenterApi } from '#/api/ops/order-center';
|
||||
|
||||
/** 保洁类型选项 */
|
||||
export const CLEANING_TYPE_OPTIONS = [
|
||||
{ label: '日常', value: OpsOrderCenterApi.CleaningType.ROUTINE },
|
||||
{ label: '深度', value: OpsOrderCenterApi.CleaningType.DEEP },
|
||||
{ label: '应急', value: OpsOrderCenterApi.CleaningType.EMERGENCY },
|
||||
];
|
||||
|
||||
/** 保洁类型文本映射 */
|
||||
export const CLEANING_TYPE_TEXT_MAP: Record<string, string> = {
|
||||
ROUTINE: '日常',
|
||||
DEEP: '深度',
|
||||
EMERGENCY: '应急',
|
||||
};
|
||||
@@ -135,6 +135,10 @@ export const TRIGGER_SOURCE_OPTIONS = [
|
||||
{ label: '蓝牙信标', value: OpsOrderCenterApi.TriggerSource.IOT_BEACON },
|
||||
{ label: '客流阈值', value: OpsOrderCenterApi.TriggerSource.PEOPLE_FLOW },
|
||||
{ label: '手动创建', value: OpsOrderCenterApi.TriggerSource.MANUAL },
|
||||
{ label: '视频告警', value: OpsOrderCenterApi.TriggerSource.VIDEO_ALARM },
|
||||
{ label: '门禁告警', value: OpsOrderCenterApi.TriggerSource.ACCESS_ALARM },
|
||||
{ label: '巡更告警', value: OpsOrderCenterApi.TriggerSource.PATROL_ALARM },
|
||||
{ label: '紧急按钮', value: OpsOrderCenterApi.TriggerSource.PANIC_BUTTON },
|
||||
];
|
||||
|
||||
/** 触发来源文本映射 */
|
||||
@@ -144,6 +148,10 @@ export const TRIGGER_SOURCE_TEXT_MAP: Record<string, string> = {
|
||||
TRAFFIC: '客流阈值',
|
||||
PEOPLE_FLOW: '客流阈值',
|
||||
MANUAL: '手动创建',
|
||||
VIDEO_ALARM: '视频告警',
|
||||
ACCESS_ALARM: '门禁告警',
|
||||
PATROL_ALARM: '巡更告警',
|
||||
PANIC_BUTTON: '紧急按钮',
|
||||
};
|
||||
|
||||
/** 保洁类型选项 */
|
||||
@@ -160,6 +168,22 @@ export const CLEANING_TYPE_TEXT_MAP: Record<string, string> = {
|
||||
EMERGENCY: '应急',
|
||||
};
|
||||
|
||||
/** 安保告警类型文本映射 */
|
||||
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',
|
||||
};
|
||||
|
||||
/** 列表表格列定义 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
|
||||
@@ -733,15 +733,17 @@ onUnmounted(stopPolling);
|
||||
|
||||
<!-- 横向进度指示器 -->
|
||||
<div class="progress-steps-wrapper">
|
||||
<div class="progress-line">
|
||||
<div
|
||||
class="progress-line-fill"
|
||||
:style="{
|
||||
width: `${Math.max(0, (currentStepIndex / (visibleSteps.length - 1)) * 100)}%`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="progress-nodes">
|
||||
<!-- 进度线(背景 + 填充) -->
|
||||
<div class="progress-line">
|
||||
<div
|
||||
class="progress-line-fill"
|
||||
:class="{ 'line-all-completed': order.status === 'COMPLETED' }"
|
||||
:style="{
|
||||
width: `${Math.max(0, (currentStepIndex / (visibleSteps.length - 1)) * 100)}%`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(step, index) in visibleSteps"
|
||||
:key="step.key"
|
||||
@@ -749,7 +751,7 @@ onUnmounted(stopPolling);
|
||||
:class="{
|
||||
'node-completed': index < currentStepIndex,
|
||||
'node-current':
|
||||
index === currentStepIndex && order.status !== 'CANCELLED',
|
||||
index === currentStepIndex && !['CANCELLED', 'COMPLETED'].includes(order.status),
|
||||
'node-completed-current':
|
||||
index === currentStepIndex && order.status === 'COMPLETED',
|
||||
'node-pending': index > currentStepIndex,
|
||||
@@ -822,17 +824,19 @@ onUnmounted(stopPolling);
|
||||
</div>
|
||||
<div class="log-simple-content">
|
||||
<div class="log-simple-header">
|
||||
<span
|
||||
class="log-simple-title"
|
||||
:style="{ color: getLogTypeConfig(log.type).color }"
|
||||
>
|
||||
{{ log.title }}
|
||||
<span class="log-simple-title-line">
|
||||
<span
|
||||
class="log-simple-title"
|
||||
:style="{ color: getLogTypeConfig(log.type).color }"
|
||||
>
|
||||
{{ log.title }}
|
||||
</span>
|
||||
<span v-if="log.content" class="log-simple-msg">{{ log.content }}</span>
|
||||
</span>
|
||||
<span class="log-simple-time">{{
|
||||
formatRelativeTime(log.time)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="log-simple-text">{{ log.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1543,14 +1547,22 @@ onUnmounted(stopPolling);
|
||||
/* 横向进度条 */
|
||||
.progress-steps-wrapper {
|
||||
position: relative;
|
||||
padding: 0 20px;
|
||||
padding: 0 72px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-nodes {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 56px;
|
||||
left: 56px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
height: 3px;
|
||||
background: #e8e8e8;
|
||||
border-radius: 2px;
|
||||
@@ -1561,20 +1573,22 @@ onUnmounted(stopPolling);
|
||||
background: linear-gradient(90deg, #52c41a 0%, #1677ff 100%);
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.progress-nodes {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
&.line-all-completed {
|
||||
background: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
width: 0;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
@@ -1792,74 +1806,111 @@ onUnmounted(stopPolling);
|
||||
|
||||
/* ========== 展开日志区域样式 ========== */
|
||||
.logs-expand-section {
|
||||
padding-top: 12px;
|
||||
padding-top: 0;
|
||||
margin-top: 12px;
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
animation: slide-down 0.2s ease;
|
||||
}
|
||||
|
||||
.logs-simple-list {
|
||||
max-height: 240px;
|
||||
padding-left: 2px;
|
||||
max-height: 280px;
|
||||
padding: 4px 4px 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-simple-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: default;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&:last-child .log-simple-line {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.log-simple-left {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 10px;
|
||||
padding-top: 5px;
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
margin: -8px 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-simple-dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
box-shadow: 0 0 0 3px #fff;
|
||||
}
|
||||
|
||||
.log-simple-line {
|
||||
flex: 1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
min-height: 16px;
|
||||
margin: 3px 0;
|
||||
background: #e8e8e8;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.log-simple-item:first-child .log-simple-line {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.log-simple-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 2px 0 10px;
|
||||
}
|
||||
|
||||
.log-simple-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
gap: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.log-simple-title-line {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-simple-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-simple-msg {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-simple-time {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.log-simple-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #595959;
|
||||
color: #bfbfbf;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logs-empty {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { OpsCleaningApi, sendDeviceNotify } from '#/api/ops/cleaning';
|
||||
import { getOrderPage } from '#/api/ops/order-center';
|
||||
|
||||
import AreaFilterDrawer from '../../components/AreaFilterDrawer.vue';
|
||||
import {
|
||||
ORDER_TYPE_COLOR_MAP,
|
||||
ORDER_TYPE_OPTIONS,
|
||||
@@ -44,6 +45,8 @@ const router = useRouter();
|
||||
const viewMode = ref<'card' | 'list'>('card');
|
||||
const activeTab = ref('ALL');
|
||||
const showAdvancedFilter = ref(false);
|
||||
const showAreaFilter = ref(false);
|
||||
const selectedAreaId = ref<number | undefined>(undefined);
|
||||
const cardViewRef = ref();
|
||||
const statsBarRef = ref();
|
||||
|
||||
@@ -80,6 +83,7 @@ const queryParams = ref({
|
||||
priority: undefined as OpsOrderCenterApi.Priority | undefined,
|
||||
orderCode: '',
|
||||
title: '',
|
||||
areaId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
/** 搜索 */
|
||||
@@ -105,7 +109,9 @@ function handleReset() {
|
||||
priority: undefined,
|
||||
orderCode: '',
|
||||
title: '',
|
||||
areaId: undefined,
|
||||
};
|
||||
selectedAreaId.value = undefined;
|
||||
activeTab.value = 'ALL';
|
||||
handleSearch();
|
||||
}
|
||||
@@ -191,6 +197,13 @@ async function handleNotify(
|
||||
}
|
||||
}
|
||||
|
||||
/** 区域筛选确认 */
|
||||
function handleAreaConfirm(id: number | undefined) {
|
||||
selectedAreaId.value = id;
|
||||
queryParams.value.areaId = id;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 处理统计卡片点击 */
|
||||
function handleStatClick(statKey: string) {
|
||||
// 重置筛选条件
|
||||
@@ -200,6 +213,7 @@ function handleStatClick(statKey: string) {
|
||||
priority: undefined,
|
||||
orderCode: '',
|
||||
title: '',
|
||||
areaId: queryParams.value.areaId,
|
||||
};
|
||||
|
||||
switch (statKey) {
|
||||
@@ -280,6 +294,13 @@ onMounted(() => {
|
||||
<UpgradePriorityFormModal @success="handleRefresh" />
|
||||
<CancelFormModal @success="handleRefresh" />
|
||||
|
||||
<!-- 区域筛选抽屉 -->
|
||||
<AreaFilterDrawer
|
||||
v-model:open="showAreaFilter"
|
||||
v-model:model-value="selectedAreaId"
|
||||
@confirm="handleAreaConfirm"
|
||||
/>
|
||||
|
||||
<!-- 快速统计栏 -->
|
||||
<StatsBar ref="statsBarRef" @stat-click="handleStatClick" />
|
||||
|
||||
@@ -341,6 +362,15 @@ onMounted(() => {
|
||||
<IconifyIcon icon="solar:filter-bold" />
|
||||
</Button>
|
||||
|
||||
<!-- 区域筛选按钮 -->
|
||||
<Button
|
||||
class="action-btn"
|
||||
:class="{ 'action-btn--active': selectedAreaId !== undefined }"
|
||||
@click="showAreaFilter = true"
|
||||
>
|
||||
<IconifyIcon icon="solar:map-point-bold" />
|
||||
</Button>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<Button class="action-btn" @click="handleRefresh">
|
||||
<IconifyIcon icon="solar:refresh-bold" />
|
||||
|
||||
@@ -11,19 +11,19 @@ import AreaTree from './AreaTree.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 控制抽屉显示 */
|
||||
open?: boolean;
|
||||
/** 外部已确认的区域 ID */
|
||||
modelValue?: number | undefined;
|
||||
/** 控制抽屉显示 */
|
||||
open?: boolean;
|
||||
}>(),
|
||||
{ open: false, modelValue: undefined },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
'update:modelValue': [id: number | undefined];
|
||||
/** 确认选择后触发 */
|
||||
confirm: [id: number | undefined];
|
||||
'update:modelValue': [id: number | undefined];
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
const areaTreeRef = ref<InstanceType<typeof AreaTree>>();
|
||||
@@ -47,7 +47,10 @@ watch(
|
||||
/** 选中区域的完整路径名称 */
|
||||
const selectedAreaPath = computed(() => {
|
||||
if (!tempSelectedArea.value?.id) return '';
|
||||
return areaTreeRef.value?.getAreaPath(tempSelectedArea.value.id) ?? tempSelectedArea.value.areaName;
|
||||
return (
|
||||
areaTreeRef.value?.getAreaPath(tempSelectedArea.value.id) ??
|
||||
tempSelectedArea.value.areaName
|
||||
);
|
||||
});
|
||||
|
||||
/** 树节点选中 */
|
||||
@@ -83,7 +86,11 @@ function handleClose() {
|
||||
title="区域筛选"
|
||||
placement="right"
|
||||
:width="320"
|
||||
:body-style="{ padding: '12px 16px', display: 'flex', flexDirection: 'column' }"
|
||||
:body-style="{
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}"
|
||||
:header-style="{ padding: '12px 16px' }"
|
||||
:footer-style="{ padding: '10px 16px' }"
|
||||
@close="handleClose"
|
||||
@@ -110,7 +117,11 @@ function handleClose() {
|
||||
<IconifyIcon icon="solar:restart-bold" class="btn-icon" />
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" :disabled="!tempSelectedArea" @click="handleConfirm">
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="!tempSelectedArea"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<IconifyIcon icon="solar:check-circle-bold" class="btn-icon" />
|
||||
确认
|
||||
</Button>
|
||||
@@ -133,8 +144,8 @@ function handleClose() {
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
|
||||
@@ -21,14 +21,19 @@ const props = withDefaults(
|
||||
/** 外部控制选中的 key 列表(单选模式) */
|
||||
selectedKeys?: number[];
|
||||
}>(),
|
||||
{ activeOnly: true, checkable: false, checkedKeys: () => [], selectedKeys: undefined },
|
||||
{
|
||||
activeOnly: true,
|
||||
checkable: false,
|
||||
checkedKeys: () => [],
|
||||
selectedKeys: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 选中区域节点,传出完整节点对象;取消选中时传 null */
|
||||
select: [area: null | OpsAreaApi.BusArea];
|
||||
/** checkable 模式下勾选变化 */
|
||||
check: [checkedKeys: number[], halfCheckedKeys: number[]];
|
||||
/** 选中区域节点,传出完整节点对象;取消选中时传 null */
|
||||
select: [area: null | OpsAreaApi.BusArea];
|
||||
/** 同步 checkedKeys 到父组件 */
|
||||
'update:checkedKeys': [keys: number[]];
|
||||
}>();
|
||||
|
||||
Reference in New Issue
Block a user