feat(@vben/web-antd): 完善保洁工单管理模块功能

- 工单看板:优化数据展示和交互逻辑
- 工单列表:增强筛选和分页功能
- 分配表单:改进表单验证和用户体验
- 卡片视图:优化布局和视觉效果
- 数据配置:更新常量定义和类型

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-02-03 21:37:05 +08:00
parent 8a048a423a
commit 38a6eaa39e
5 changed files with 270 additions and 122 deletions

View File

@@ -13,7 +13,7 @@ import { getQuickStats } from '#/api/ops/order-center';
defineOptions({ name: 'CleaningWorkOrderDashboard' });
// ========== 模拟数据开关 ==========
const USE_MOCK_DATA = true;
const USE_MOCK_DATA = false;
// ========== 数据类型定义 ==========
interface DashboardStats {
@@ -22,8 +22,8 @@ interface DashboardStats {
inProgressCount: number;
completedTodayCount: number;
completedTotalCount: number;
onlineCleanerCount: number;
totalCleanerCount: number;
onlineBadgeCount: number;
totalBadgeCount: number;
// 趋势数据7天
trendData: {
@@ -99,8 +99,8 @@ const statsData = ref<DashboardStats>({
inProgressCount: 0,
completedTodayCount: 0,
completedTotalCount: 0,
onlineCleanerCount: 0,
totalCleanerCount: 0,
onlineBadgeCount: 0,
totalBadgeCount: 0,
trendData: {
dates: [],
createdData: [],
@@ -131,8 +131,8 @@ const MOCK_STATS: DashboardStats = {
inProgressCount: 15,
completedTodayCount: 42,
completedTotalCount: 1586,
onlineCleanerCount: 12,
totalCleanerCount: 18,
onlineBadgeCount: 12,
totalBadgeCount: 18,
trendData: {
dates: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
createdData: [45, 52, 38, 65, 48, 55, 42],
@@ -914,7 +914,7 @@ async function loadStats() {
pendingCount: quickStats.pendingCount,
inProgressCount: quickStats.inProgressCount,
completedTodayCount: quickStats.completedTodayCount,
onlineCleanerCount: quickStats.onlineCleanerCount,
onlineBadgeCount: quickStats.onlineBadgeCount,
};
}

View File

@@ -82,7 +82,7 @@ export const PRIORITY_STYLE_MAP: Record<
label: 'P2',
color: '#9E9E9E',
bgColor: '#FAFAFA',
icon: '',
icon: 'lucide:info', // Added icon
animation: false,
},
};
@@ -137,6 +137,8 @@ export const TRIGGER_SOURCE_OPTIONS = [
/** 触发来源文本映射 */
export const TRIGGER_SOURCE_TEXT_MAP: Record<string, string> = {
IOT_BEACON: '蓝牙信标',
IOT_TRAFFIC: '客流阈值',
TRAFFIC: '客流阈值',
PEOPLE_FLOW: '客流阈值',
MANUAL: '手动创建',
};

View File

@@ -115,9 +115,11 @@ function handleTabChange(key: number | string) {
const keyStr = String(key);
activeTab.value = keyStr;
const tabConfig = STATUS_TAB_OPTIONS.find((t) => t.key === keyStr);
queryParams.value.status = (tabConfig?.statuses ?? undefined) as
| OpsOrderCenterApi.OrderStatus[]
| undefined;
if (tabConfig) {
queryParams.value.status = tabConfig.statuses as
| OpsOrderCenterApi.OrderStatus[]
| undefined;
}
handleSearch();
}
@@ -175,7 +177,7 @@ async function handleNotify(
try {
await sendDeviceNotify({
cleanerId: row.assigneeId,
badgeId: row.assigneeId,
type:
type === 'voice'
? OpsCleaningApi.NotifyType.VOICE
@@ -212,7 +214,8 @@ function handleStatClick(statKey: string) {
'DISPATCHED',
'CONFIRMED',
'ARRIVED',
] as OpsOrderCenterApi.OrderStatus[];
'QUEUED',
];
break;
}
case 'pendingCount': {
@@ -240,11 +243,18 @@ const [Grid, gridApi] = useVbenVxeGrid({
}: {
page: { currentPage: number; pageSize: number };
}) => {
return await getOrderPage({
const params = {
pageNo: page.currentPage,
pageSize: page.pageSize,
...queryParams.value,
});
};
// 处理状态数组
// 如果后端接收 List<String>,通常不需要手动 join(','),直接传数组即可
// axios/requestClient 会自动处理为 status=A&status=B 的形式
// 这里移除 .join(',') 的逻辑,直接保留数组
return await getOrderPage(params);
},
},
},

View File

@@ -8,7 +8,7 @@ import { useVbenModal } from '@vben/common-ui';
import { Avatar, Badge, Card, message, Spin } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getCleanerStatusList } from '#/api/ops/cleaning';
import { getBadgeStatusList } from '#/api/ops/cleaning';
import { assignOrder } from '#/api/ops/order-center';
defineOptions({ name: 'WorkOrderAssignForm' });
@@ -23,7 +23,7 @@ const [Modal, modalApi] = useVbenModal({
orderId.value = data.orderId;
orderCode.value = data.orderCode;
}
await loadCleaners();
await loadBadges();
}
},
onConfirm: handleSubmit,
@@ -32,9 +32,9 @@ const [Modal, modalApi] = useVbenModal({
const orderId = ref<number>();
const orderCode = ref<string>('');
const loading = ref(false);
const cleanerLoading = ref(false);
const cleanerList = ref<OpsCleaningApi.CleanerStatusItem[]>([]);
const selectedCleanerId = ref<number>();
const badgeLoading = ref(false);
const badgeList = ref<OpsCleaningApi.BadgeStatusItem[]>([]);
const selectedBadgeId = ref<number>();
const [Form, formApi] = useVbenForm({
schema: [
@@ -51,22 +51,22 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
/** 加载保洁员列表 */
async function loadCleaners() {
cleanerLoading.value = true;
/** 加载工牌列表 */
async function loadBadges() {
badgeLoading.value = true;
try {
const res = await getCleanerStatusList();
cleanerList.value = res.list || [];
const res = await getBadgeStatusList();
badgeList.value = res || [];
} catch {
cleanerList.value = [];
badgeList.value = [];
} finally {
cleanerLoading.value = false;
badgeLoading.value = false;
}
}
/** 选择保洁员 */
function selectCleaner(cleaner: OpsCleaningApi.CleanerStatusItem) {
selectedCleanerId.value = cleaner.userId;
/** 选择工牌 */
function selectBadge(badge: OpsCleaningApi.BadgeStatusItem) {
selectedBadgeId.value = badge.deviceId;
}
/** 获取状态颜色 */
@@ -100,8 +100,8 @@ function getBatteryColor(level: number) {
/** 提交表单 */
async function handleSubmit() {
if (!selectedCleanerId.value) {
message.warning('请选择执行');
if (!selectedBadgeId.value) {
message.warning('请选择执行工牌');
return;
}
@@ -110,7 +110,7 @@ async function handleSubmit() {
const formData = await formApi.getValues();
await assignOrder({
orderId: orderId.value!,
assigneeId: selectedCleanerId.value,
assigneeId: selectedBadgeId.value,
remark: formData.remark,
});
message.success('派单成功');
@@ -121,9 +121,9 @@ async function handleSubmit() {
}
}
/** 可用保洁员(优先显示空闲) */
const sortedCleaners = computed(() => {
return [...cleanerList.value].toSorted((a, b) => {
/** 可用工牌(优先显示空闲) */
const sortedBadges = computed(() => {
return [...badgeList.value].toSorted((a, b) => {
const order: Record<string, number> = {
IDLE: 0,
BUSY: 1,
@@ -147,65 +147,60 @@ const sortedCleaners = computed(() => {
</div>
</div>
<!-- 保洁员选择 -->
<!-- 工牌选择 -->
<div class="mb-4">
<div class="mb-2 text-sm font-medium">选择执行</div>
<Spin :spinning="cleanerLoading">
<div class="mb-2 text-sm font-medium">选择执行工牌</div>
<Spin :spinning="badgeLoading">
<div
v-if="sortedCleaners.length > 0"
class="cleaner-grid grid max-h-64 gap-3 overflow-y-auto"
v-if="sortedBadges.length > 0"
class="badge-grid grid max-h-64 gap-3 overflow-y-auto"
>
<Card
v-for="cleaner in sortedCleaners"
:key="cleaner.userId"
v-for="badge in sortedBadges"
:key="badge.deviceId"
size="small"
class="cleaner-card cursor-pointer transition-all"
class="badge-card cursor-pointer transition-all"
:class="[
selectedCleanerId === cleaner.userId
selectedBadgeId === badge.deviceId
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-gray-300',
cleaner.status === 'OFFLINE' ? 'opacity-50' : '',
badge.status === 'OFFLINE' ? 'opacity-50' : '',
]"
@click="selectCleaner(cleaner)"
@click="selectBadge(badge)"
>
<div class="flex items-center gap-3">
<Badge
:color="getStatusColor(cleaner.status)"
:offset="[-4, 28]"
>
<Badge :color="getStatusColor(badge.status)" :offset="[-4, 28]">
<Avatar size="small" class="bg-blue-500">
{{ cleaner.userName?.charAt(0) }}
{{ badge.deviceKey?.charAt(0) || '?' }}
</Avatar>
</Badge>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between">
<span class="truncate font-medium">{{
cleaner.userName
badge.deviceKey
}}</span>
<span
class="ml-2 text-xs"
:style="{ color: getStatusColor(cleaner.status) }"
:style="{ color: getStatusColor(badge.status) }"
>
{{ getStatusText(cleaner.status) }}
{{ getStatusText(badge.status) }}
</span>
</div>
<div
class="mt-1 flex items-center justify-between text-xs text-gray-400"
>
<span>{{ cleaner.currentAreaName || '未知区域' }}</span>
<span>{{ badge.currentAreaName || '未知区域' }}</span>
<span
:style="{ color: getBatteryColor(cleaner.batteryLevel) }"
:style="{ color: getBatteryColor(badge.batteryLevel) }"
>
{{ cleaner.batteryLevel }}%
{{ badge.batteryLevel }}%
</span>
</div>
</div>
</div>
</Card>
</div>
<div v-else class="py-8 text-center text-gray-400">
暂无可用保洁员
</div>
<div v-else class="py-8 text-center text-gray-400">暂无可用工牌</div>
</Spin>
</div>
@@ -217,11 +212,11 @@ const sortedCleaners = computed(() => {
<style scoped lang="scss">
.assign-form {
.cleaner-grid {
.badge-grid {
grid-template-columns: repeat(2, 1fr);
}
.cleaner-card {
.badge-card {
border-radius: 8px;
:deep(.ant-card-body) {

View File

@@ -1,12 +1,11 @@
<script lang="ts" setup>
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useDictStore } from '@vben/stores';
import {
Avatar,
Button,
Col,
Dropdown,
@@ -15,16 +14,17 @@ import {
Pagination,
Popconfirm,
Row,
Spin,
Tag,
Tooltip,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getOrderPage } from '#/api/ops/order-center';
import {
ORDER_TYPE_COLOR_MAP,
ORDER_TYPE_TEXT_MAP,
PRIORITY_STYLE_MAP,
STATUS_COLOR_MAP,
STATUS_ICON_MAP,
STATUS_TEXT_MAP,
@@ -43,7 +43,7 @@ const emit = defineEmits<{
}>();
// ========== 模拟数据开关 ==========
const USE_MOCK_DATA = true;
const USE_MOCK_DATA = false;
// 模拟工单数据
const MOCK_ORDER_LIST: OpsOrderCenterApi.OrderItem[] = [
@@ -153,6 +153,101 @@ const queryParams = ref({
pageSize: 12,
});
/** 字典相关 */
const dictStore = useDictStore();
const priorityOptions = computed(() =>
dictStore.getDictOptions(DICT_TYPE.OPS_ORDER_PRIORITY),
);
const sourceTypeOptions = computed(() =>
dictStore.getDictOptions(DICT_TYPE.OPS_TRIGGER_SOURCE),
);
/** 获取优先级信息 */
function getPriorityInfo(priority: number) {
const dictItem = priorityOptions.value.find(
(item) => item.value === String(priority),
);
if (!dictItem) {
return {
label: `P${priority}`,
style: { color: '#8c8c8c', backgroundColor: '#f5f5f5' },
icon: 'lucide:info',
};
}
// 映射颜色类型到样式
let style = { color: '#8c8c8c', backgroundColor: '#f5f5f5' };
switch (dictItem.colorType) {
case 'danger': {
style = { color: '#ff4d4f', backgroundColor: '#fff1f0' };
break;
}
case 'info': {
style = { color: '#1677ff', backgroundColor: '#e6f4ff' };
break;
}
case 'success': {
style = { color: '#52c41a', backgroundColor: '#f6ffed' };
break;
}
case 'warning': {
style = { color: '#fa8c16', backgroundColor: '#fff7e6' };
break;
}
// No default
}
// 图标映射 (硬编码辅助)
let icon = 'lucide:info';
if (priority === 0) icon = 'solar:bolt-bold';
else if (priority === 1) icon = 'lucide:alert-triangle';
return {
label: dictItem.label,
style,
icon,
};
}
function getSourceTypeInfo(value: string) {
const option = sourceTypeOptions.value.find((item) => item.value === value);
if (!option) return { label: value, color: 'default' };
// 优先使用字典配置的 cssClass 作为颜色 (如果匹配 Ant Design Tag 的预设颜色)
// 或者如果你的字典有专门的 color 字段也可以在这里处理
if (option.cssClass) {
return { label: option.label, color: option.cssClass };
}
let color = 'default';
switch (option.colorType) {
case 'danger': {
color = 'error';
break;
}
case 'info': {
color = 'default';
break;
}
case 'primary': {
color = 'processing';
break;
}
case 'success': {
color = 'success';
break;
}
case 'warning': {
color = 'warning';
break;
}
default: {
color = option.colorType || 'default';
}
}
return { label: option.label, color };
}
/** 获取工单列表 */
async function getList() {
loading.value = true;
@@ -181,11 +276,6 @@ function handlePageChange(page: number, pageSize: number) {
getList();
}
/** 获取优先级样式 */
function getPriorityStyle(priority: number) {
return PRIORITY_STYLE_MAP[priority] || PRIORITY_STYLE_MAP[2];
}
/** 判断是否为紧急工单 */
function isUrgent(priority: number) {
return priority === 0;
@@ -202,18 +292,20 @@ function isInProgress(status: string) {
}
/** 格式化时间显示 */
function formatTime(time: string) {
function formatTime(time: Date | string) {
if (!time) return '-';
const date = new Date(time);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(minutes / 60);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
return time.slice(5, 16);
try {
const date = dayjs(time);
if (!date.isValid()) {
return '-';
}
// 格式化为 YYYY-MM-DD HH:mm:ss
return date.format('YYYY-MM-DD HH:mm:ss');
} catch {
return '-';
}
}
defineExpose({
@@ -232,7 +324,7 @@ onMounted(() => {
<template>
<div class="work-order-card-view">
<!-- 工单卡片列表 -->
<div v-loading="loading" class="card-grid">
<Spin :spinning="loading" class="card-grid">
<Row v-if="list.length > 0" :gutter="[12, 12]">
<Col
v-for="item in list"
@@ -266,15 +358,14 @@ onMounted(() => {
}}</span>
</div>
<div
v-if="item.priority !== 2"
class="priority-tag"
:class="`priority-${item.priority}`"
:style="getPriorityInfo(item.priority).style"
>
<IconifyIcon
v-if="item.priority === 0"
icon="solar:bolt-bold"
v-if="getPriorityInfo(item.priority).icon"
:icon="getPriorityInfo(item.priority).icon"
/>
{{ getPriorityStyle(item.priority)?.label }}
{{ getPriorityInfo(item.priority).label }}
</div>
</div>
@@ -287,34 +378,63 @@ onMounted(() => {
<!-- 信息行 -->
<div class="info-rows">
<!-- 工单编号 -->
<div class="info-row">
<IconifyIcon
icon="solar:bill-list-bold-duotone"
class="info-icon"
/>
<span class="info-text order-code-styled">
{{ item.orderCode }}
</span>
</div>
<!-- 位置信息 -->
<div class="info-row">
<IconifyIcon
icon="solar:map-point-bold-duotone"
class="info-icon"
/>
<span class="info-text">{{
item.location || '未指定位置'
}}</span>
<span class="info-text location-text">
{{ item.location || '未指定位置' }}
</span>
</div>
<!-- 执行人 -->
<div class="info-row">
<IconifyIcon
icon="solar:user-bold-duotone"
class="info-icon"
/>
<template v-if="item.assigneeName">
<Avatar :size="16" class="assignee-avatar">
{{ item.assigneeName.charAt(0) }}
</Avatar>
<span class="info-text">{{ item.assigneeName }}</span>
</template>
<span
v-if="item.assigneeName"
class="info-text assignee-text"
>
{{ item.assigneeName }}
</span>
<span v-else class="info-text info-text--muted">待分配</span>
</div>
<!-- 触发来源 -->
<div v-if="item.sourceType" class="info-row">
<IconifyIcon
icon="solar:radar-bold-duotone"
class="info-icon"
/>
<Tag
:color="getSourceTypeInfo(item.sourceType).color"
class="source-tag"
:bordered="false"
>
{{ getSourceTypeInfo(item.sourceType).label }}
</Tag>
</div>
</div>
</div>
<!-- 底部元信息 + 操作按钮 -->
<div class="card-footer">
<!-- 左侧类型 + 编号 + 时间 -->
<!-- 左侧类型 + 时间 -->
<div class="card-meta">
<Tag
:color="ORDER_TYPE_COLOR_MAP[item.orderType]"
@@ -322,7 +442,6 @@ onMounted(() => {
>
{{ ORDER_TYPE_TEXT_MAP[item.orderType] }}
</Tag>
<span class="order-code">#{{ item.orderCode.slice(-6) }}</span>
<span class="create-time">{{
formatTime(item.createTime)
}}</span>
@@ -409,7 +528,7 @@ onMounted(() => {
<!-- 空状态 -->
<Empty v-else description="暂无工单数据" class="my-16" />
</div>
</Spin>
<!-- 分页 -->
<div v-if="list.length > 0" class="pagination-wrapper">
@@ -451,8 +570,9 @@ onMounted(() => {
position: relative;
display: flex;
flex-direction: column;
height: 190px;
padding: 14px;
height: 100%;
min-height: 200px;
padding: 12px;
cursor: pointer;
background: #fff;
border: 1px solid #f0f0f0;
@@ -482,7 +602,7 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
margin-bottom: 8px;
}
.status-info {
@@ -510,15 +630,8 @@ onMounted(() => {
border-radius: 4px;
&.priority-0 {
color: #ff4d4f;
background: #fff1f0;
animation: pulse 2s infinite;
}
&.priority-1 {
color: #fa8c16;
background: #fff7e6;
}
}
// 主体
@@ -531,12 +644,12 @@ onMounted(() => {
.card-title {
display: -webkit-box;
margin: 0 0 10px;
margin: 0 0 8px;
overflow: hidden;
-webkit-line-clamp: 1;
-webkit-line-clamp: 2;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
line-height: 1.5;
color: #262626;
-webkit-box-orient: vertical;
}
@@ -545,32 +658,53 @@ onMounted(() => {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.info-row {
display: flex;
gap: 6px;
gap: 8px;
align-items: center;
font-size: 13px;
color: #595959;
.info-icon {
flex-shrink: 0;
font-size: 14px;
font-size: 15px;
color: #8c8c8c;
}
.info-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
white-space: normal;
&--muted {
font-style: italic;
color: #bfbfbf;
}
}
.order-code-styled {
padding: 1px 6px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 12px;
color: #595959;
background: #f5f5f5;
border-radius: 4px;
}
.location-text {
font-weight: 500;
color: #262626;
}
.assignee-text {
font-weight: 500;
color: #1677ff;
}
.assignee-avatar {
flex-shrink: 0;
font-size: 10px !important;
@@ -578,6 +712,13 @@ onMounted(() => {
color: #fff;
background: #1677ff;
}
.source-tag {
margin: 0;
font-size: 11px;
line-height: 18px;
border: none;
}
}
// 底部区域
@@ -586,8 +727,8 @@ onMounted(() => {
gap: 12px;
align-items: center;
justify-content: space-between;
padding-top: 10px;
margin-top: auto;
padding-top: 8px;
margin-top: 10px;
border-top: 1px solid #f5f5f5;
}