feat(ops): add cleaning work order management module

This commit is contained in:
lzh
2026-01-25 22:33:34 +08:00
parent dac4e33cc6
commit 712cbd5aaf
12 changed files with 6639 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
import { requestClient } from '#/api/request';
export namespace OpsCleaningApi {
/** 保洁员状态枚举 */
export enum CleanerStatus {
BUSY = 'BUSY', // 忙碌
IDLE = 'IDLE', // 空闲
OFFLINE = 'OFFLINE', // 离线
PAUSED = 'PAUSED', // 暂停中
}
/** 通知类型枚举 */
export enum NotifyType {
VIBRATE = 'VIBRATE', // 震动
VOICE = 'VOICE', // 语音
}
/** 升级优先级请求 */
export interface UpgradePriorityReq {
orderId: number; // 工单ID
reason: string; // 升级原因
}
/** 工牌通知请求 */
export interface DeviceNotifyReq {
cleanerId: number; // 保洁员ID
type: NotifyType; // 通知类型
content?: string; // 语音内容(仅语音通知需要)
}
/** 保洁员状态信息 */
export interface CleanerStatusItem {
userId: number; // 用户ID
userName: string; // 用户名称
avatar?: string; // 头像
status: CleanerStatus; // 状态
currentAreaId?: number; // 当前区域ID
currentAreaName?: string; // 当前区域名称
batteryLevel: number; // 电量0-100
lastHeartbeatTime: string; // 最后心跳时间
todayCompletedCount?: number; // 今日完成工单数
todayWorkMinutes?: number; // 今日工作时长(分钟)
}
/** 保洁员状态列表查询参数 */
export interface CleanerListQuery {
areaId?: number; // 区域ID可选
status?: CleanerStatus; // 状态筛选(可选)
}
/** 保洁员状态列表响应 */
export interface CleanerListResp {
list: CleanerStatusItem[];
}
/** 工单时间轴节点 */
export interface TimelineItem {
status: string; // 状态
statusName: string; // 状态名称
time: string; // 时间
operator?: string; // 操作人
description?: string; // 描述
extra?: Record<string, any>; // 额外信息如RSSI值、信标ID等
}
/** 工单时间轴响应 */
export interface OrderTimelineResp {
orderId: number;
currentStatus: string;
timeline: TimelineItem[];
}
/** 保洁员工牌实时状态 */
export interface BadgeRealtimeStatus {
cleanerId: number; // 保洁员ID
deviceId: number; // 设备ID
deviceKey: string; // 设备Key
status: CleanerStatus; // 设备状态
batteryLevel: number; // 电量
lastHeartbeatTime: string; // 最后心跳时间
rssi?: number; // 信号强度
isInArea: boolean; // 是否在区域内
areaId?: number; // 当前区域ID
areaName?: string; // 当前区域名称
}
}
// ==================== 保洁专属接口 ====================
/** 升级工单优先级 (P0 插队) */
export function upgradePriority(data: OpsCleaningApi.UpgradePriorityReq) {
return requestClient.post('/ops/clean/order/upgrade-priority', data);
}
/** 发送工牌通知 (语音/震动) */
export function sendDeviceNotify(data: OpsCleaningApi.DeviceNotifyReq) {
return requestClient.post('/ops/clean/device/notify', data);
}
/** 查询保洁员实时状态列表 */
export function getCleanerStatusList(params?: OpsCleaningApi.CleanerListQuery) {
return requestClient.get<OpsCleaningApi.CleanerListResp>(
'/ops/clean/cleaner/list',
{ params },
);
}
/** 获取工单时间轴 */
export function getOrderTimeline(orderId: number) {
return requestClient.get<OpsCleaningApi.OrderTimelineResp>(
`/ops/clean/order/timeline/${orderId}`,
);
}
/** 获取保洁员工牌实时状态 */
export function getBadgeRealtimeStatus(cleanerId: number) {
return requestClient.get<OpsCleaningApi.BadgeRealtimeStatus>(
`/ops/clean/badge/realtime/${cleanerId}`,
);
}
/** 手动完成工单(兜底操作) */
export function manualCompleteOrder(orderId: number, remark?: string) {
return requestClient.post('/ops/clean/order/manual-complete', {
orderId,
remark,
});
}

View File

@@ -0,0 +1,190 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace OpsOrderCenterApi {
/** 工单类型枚举 */
export enum OrderType {
CLEAN = 'CLEAN', // 保洁
REPAIR = 'REPAIR', // 维修
SECURITY = 'SECURITY', // 安保
}
/** 工单状态枚举 */
export enum OrderStatus {
ARRIVED = 'ARRIVED', // 已到岗
CANCELLED = 'CANCELLED', // 已取消
COMPLETED = 'COMPLETED', // 已完成
CONFIRMED = 'CONFIRMED', // 已确认
DISPATCHED = 'DISPATCHED', // 已推送
PAUSED = 'PAUSED', // 已暂停
PENDING = 'PENDING', // 待分配
QUEUED = 'QUEUED', // 排队中
}
/** 保洁类型枚举 */
export enum CleaningType {
DEEP = 'DEEP', // 深度
EMERGENCY = 'EMERGENCY', // 应急
ROUTINE = 'ROUTINE', // 日常
}
/** 优先级枚举 (0=P0紧急, 1=P1重要, 2=P2普通) */
export enum Priority {
P0 = 0, // 紧急
P1 = 1, // 重要
P2 = 2, // 普通
}
/** 触发来源枚举 */
export enum TriggerSource {
IOT_BEACON = 'IOT_BEACON', // 蓝牙信标
MANUAL = 'MANUAL', // 手动创建
PEOPLE_FLOW = 'PEOPLE_FLOW', // 客流阈值
}
/** 工单查询参数 */
export interface OrderPageQuery extends PageParam {
orderType?: OrderType; // 工单类型
status?: OrderStatus | OrderStatus[]; // 工单状态(支持多选)
priority?: Priority; // 优先级
areaId?: number; // 区域ID
assigneeId?: number; // 执行人ID
orderCode?: string; // 工单编号(模糊搜索)
title?: string; // 标题(模糊搜索)
createTime?: string[]; // 创建时间范围
}
/** 保洁扩展信息 */
export interface CleaningExtInfo {
cleaningType?: CleaningType; // 作业类型
expectedDuration?: number; // 预计时长(分钟)
arrivedTime?: string; // 到岗时间
totalPauseSeconds?: number; // 暂停总秒数
difficultyLevel?: number; // 难度等级
isAuto?: number; // 是否自动创建 (1=自动, 0=手动)
}
/** 工单列表项 */
export interface OrderItem {
id: number;
orderCode: string; // 工单编号
title: string; // 工单标题
orderType: OrderType; // 工单类型
status: OrderStatus; // 工单状态
priority: Priority; // 优先级
areaId?: number; // 区域ID
location?: string; // 位置描述
assigneeId?: number; // 执行人ID
assigneeName?: string; // 执行人姓名
createTime: string; // 创建时间
extInfo?: CleaningExtInfo; // 保洁扩展信息
}
/** 工单详情 */
export interface OrderDetail extends OrderItem {
description?: string; // 工单描述
triggerSource?: TriggerSource; // 触发来源
triggerDeviceId?: number; // 触发设备ID
triggerDeviceKey?: string; // 触发设备Key
triggerRule?: string; // 触发规则
startTime?: string; // 开始时间(到岗时间)
endTime?: string; // 结束时间
remark?: string; // 备注
}
/** 统计数据 - 按类型和状态分组 */
export interface OrderStats {
[orderType: string]: {
[status: string]: number;
};
}
/** 快速统计数据 */
export interface QuickStats {
pendingCount: number; // 待分配数量
inProgressCount: number; // 进行中数量
completedTodayCount: number; // 今日完成数量
onlineCleanerCount: number; // 在线保洁员数量
}
/** 分配工单请求 */
export interface AssignOrderReq {
orderId: number; // 工单ID
assigneeId: number; // 执行人ID
remark?: string; // 备注
}
/** 暂停工单请求 */
export interface PauseOrderReq {
orderId: number; // 工单ID
userId: number; // 操作人ID
reason: string; // 暂停原因
}
/** 恢复工单请求 */
export interface ResumeOrderReq {
orderId: number; // 工单ID
userId: number; // 操作人ID
}
/** 取消工单请求 */
export interface CancelOrderReq {
id: number; // 工单ID
reason: string; // 取消原因
}
}
// ==================== 工单查询接口 ====================
/** 分页查询工单 */
export function getOrderPage(params: OpsOrderCenterApi.OrderPageQuery) {
return requestClient.get<PageResult<OpsOrderCenterApi.OrderItem>>(
'/ops/order-center/page',
{ params },
);
}
/** 查询工单详情 */
export function getOrderDetail(id: number) {
return requestClient.get<OpsOrderCenterApi.OrderDetail>(
`/ops/order-center/detail/${id}`,
);
}
/** 获取统计数据 */
export function getOrderStats(groupBy?: 'status' | 'type') {
return requestClient.get<OpsOrderCenterApi.OrderStats>(
'/ops/order-center/stats',
{ params: { groupBy } },
);
}
/** 获取快速统计数据(用于顶部统计栏) */
export function getQuickStats() {
return requestClient.get<OpsOrderCenterApi.QuickStats>(
'/ops/order-center/quick-stats',
);
}
// ==================== 工单操作接口 ====================
/** 重新分配/派单 */
export function assignOrder(data: OpsOrderCenterApi.AssignOrderReq) {
return requestClient.post('/ops/order/assign', data);
}
/** 暂停工单 */
export function pauseOrder(data: OpsOrderCenterApi.PauseOrderReq) {
return requestClient.post('/ops/order/pause', data);
}
/** 恢复工单 */
export function resumeOrder(data: OpsOrderCenterApi.ResumeOrderReq) {
return requestClient.post('/ops/order/resume', data);
}
/** 取消工单 */
export function cancelOrder(data: OpsOrderCenterApi.CancelOrderReq) {
return requestClient.post('/ops/order/cancel', data);
}

View File

@@ -0,0 +1,40 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/ops',
name: 'OpsCenter',
meta: {
title: '运维中心',
icon: 'lucide:wrench',
keepAlive: true,
hideInMenu: true,
},
children: [
// 保洁工单详情
{
path: 'cleaning/work-order/detail/:id',
name: 'CleaningWorkOrderDetail',
meta: {
title: '工单详情',
activePath: '/ops/cleaning/work-order',
},
component: () =>
import('#/views/ops/cleaning/work-order/detail/index.vue'),
},
// 保洁工单统计看板
{
path: 'cleaning/work-order/dashboard',
name: 'CleaningWorkOrderDashboard',
meta: {
title: '统计看板',
activePath: '/ops/cleaning/work-order',
},
component: () =>
import('#/views/ops/cleaning/work-order/dashboard/index.vue'),
},
],
},
];
export default routes;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,363 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { OpsOrderCenterApi } from '#/api/ops/order-center';
/** 状态颜色映射 */
export const STATUS_COLOR_MAP: Record<string, string> = {
PENDING: '#8c8c8c', // 灰色 - 待分配
QUEUED: '#faad14', // 黄色 - 排队中
DISPATCHED: '#1677ff', // 蓝色 - 已推送
CONFIRMED: '#13c2c2', // 青色 - 已确认
ARRIVED: '#52c41a', // 绿色 - 已到岗
PAUSED: '#fa8c16', // 橙色 - 已暂停
COMPLETED: '#389e0d', // 深绿 - 已完成
CANCELLED: '#ff4d4f', // 红色 - 已取消
};
/** 状态文本映射 */
export const STATUS_TEXT_MAP: Record<string, string> = {
PENDING: '待分配',
QUEUED: '排队中',
DISPATCHED: '已推送',
CONFIRMED: '已确认',
ARRIVED: '作业中',
PAUSED: '已暂停',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
/** 状态图标映射 */
export const STATUS_ICON_MAP: Record<string, string> = {
PENDING: 'solar:inbox-line-bold-duotone', // 待分配
QUEUED: 'solar:clock-circle-bold-duotone', // 排队中
DISPATCHED: 'solar:transfer-horizontal-bold-duotone', // 已推送
CONFIRMED: 'solar:check-circle-bold-duotone', // 已确认
ARRIVED: 'solar:play-circle-bold-duotone', // 作业中
PAUSED: 'solar:pause-circle-bold-duotone', // 已暂停
COMPLETED: 'solar:check-read-bold-duotone', // 已完成
CANCELLED: 'solar:close-circle-bold-duotone', // 已取消
};
/** Tab 快捷筛选配置 */
export const STATUS_TAB_OPTIONS = [
{ key: 'ALL', label: '全部', statuses: undefined },
{ key: 'PENDING', label: '待处理', statuses: ['PENDING'] },
{
key: 'IN_PROGRESS',
label: '进行中',
statuses: ['DISPATCHED', 'CONFIRMED', 'ARRIVED', 'QUEUED'],
},
{ key: 'PAUSED', label: '已暂停', statuses: ['PAUSED'] },
{ key: 'COMPLETED', label: '已完成', statuses: ['COMPLETED'] },
{ key: 'CANCELLED', label: '已取消', statuses: ['CANCELLED'] },
];
/** 优先级样式映射 */
export const PRIORITY_STYLE_MAP: Record<
number,
{
animation: boolean;
bgColor: string;
color: string;
icon: string;
label: string;
}
> = {
0: {
label: 'P0',
color: '#F44336',
bgColor: '#FFEBEE',
icon: 'lucide:zap',
animation: true,
},
1: {
label: 'P1',
color: '#FF9800',
bgColor: '#FFF3E0',
icon: 'lucide:alert-triangle',
animation: false,
},
2: {
label: 'P2',
color: '#9E9E9E',
bgColor: '#FAFAFA',
icon: '',
animation: false,
},
};
/** 工单类型选项 */
export const ORDER_TYPE_OPTIONS = [
{ label: '保洁', value: OpsOrderCenterApi.OrderType.CLEAN },
{ label: '维修', value: OpsOrderCenterApi.OrderType.REPAIR },
{ label: '安保', value: OpsOrderCenterApi.OrderType.SECURITY },
];
/** 工单类型文本映射 */
export const ORDER_TYPE_TEXT_MAP: Record<string, string> = {
CLEAN: '保洁',
REPAIR: '维修',
SECURITY: '安保',
};
/** 工单类型颜色映射 */
export const ORDER_TYPE_COLOR_MAP: Record<string, string> = {
CLEAN: '#52c41a', // 绿色
REPAIR: '#fa8c16', // 橙色
SECURITY: '#1890ff', // 蓝色
};
/** 工单状态选项 */
export const ORDER_STATUS_OPTIONS = [
{ label: '待分配', value: OpsOrderCenterApi.OrderStatus.PENDING },
{ label: '排队中', value: OpsOrderCenterApi.OrderStatus.QUEUED },
{ label: '已推送', value: OpsOrderCenterApi.OrderStatus.DISPATCHED },
{ label: '已确认', value: OpsOrderCenterApi.OrderStatus.CONFIRMED },
{ label: '已到岗', value: OpsOrderCenterApi.OrderStatus.ARRIVED },
{ label: '已暂停', value: OpsOrderCenterApi.OrderStatus.PAUSED },
{ label: '已完成', value: OpsOrderCenterApi.OrderStatus.COMPLETED },
{ label: '已取消', value: OpsOrderCenterApi.OrderStatus.CANCELLED },
];
/** 优先级选项 */
export const PRIORITY_OPTIONS = [
{ label: 'P0 (紧急)', value: OpsOrderCenterApi.Priority.P0 },
{ label: 'P1 (重要)', value: OpsOrderCenterApi.Priority.P1 },
{ label: 'P2 (普通)', value: OpsOrderCenterApi.Priority.P2 },
];
/** 触发来源选项 */
export const TRIGGER_SOURCE_OPTIONS = [
{ label: '蓝牙信标', value: OpsOrderCenterApi.TriggerSource.IOT_BEACON },
{ label: '客流阈值', value: OpsOrderCenterApi.TriggerSource.PEOPLE_FLOW },
{ label: '手动创建', value: OpsOrderCenterApi.TriggerSource.MANUAL },
];
/** 触发来源文本映射 */
export const TRIGGER_SOURCE_TEXT_MAP: Record<string, string> = {
IOT_BEACON: '蓝牙信标',
PEOPLE_FLOW: '客流阈值',
MANUAL: '手动创建',
};
/** 保洁类型选项 */
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: '应急',
};
/** 列表表格列定义 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'seq', width: 50, title: '序号' },
{
field: 'orderCode',
title: '工单编号',
minWidth: 180,
showOverflow: true,
},
{
field: 'title',
title: '工单标题',
minWidth: 180,
showOverflow: true,
},
{
field: 'orderType',
title: '类型',
width: 80,
align: 'center',
slots: { default: 'orderType' },
},
{
field: 'status',
title: '状态',
width: 90,
align: 'center',
slots: { default: 'status' },
},
{
field: 'priority',
title: '优先级',
width: 80,
align: 'center',
slots: { default: 'priority' },
},
{
field: 'location',
title: '位置',
minWidth: 150,
showOverflow: true,
},
{
field: 'assigneeName',
title: '执行人',
width: 90,
align: 'center',
slots: { default: 'assignee' },
},
{
field: 'createTime',
title: '创建时间',
width: 160,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 200,
fixed: 'right',
align: 'center',
slots: { default: 'actions' },
},
];
}
/** 搜索表单Schema */
export function useSearchFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'orderType',
label: '工单类型',
component: 'Select',
componentProps: {
options: ORDER_TYPE_OPTIONS,
placeholder: '请选择工单类型',
allowClear: true,
},
},
{
fieldName: 'status',
label: '工单状态',
component: 'Select',
componentProps: {
options: ORDER_STATUS_OPTIONS,
placeholder: '请选择工单状态',
mode: 'multiple',
allowClear: true,
maxTagCount: 2,
},
},
{
fieldName: 'priority',
label: '优先级',
component: 'Select',
componentProps: {
options: PRIORITY_OPTIONS,
placeholder: '请选择优先级',
allowClear: true,
},
},
{
fieldName: 'orderCode',
label: '工单编号',
component: 'Input',
componentProps: {
placeholder: '请输入工单编号',
allowClear: true,
},
},
{
fieldName: 'title',
label: '工单标题',
component: 'Input',
componentProps: {
placeholder: '请输入标题关键词',
allowClear: true,
},
},
];
}
/** 派单表单Schema */
export function useAssignFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'orderId',
label: '工单ID',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'assigneeId',
label: '执行人',
component: 'Select',
componentProps: {
placeholder: '请选择执行人',
// TODO: 接入保洁员列表API
options: [],
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注(选填)',
rows: 3,
},
},
];
}
/** 升级优先级表单Schema */
export function useUpgradePriorityFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'orderId',
label: '工单ID',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'reason',
label: '升级原因',
component: 'Textarea',
componentProps: {
placeholder: '请输入升级为P0紧急工单的原因',
rows: 4,
},
rules: 'required',
},
];
}
/** 状态干预表单Schema */
export function useStatusInterventionFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'orderId',
label: '工单ID',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'reason',
label: '操作原因',
component: 'Textarea',
componentProps: {
placeholder: '请输入操作原因',
rows: 4,
},
rules: 'required',
},
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,823 @@
<script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
import { nextTick, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Input,
message,
Select,
Tabs,
Tag,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { OpsCleaningApi, sendDeviceNotify } from '#/api/ops/cleaning';
import { getOrderPage } from '#/api/ops/order-center';
import {
ORDER_TYPE_COLOR_MAP,
ORDER_TYPE_OPTIONS,
ORDER_TYPE_TEXT_MAP,
PRIORITY_OPTIONS,
STATUS_COLOR_MAP,
STATUS_TAB_OPTIONS,
STATUS_TEXT_MAP,
useGridColumns,
} from './data';
import AssignForm from './modules/assign-form.vue';
import CancelForm from './modules/cancel-form.vue';
import CardView from './modules/card-view.vue';
import StatsBar from './modules/stats-bar.vue';
import UpgradePriorityForm from './modules/upgrade-priority-form.vue';
defineOptions({ name: 'CleaningWorkOrderCenter' });
const router = useRouter();
const viewMode = ref<'card' | 'list'>('card');
const activeTab = ref('ALL');
const showAdvancedFilter = ref(false);
const cardViewRef = ref();
const statsBarRef = ref();
/** Tab 状态数量统计 */
const tabCounts = ref<Record<string, number>>({
ALL: 0,
PENDING: 0,
IN_PROGRESS: 0,
PAUSED: 0,
COMPLETED: 0,
CANCELLED: 0,
});
// 模态框
const [AssignFormModal, assignFormModalApi] = useVbenModal({
connectedComponent: AssignForm,
destroyOnClose: true,
});
const [UpgradePriorityFormModal, upgradePriorityFormModalApi] = useVbenModal({
connectedComponent: UpgradePriorityForm,
destroyOnClose: true,
});
const [CancelFormModal, cancelFormModalApi] = useVbenModal({
connectedComponent: CancelForm,
destroyOnClose: true,
});
// 查询参数
const queryParams = ref({
orderType: undefined as OpsOrderCenterApi.OrderType | undefined,
status: undefined as OpsOrderCenterApi.OrderStatus[] | undefined,
priority: undefined as OpsOrderCenterApi.Priority | undefined,
orderCode: '',
title: '',
});
/** 搜索 */
function handleSearch() {
if (viewMode.value === 'list') {
gridApi.query();
} else {
cardViewRef.value?.query();
}
}
/** 刷新数据 */
function handleRefresh() {
handleSearch();
statsBarRef.value?.refresh();
}
/** 重置筛选条件 */
function handleReset() {
queryParams.value = {
orderType: undefined,
status: undefined,
priority: undefined,
orderCode: '',
title: '',
};
activeTab.value = 'ALL';
handleSearch();
}
/** Tab 切换 */
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;
handleSearch();
}
/** 视图切换 */
async function handleViewModeChange(mode: 'card' | 'list') {
if (viewMode.value === mode) return;
viewMode.value = mode;
await nextTick();
handleSearch();
}
/** 查看详情 */
function handleDetail(id: number) {
router.push({ name: 'CleaningWorkOrderDetail', params: { id } });
}
/** 打开派单表单 */
function handleAssign(row: OpsOrderCenterApi.OrderItem) {
assignFormModalApi
.setData({ orderId: row.id, orderCode: row.orderCode })
.open();
}
/** 升级优先级 */
function handleUpgrade(row: OpsOrderCenterApi.OrderItem) {
upgradePriorityFormModalApi
.setData({
orderId: row.id,
orderCode: row.orderCode,
currentPriority: row.priority,
})
.open();
}
/** 取消工单 */
function handleCancel(row: OpsOrderCenterApi.OrderItem) {
cancelFormModalApi
.setData({
orderId: row.id,
orderCode: row.orderCode,
title: row.title,
})
.open();
}
/** 发送工牌通知 */
async function handleNotify(
row: OpsOrderCenterApi.OrderItem,
type: 'vibrate' | 'voice',
) {
if (!row.assigneeId) {
message.warning('该工单暂未分配执行人');
return;
}
try {
await sendDeviceNotify({
cleanerId: row.assigneeId,
type:
type === 'voice'
? OpsCleaningApi.NotifyType.VOICE
: OpsCleaningApi.NotifyType.VIBRATE,
content:
type === 'voice' ? `请注意,您有待处理工单:${row.title}` : undefined,
});
message.success(type === 'voice' ? '语音提醒已发送' : '震动提醒已发送');
} catch {
message.error('发送失败');
}
}
/** 处理统计卡片点击 */
function handleStatClick(statKey: string) {
// 重置筛选条件
queryParams.value = {
orderType: undefined,
status: undefined,
priority: undefined,
orderCode: '',
title: '',
};
switch (statKey) {
case 'completedTodayCount': {
queryParams.value.status = [
'COMPLETED',
] as OpsOrderCenterApi.OrderStatus[];
break;
}
case 'inProgressCount': {
queryParams.value.status = [
'DISPATCHED',
'CONFIRMED',
'ARRIVED',
] as OpsOrderCenterApi.OrderStatus[];
break;
}
case 'pendingCount': {
queryParams.value.status = ['PENDING'] as OpsOrderCenterApi.OrderStatus[];
break;
}
default: {
break;
}
}
handleSearch();
}
// 表格配置
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 500,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
return await getOrderPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...queryParams.value,
});
},
},
},
pagerConfig: {
enabled: true,
},
rowConfig: {
keyField: 'id',
isHover: true,
},
} as VxeTableGridOptions<OpsOrderCenterApi.OrderItem>,
});
onMounted(() => {
// 默认使用卡片视图
});
</script>
<template>
<Page auto-content-height>
<!-- 模态框 -->
<AssignFormModal @success="handleRefresh" />
<UpgradePriorityFormModal @success="handleRefresh" />
<CancelFormModal @success="handleRefresh" />
<!-- 快速统计栏 -->
<StatsBar ref="statsBarRef" @stat-click="handleStatClick" />
<!-- 工单列表容器 -->
<Card :body-style="{ padding: 0 }">
<!-- Tab 状态筛选 + 操作按钮 -->
<div class="tab-row">
<!-- 左侧Tab -->
<Tabs
v-model:active-key="activeTab"
class="status-tabs"
:tab-bar-gutter="24"
@change="handleTabChange"
>
<Tabs.TabPane v-for="tab in STATUS_TAB_OPTIONS" :key="tab.key">
<template #tab>
<div class="tab-item">
<span class="tab-label">{{ tab.label }}</span>
<span
v-if="(tabCounts[tab.key] ?? 0) > 0"
class="tab-count"
:class="{ 'tab-count--danger': tab.key === 'PENDING' }"
>
{{ tabCounts[tab.key] }}
</span>
</div>
</template>
</Tabs.TabPane>
</Tabs>
<!-- 右侧操作按钮 -->
<div class="tab-actions">
<!-- 视图切换 -->
<div class="btn-group">
<Button
class="group-btn"
:class="{ 'group-btn--active': viewMode === 'card' }"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="solar:widget-bold" />
</Button>
<Button
class="group-btn"
:class="{ 'group-btn--active': viewMode === 'list' }"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="solar:list-bold" />
</Button>
</div>
<div class="btn-divider"></div>
<!-- 筛选按钮 -->
<Button
class="action-btn"
:class="{ 'action-btn--active': showAdvancedFilter }"
@click="showAdvancedFilter = !showAdvancedFilter"
>
<IconifyIcon icon="solar:filter-bold" />
</Button>
<!-- 刷新按钮 -->
<Button class="action-btn" @click="handleRefresh">
<IconifyIcon icon="solar:refresh-bold" />
</Button>
<div class="btn-divider"></div>
<!-- 创建按钮 -->
<Button
type="primary"
class="create-btn"
@click="() => message.info('手动创建工单功能开发中')"
>
<IconifyIcon icon="solar:add-circle-bold" />
<span>创建工单</span>
</Button>
</div>
</div>
<!-- 搜索条件区域展开时显示 -->
<div v-show="showAdvancedFilter" class="search-panel">
<div class="search-items">
<div class="search-item">
<span class="search-label">关键词</span>
<Input
v-model:value="queryParams.title"
placeholder="工单标题"
allow-clear
style="width: 180px"
@press-enter="handleSearch"
/>
</div>
<div class="search-item">
<span class="search-label">工单编号</span>
<Input
v-model:value="queryParams.orderCode"
placeholder="输入工单编号"
allow-clear
style="width: 160px"
@press-enter="handleSearch"
/>
</div>
<div class="search-item">
<span class="search-label">工单类型</span>
<Select
v-model:value="queryParams.orderType"
placeholder="全部"
allow-clear
style="width: 120px"
:options="ORDER_TYPE_OPTIONS"
/>
</div>
<div class="search-item">
<span class="search-label">优先级</span>
<Select
v-model:value="queryParams.priority"
placeholder="全部"
allow-clear
style="width: 120px"
:options="PRIORITY_OPTIONS"
/>
</div>
</div>
<div class="search-actions">
<Button @click="handleReset">
<IconifyIcon icon="solar:restart-bold" class="btn-icon" />
重置
</Button>
<Button type="primary" @click="handleSearch">
<IconifyIcon icon="solar:magnifer-bold" class="btn-icon" />
搜索
</Button>
</div>
</div>
<!-- 卡片视图 -->
<div v-if="viewMode === 'card'" class="card-content">
<CardView
ref="cardViewRef"
:search-params="queryParams as any"
@detail="handleDetail"
@assign="handleAssign"
@upgrade="handleUpgrade"
@cancel="handleCancel"
@notify="handleNotify"
/>
</div>
<!-- 表格视图 -->
<div v-else class="list-content">
<Grid>
<!-- 工单类型列 -->
<template #orderType="{ row }">
<Tag :color="ORDER_TYPE_COLOR_MAP[row.orderType]" size="small">
{{ ORDER_TYPE_TEXT_MAP[row.orderType] }}
</Tag>
</template>
<!-- 工单状态列 -->
<template #status="{ row }">
<Tag :color="STATUS_COLOR_MAP[row.status]" size="small">
{{ STATUS_TEXT_MAP[row.status] }}
</Tag>
</template>
<!-- 优先级列 -->
<template #priority="{ row }">
<Tag v-if="row.priority === 0" color="error" size="small"> P0 </Tag>
<Tag v-else-if="row.priority === 1" color="warning" size="small">
P1
</Tag>
<span v-else class="text-gray-400">P2</span>
</template>
<!-- 执行人列 -->
<template #assignee="{ row }">
<span v-if="row.assigneeName">{{ row.assigneeName }}</span>
<span v-else class="text-gray-400">待分配</span>
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<Button type="link" size="small" @click="handleDetail(row.id)">
详情
</Button>
<Button
v-if="row.status === 'PENDING'"
type="link"
size="small"
@click="handleAssign(row)"
>
派单
</Button>
<Button
v-if="row.status === 'DISPATCHED'"
type="link"
size="small"
@click="handleNotify(row, 'voice')"
>
提醒
</Button>
<Button
v-if="
row.priority !== 0 &&
!['COMPLETED', 'CANCELLED'].includes(row.status)
"
type="link"
size="small"
danger
@click="handleUpgrade(row)"
>
升级
</Button>
<Button
v-if="!['COMPLETED', 'CANCELLED'].includes(row.status)"
type="link"
size="small"
danger
@click="handleCancel(row)"
>
取消
</Button>
</template>
</Grid>
</div>
</Card>
</Page>
</template>
<style scoped lang="scss">
/* 隐藏 VxeGrid 自带的搜索表单区域 */
:deep(.vxe-grid--form-wrapper) {
display: none !important;
}
/* Tab */
.tab-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
}
/* Tab 样式优化 */
.status-tabs {
flex: 1;
min-width: 0;
:deep(.ant-tabs-nav) {
margin-bottom: 0;
&::before {
display: none;
}
}
:deep(.ant-tabs-tab) {
padding: 14px 4px;
font-size: 14px;
transition: all 0.2s;
&:hover {
color: var(--ant-color-primary);
}
&.ant-tabs-tab-active {
font-weight: 600;
}
}
:deep(.ant-tabs-ink-bar) {
height: 2px;
border-radius: 1px;
}
.tab-item {
display: flex;
gap: 6px;
align-items: center;
}
.tab-label {
line-height: 1;
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: 500;
line-height: 1;
color: #fff;
background: #8c8c8c;
border-radius: 9px;
&--danger {
background: #ff4d4f;
}
}
}
/* Tab 右侧操作按钮 */
.tab-actions {
display: flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
padding: 8px 0;
}
/* 按钮分隔线 */
.btn-divider {
width: 1px;
height: 16px;
margin: 0 4px;
background: #e5e5e5;
}
/* 视图切换按钮组 */
.btn-group {
display: flex;
gap: 0;
background: #f5f5f5;
border-radius: 6px;
.group-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
font-size: 16px;
color: #8c8c8c;
background: transparent;
border: none;
border-radius: 6px;
transition: all 0.2s;
&:hover {
color: #595959;
}
&--active {
color: var(--ant-color-primary);
background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / 8%);
}
}
}
/* 操作按钮 */
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
font-size: 16px;
color: #595959;
background: transparent;
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: all 0.2s;
&:hover {
color: var(--ant-color-primary);
border-color: var(--ant-color-primary);
}
&--active {
color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
border-color: var(--ant-color-primary);
}
}
/* 创建按钮 */
.create-btn {
display: flex;
gap: 4px;
align-items: center;
height: 32px;
padding: 0 14px;
font-size: 14px;
border-radius: 6px;
}
/* 搜索面板 */
.search-panel {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: transparent;
border-bottom: 1px solid #f0f0f0;
}
.search-items {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.search-item {
display: flex;
gap: 8px;
align-items: center;
}
.search-label {
font-size: 13px;
color: #595959;
white-space: nowrap;
}
.search-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* 内容区 */
.card-content {
padding: 16px;
}
.list-content {
padding: 16px;
:deep(.vxe-grid) {
overflow: hidden;
background: transparent;
border-radius: 8px;
}
:deep(.vxe-table) {
background: transparent;
}
:deep(.vxe-header--column) {
background: rgb(0 0 0 / 2%);
}
:deep(.vxe-body--row) {
background: transparent;
&:hover > td {
background: rgb(0 0 0 / 4%) !important;
}
}
:deep(.vxe-table--border-line) {
border-color: rgb(0 0 0 / 6%);
}
}
html.dark {
.tab-row {
border-color: #303030;
}
.btn-divider {
background: #404040;
}
.btn-group {
background: #333;
.group-btn {
color: #8c8c8c;
&:hover {
color: #bfbfbf;
}
&--active {
color: var(--ant-color-primary);
background: #1f1f1f;
}
}
}
.action-btn {
color: #8c8c8c;
border-color: #434343;
&:hover {
color: var(--ant-color-primary);
border-color: var(--ant-color-primary);
}
&--active {
color: var(--ant-color-primary);
background: rgb(22 119 255 / 15%);
border-color: var(--ant-color-primary);
}
}
.tab-count {
background: #595959;
&--danger {
background: #a61d24;
}
}
.search-panel {
background: transparent;
border-color: #303030;
}
.search-label {
color: rgb(255 255 255 / 65%);
}
.list-content {
:deep(.vxe-grid) {
background: transparent;
}
:deep(.vxe-table) {
background: transparent;
}
:deep(.vxe-header--column) {
background: rgb(255 255 255 / 4%);
}
:deep(.vxe-body--row) {
background: transparent;
&:hover > td {
background: rgb(255 255 255 / 6%) !important;
}
}
:deep(.vxe-table--border-line) {
border-color: rgb(255 255 255 / 8%);
}
}
}
</style>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import type { OpsCleaningApi } from '#/api/ops/cleaning';
import { computed, ref } from 'vue';
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 { assignOrder } from '#/api/ops/order-center';
defineOptions({ name: 'WorkOrderAssignForm' });
const emit = defineEmits<{ success: [] }>();
const [Modal, modalApi] = useVbenModal({
onOpenChange: async (isOpen) => {
if (isOpen) {
const data = modalApi.getData<{ orderCode: string; orderId: number }>();
if (data) {
orderId.value = data.orderId;
orderCode.value = data.orderCode;
}
await loadCleaners();
}
},
onConfirm: handleSubmit,
});
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 [Form, formApi] = useVbenForm({
schema: [
{
fieldName: 'remark',
label: '派单备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入派单备注(选填)',
rows: 3,
},
},
],
showDefaultActions: false,
});
/** 加载保洁员列表 */
async function loadCleaners() {
cleanerLoading.value = true;
try {
const res = await getCleanerStatusList();
cleanerList.value = res.list || [];
} catch {
cleanerList.value = [];
} finally {
cleanerLoading.value = false;
}
}
/** 选择保洁员 */
function selectCleaner(cleaner: OpsCleaningApi.CleanerStatusItem) {
selectedCleanerId.value = cleaner.userId;
}
/** 获取状态颜色 */
function getStatusColor(status: string) {
const colorMap: Record<string, string> = {
IDLE: '#52c41a',
BUSY: '#faad14',
OFFLINE: '#d9d9d9',
PAUSED: '#ff7a45',
};
return colorMap[status] || '#d9d9d9';
}
/** 获取状态文本 */
function getStatusText(status: string) {
const textMap: Record<string, string> = {
IDLE: '空闲',
BUSY: '忙碌',
OFFLINE: '离线',
PAUSED: '暂停',
};
return textMap[status] || status;
}
/** 获取电量颜色 */
function getBatteryColor(level: number) {
if (level <= 20) return '#F44336';
if (level <= 50) return '#FF9800';
return '#4CAF50';
}
/** 提交表单 */
async function handleSubmit() {
if (!selectedCleanerId.value) {
message.warning('请选择执行人');
return;
}
loading.value = true;
try {
const formData = await formApi.getValues();
await assignOrder({
orderId: orderId.value!,
assigneeId: selectedCleanerId.value,
remark: formData.remark,
});
message.success('派单成功');
modalApi.close();
emit('success');
} finally {
loading.value = false;
}
}
/** 可用保洁员(优先显示空闲) */
const sortedCleaners = computed(() => {
return [...cleanerList.value].toSorted((a, b) => {
const order: Record<string, number> = {
IDLE: 0,
BUSY: 1,
PAUSED: 2,
OFFLINE: 3,
};
return (order[a.status] || 99) - (order[b.status] || 99);
});
});
</script>
<template>
<Modal title="分配工单" class="w-[600px]">
<div class="assign-form">
<!-- 工单信息 -->
<div class="mb-4 rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
<div class="text-sm text-gray-500">
工单编号<span class="font-medium text-gray-700 dark:text-gray-300">
{{ orderCode }}
</span>
</div>
</div>
<!-- 保洁员选择 -->
<div class="mb-4">
<div class="mb-2 text-sm font-medium">选择执行人</div>
<Spin :spinning="cleanerLoading">
<div
v-if="sortedCleaners.length > 0"
class="cleaner-grid grid max-h-64 gap-3 overflow-y-auto"
>
<Card
v-for="cleaner in sortedCleaners"
:key="cleaner.userId"
size="small"
class="cleaner-card cursor-pointer transition-all"
:class="[
selectedCleanerId === cleaner.userId
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-gray-300',
cleaner.status === 'OFFLINE' ? 'opacity-50' : '',
]"
@click="selectCleaner(cleaner)"
>
<div class="flex items-center gap-3">
<Badge
:color="getStatusColor(cleaner.status)"
:offset="[-4, 28]"
>
<Avatar size="small" class="bg-blue-500">
{{ cleaner.userName?.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
}}</span>
<span
class="ml-2 text-xs"
:style="{ color: getStatusColor(cleaner.status) }"
>
{{ getStatusText(cleaner.status) }}
</span>
</div>
<div
class="mt-1 flex items-center justify-between text-xs text-gray-400"
>
<span>{{ cleaner.currentAreaName || '未知区域' }}</span>
<span
:style="{ color: getBatteryColor(cleaner.batteryLevel) }"
>
{{ cleaner.batteryLevel }}%
</span>
</div>
</div>
</div>
</Card>
</div>
<div v-else class="py-8 text-center text-gray-400">
暂无可用保洁员
</div>
</Spin>
</div>
<!-- 备注表单 -->
<Form />
</div>
</Modal>
</template>
<style scoped lang="scss">
.assign-form {
.cleaner-grid {
grid-template-columns: repeat(2, 1fr);
}
.cleaner-card {
border-radius: 8px;
:deep(.ant-card-body) {
padding: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { cancelOrder } from '#/api/ops/order-center';
defineOptions({ name: 'CancelOrderForm' });
const emit = defineEmits<{ success: [] }>();
const [Modal, modalApi] = useVbenModal({
onOpenChange: (isOpen) => {
if (isOpen) {
const data = modalApi.getData<{
orderCode: string;
orderId: number;
title: string;
}>();
if (data) {
orderId.value = data.orderId;
orderCode.value = data.orderCode;
orderTitle.value = data.title;
}
}
},
onConfirm: handleSubmit,
});
const orderId = ref<number>();
const orderCode = ref<string>('');
const orderTitle = ref<string>('');
const loading = ref(false);
const [Form, formApi] = useVbenForm({
schema: [
{
fieldName: 'reason',
label: '取消原因',
component: 'Textarea',
componentProps: {
placeholder: '请输入取消工单的原因',
rows: 4,
maxLength: 200,
showCount: true,
},
rules: z
.string()
.min(2, '原因至少2个字符')
.max(200, '原因不能超过200字符'),
},
],
showDefaultActions: false,
});
/** 提交表单 */
async function handleSubmit() {
const { valid, values } = await formApi.validate();
if (!valid) return;
loading.value = true;
try {
await cancelOrder({
id: orderId.value!,
reason: values.reason,
});
message.success('工单已取消');
modalApi.close();
emit('success');
} finally {
loading.value = false;
}
}
</script>
<template>
<Modal title="取消工单" class="w-[480px]">
<div class="cancel-form">
<!-- 警告提示 -->
<Alert
type="error"
show-icon
class="mb-4"
message="取消后工单将无法恢复,请确认操作"
>
<template #icon>
<IconifyIcon icon="lucide:alert-circle" class="text-red-500" />
</template>
</Alert>
<!-- 工单信息 -->
<div class="mb-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div class="mb-2 text-sm text-gray-500">
工单编号<span
class="font-medium text-gray-700 dark:text-gray-300"
>{{ orderCode }}</span
>
</div>
<div class="text-sm text-gray-500">
工单标题<span
class="font-medium text-gray-700 dark:text-gray-300"
>{{ orderTitle }}</span
>
</div>
</div>
<!-- 表单 -->
<Form />
</div>
</Modal>
</template>
<style scoped>
.cancel-form {
:deep(.ant-form-item) {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,726 @@
<script lang="ts" setup>
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
Avatar,
Button,
Col,
Dropdown,
Empty,
Menu,
Pagination,
Popconfirm,
Row,
Tag,
Tooltip,
} from 'ant-design-vue';
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,
} from '../data';
defineOptions({ name: 'WorkOrderCardView' });
const props = defineProps<Props>();
const emit = defineEmits<{
assign: [row: OpsOrderCenterApi.OrderItem];
cancel: [row: OpsOrderCenterApi.OrderItem];
detail: [id: number];
notify: [row: OpsOrderCenterApi.OrderItem, type: 'vibrate' | 'voice'];
upgrade: [row: OpsOrderCenterApi.OrderItem];
}>();
// ========== 模拟数据开关 ==========
const USE_MOCK_DATA = true;
// 模拟工单数据
const MOCK_ORDER_LIST: OpsOrderCenterApi.OrderItem[] = [
{
id: 10_001,
orderCode: 'WO20260123143025001',
title: 'A区洗手间紧急保洁',
orderType: 'CLEAN' as OpsOrderCenterApi.OrderType,
status: 'ARRIVED' as OpsOrderCenterApi.OrderStatus,
priority: 0 as OpsOrderCenterApi.Priority,
location: 'A区 / 1楼洗手间',
assigneeId: 2001,
assigneeName: '张三',
createTime: '2026-01-23 14:30:25',
},
{
id: 10_002,
orderCode: 'WO20260123150012002',
title: 'B区大厅日常保洁',
orderType: 'CLEAN' as OpsOrderCenterApi.OrderType,
status: 'DISPATCHED' as OpsOrderCenterApi.OrderStatus,
priority: 1 as OpsOrderCenterApi.Priority,
location: 'B区 / 大厅',
assigneeId: 2002,
assigneeName: '李四',
createTime: '2026-01-23 15:00:12',
},
{
id: 10_003,
orderCode: 'WO20260123151530003',
title: 'C区会议室深度保洁',
orderType: 'CLEAN' as OpsOrderCenterApi.OrderType,
status: 'PENDING' as OpsOrderCenterApi.OrderStatus,
priority: 2 as OpsOrderCenterApi.Priority,
location: 'C区 / 3楼会议室',
createTime: '2026-01-23 15:15:30',
},
{
id: 10_004,
orderCode: 'WO20260123091045004',
title: 'D区电梯厅保洁',
orderType: 'CLEAN' as OpsOrderCenterApi.OrderType,
status: 'COMPLETED' as OpsOrderCenterApi.OrderStatus,
priority: 2 as OpsOrderCenterApi.Priority,
location: 'D区 / 电梯厅',
assigneeId: 2003,
assigneeName: '王五',
createTime: '2026-01-23 09:10:45',
},
{
id: 10_005,
orderCode: 'WO20260123160000005',
title: 'E区停车场应急清洁',
orderType: 'CLEAN' as OpsOrderCenterApi.OrderType,
status: 'QUEUED' as OpsOrderCenterApi.OrderStatus,
priority: 0 as OpsOrderCenterApi.Priority,
location: 'E区 / 地下停车场',
assigneeId: 2001,
assigneeName: '张三',
createTime: '2026-01-23 16:00:00',
},
{
id: 10_006,
orderCode: 'WO20260123102030006',
title: 'A区休息区保洁',
orderType: 'CLEAN' as OpsOrderCenterApi.OrderType,
status: 'CONFIRMED' as OpsOrderCenterApi.OrderStatus,
priority: 1 as OpsOrderCenterApi.Priority,
location: 'A区 / 2楼休息区',
assigneeId: 2004,
assigneeName: '赵六',
createTime: '2026-01-23 10:20:30',
},
{
id: 10_007,
orderCode: 'WO20260123110500007',
title: 'F区门厅玻璃清洁',
orderType: 'CLEAN' as OpsOrderCenterApi.OrderType,
status: 'PAUSED' as OpsOrderCenterApi.OrderStatus,
priority: 2 as OpsOrderCenterApi.Priority,
location: 'F区 / 门厅',
assigneeId: 2005,
assigneeName: '钱七',
createTime: '2026-01-23 11:05:00',
},
{
id: 10_008,
orderCode: 'WO20260123140000008',
title: 'B区卫生间设备维修',
orderType: 'REPAIR' as OpsOrderCenterApi.OrderType,
status: 'PENDING' as OpsOrderCenterApi.OrderStatus,
priority: 1 as OpsOrderCenterApi.Priority,
location: 'B区 / 2楼卫生间',
createTime: '2026-01-23 14:00:00',
},
];
interface Props {
searchParams?: Partial<OpsOrderCenterApi.OrderPageQuery>;
}
const loading = ref(false);
const list = ref<OpsOrderCenterApi.OrderItem[]>([]);
const total = ref(0);
const queryParams = ref({
pageNo: 1,
pageSize: 12,
});
/** 获取工单列表 */
async function getList() {
loading.value = true;
try {
if (USE_MOCK_DATA) {
await new Promise((resolve) => setTimeout(resolve, 400));
list.value = [...MOCK_ORDER_LIST];
total.value = MOCK_ORDER_LIST.length;
} else {
const data = await getOrderPage({
...queryParams.value,
...props.searchParams,
});
list.value = data.list || [];
total.value = data.total || 0;
}
} finally {
loading.value = false;
}
}
/** 处理页码变化 */
function handlePageChange(page: number, pageSize: number) {
queryParams.value.pageNo = page;
queryParams.value.pageSize = pageSize;
getList();
}
/** 获取优先级样式 */
function getPriorityStyle(priority: number) {
return PRIORITY_STYLE_MAP[priority] || PRIORITY_STYLE_MAP[2];
}
/** 判断是否为紧急工单 */
function isUrgent(priority: number) {
return priority === 0;
}
/** 判断是否为终态 */
function isTerminal(status: string) {
return ['CANCELLED', 'COMPLETED'].includes(status);
}
/** 判断是否为进行中状态 */
function isInProgress(status: string) {
return ['ARRIVED', 'CONFIRMED', 'DISPATCHED', 'QUEUED'].includes(status);
}
/** 格式化时间显示 */
function formatTime(time: 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);
}
defineExpose({
reload: getList,
query: () => {
queryParams.value.pageNo = 1;
getList();
},
});
onMounted(() => {
getList();
});
</script>
<template>
<div class="work-order-card-view">
<!-- 工单卡片列表 -->
<div v-loading="loading" class="card-grid">
<Row v-if="list.length > 0" :gutter="[12, 12]">
<Col
v-for="item in list"
:key="item.id"
:xs="24"
:sm="12"
:md="8"
:lg="6"
:xl="6"
>
<div
class="order-card"
:class="{
'order-card--urgent': isUrgent(item.priority),
'order-card--terminal': isTerminal(item.status),
}"
@click="emit('detail', item.id)"
>
<!-- 头部状态 + 优先级 -->
<div class="card-header">
<div
class="status-info"
:style="{ '--status-color': STATUS_COLOR_MAP[item.status] }"
>
<IconifyIcon
:icon="STATUS_ICON_MAP[item.status] || 'solar:circle-bold'"
class="status-icon"
/>
<span class="status-text">{{
STATUS_TEXT_MAP[item.status]
}}</span>
</div>
<div
v-if="item.priority !== 2"
class="priority-tag"
:class="`priority-${item.priority}`"
>
<IconifyIcon
v-if="item.priority === 0"
icon="solar:bolt-bold"
/>
{{ getPriorityStyle(item.priority)?.label }}
</div>
</div>
<!-- 主体内容 -->
<div class="card-body">
<!-- 标题 -->
<Tooltip :title="item.title" placement="topLeft">
<h4 class="card-title">{{ item.title }}</h4>
</Tooltip>
<!-- 信息行 -->
<div class="info-rows">
<div class="info-row">
<IconifyIcon
icon="solar:map-point-bold-duotone"
class="info-icon"
/>
<span class="info-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-else class="info-text info-text--muted">待分配</span>
</div>
</div>
</div>
<!-- 底部元信息 + 操作按钮 -->
<div class="card-footer">
<!-- 左侧类型 + 编号 + 时间 -->
<div class="card-meta">
<Tag
:color="ORDER_TYPE_COLOR_MAP[item.orderType]"
class="type-tag"
>
{{ ORDER_TYPE_TEXT_MAP[item.orderType] }}
</Tag>
<span class="order-code">#{{ item.orderCode.slice(-6) }}</span>
<span class="create-time">{{
formatTime(item.createTime)
}}</span>
</div>
<!-- 右侧操作按钮 -->
<div class="card-actions" @click.stop>
<!-- 待分配派单 -->
<Tooltip v-if="item.status === 'PENDING'" title="分配执行人">
<Button
type="primary"
size="small"
class="action-btn"
@click="emit('assign', item)"
>
<IconifyIcon icon="solar:user-plus-bold" />
</Button>
</Tooltip>
<!-- 已推送提醒 -->
<Dropdown
v-if="item.status === 'DISPATCHED'"
placement="topRight"
>
<Button size="small" class="action-btn action-btn--notify">
<IconifyIcon icon="solar:bell-bold" />
</Button>
<template #overlay>
<Menu>
<Menu.Item
key="voice"
@click="emit('notify', item, 'voice')"
>
<IconifyIcon
icon="solar:volume-loud-bold"
class="mr-2"
/>
语音提醒
</Menu.Item>
<Menu.Item
key="vibrate"
@click="emit('notify', item, 'vibrate')"
>
<IconifyIcon
icon="solar:smartphone-vibration-bold"
class="mr-2"
/>
震动提醒
</Menu.Item>
</Menu>
</template>
</Dropdown>
<!-- 升级优先级 -->
<Tooltip
v-if="item.priority !== 0 && isInProgress(item.status)"
title="升级为紧急"
>
<Button
size="small"
class="action-btn action-btn--upgrade"
@click="emit('upgrade', item)"
>
<IconifyIcon icon="solar:bolt-bold" />
</Button>
</Tooltip>
<!-- 取消工单 -->
<Popconfirm
v-if="!isTerminal(item.status)"
title="确定取消此工单?"
placement="topRight"
@confirm="emit('cancel', item)"
>
<Button size="small" class="action-btn action-btn--danger">
<IconifyIcon icon="solar:close-circle-bold" />
</Button>
</Popconfirm>
</div>
</div>
</div>
</Col>
</Row>
<!-- 空状态 -->
<Empty v-else description="暂无工单数据" class="my-16" />
</div>
<!-- 分页 -->
<div v-if="list.length > 0" class="pagination-wrapper">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:show-total="(t) => `${t}`"
size="small"
show-quick-jumper
show-size-changer
:page-size-options="['12', '24', '36', '48']"
@change="handlePageChange"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.work-order-card-view {
// 动画
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.card-grid {
min-height: 300px;
}
// 卡片
.order-card {
position: relative;
display: flex;
flex-direction: column;
height: 190px;
padding: 14px;
cursor: pointer;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: all 0.2s;
&:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
}
&--urgent {
border-left: 3px solid #ff4d4f;
}
&--terminal {
opacity: 0.65;
&:hover {
opacity: 1;
}
}
}
// 头部
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.status-info {
--status-color: #8c8c8c;
display: flex;
gap: 4px;
align-items: center;
font-size: 12px;
font-weight: 500;
color: var(--status-color);
.status-icon {
font-size: 14px;
}
}
.priority-tag {
display: flex;
gap: 2px;
align-items: center;
padding: 2px 6px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
&.priority-0 {
color: #ff4d4f;
background: #fff1f0;
animation: pulse 2s infinite;
}
&.priority-1 {
color: #fa8c16;
background: #fff7e6;
}
}
// 主体
.card-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.card-title {
display: -webkit-box;
margin: 0 0 10px;
overflow: hidden;
-webkit-line-clamp: 1;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: #262626;
-webkit-box-orient: vertical;
}
.info-rows {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
}
.info-row {
display: flex;
gap: 6px;
align-items: center;
font-size: 13px;
color: #595959;
.info-icon {
flex-shrink: 0;
font-size: 14px;
color: #8c8c8c;
}
.info-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--muted {
color: #bfbfbf;
}
}
.assignee-avatar {
flex-shrink: 0;
font-size: 10px !important;
line-height: 16px !important;
color: #fff;
background: #1677ff;
}
}
// 底部区域
.card-footer {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding-top: 10px;
margin-top: auto;
border-top: 1px solid #f5f5f5;
}
// 底部元信息
.card-meta {
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
.type-tag {
flex-shrink: 0;
margin: 0;
font-size: 11px;
line-height: 16px;
border: none;
}
.order-code {
flex-shrink: 0;
font-family: 'SF Mono', Monaco, monospace;
font-size: 11px;
color: #8c8c8c;
}
.create-time {
flex-shrink: 0;
font-size: 11px;
color: #8c8c8c;
}
}
// 操作按钮
.card-actions {
display: flex;
flex-shrink: 0;
gap: 4px;
}
.action-btn {
width: 28px;
height: 28px;
padding: 0;
font-size: 14px;
line-height: 1;
border-radius: 6px;
// 确保按钮内图标居中
:deep(.iconify) {
display: block;
margin: 0 auto;
}
&--notify {
color: #fa8c16;
border-color: #ffd591;
&:hover {
color: #d46b08;
border-color: #fa8c16;
}
}
&--upgrade {
color: #ff4d4f;
border-color: #ffccc7;
&:hover {
color: #cf1322;
border-color: #ff4d4f;
}
}
&--danger {
color: #8c8c8c;
border-color: #d9d9d9;
&:hover {
color: #ff4d4f;
border-color: #ff4d4f;
}
}
}
// 分页
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
}
// 暗色模式
html.dark {
.work-order-card-view {
.order-card {
background: #1f1f1f;
border-color: #303030;
&:hover {
border-color: #434343;
}
}
.card-title {
color: rgb(255 255 255 / 85%);
}
.info-row {
color: rgb(255 255 255 / 65%);
.info-icon {
color: rgb(255 255 255 / 45%);
}
.info-text--muted {
color: rgb(255 255 255 / 30%);
}
}
.card-meta {
border-color: #303030;
.order-code,
.create-time {
color: rgb(255 255 255 / 45%);
}
}
.pagination-wrapper {
border-color: #303030;
}
}
}
</style>

View File

@@ -0,0 +1,474 @@
<script setup lang="ts">
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
import { onMounted, onUnmounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Card, Col, Row, Spin } from 'ant-design-vue';
import { getQuickStats } from '#/api/ops/order-center';
defineOptions({ name: 'WorkOrderStatsBar' });
const emit = defineEmits<{
statClick: [statKey: string];
}>();
// ========== 模拟数据开关 ==========
const USE_MOCK_DATA = true;
/** 扩展的统计数据接口 */
interface DashboardStats extends OpsOrderCenterApi.QuickStats {
// 响应速度
avgResponseTime: number; // 平均响应时间(分钟)
fastestResponseTime: number; // 最快响应时间(分钟)
responseRate: number; // 响应率(%
// 作业统计
completionRate: number; // 完成率(%
avgCompletionTime: number; // 平均完成时间(分钟)
qualityScore: number; // 作业质量评分0-100
// 人员状态
workingCleanerCount: number; // 作业中人员
idleCleanerCount: number; // 空闲人员
}
const loading = ref(false);
const statsData = ref<DashboardStats>({
pendingCount: 0,
inProgressCount: 0,
completedTodayCount: 0,
onlineCleanerCount: 0,
avgResponseTime: 0,
fastestResponseTime: 0,
responseRate: 0,
completionRate: 0,
avgCompletionTime: 0,
qualityScore: 0,
workingCleanerCount: 0,
idleCleanerCount: 0,
});
// 模拟数据
const MOCK_STATS: DashboardStats = {
pendingCount: 8,
inProgressCount: 15,
completedTodayCount: 42,
onlineCleanerCount: 12,
avgResponseTime: 12.5,
fastestResponseTime: 3.2,
responseRate: 95.8,
completionRate: 87.5,
avgCompletionTime: 45.3,
qualityScore: 92.3,
workingCleanerCount: 8,
idleCleanerCount: 4,
};
let refreshTimer: null | ReturnType<typeof setInterval> = null;
/** 加载统计数据 */
async function loadStats() {
loading.value = true;
try {
if (USE_MOCK_DATA) {
await new Promise((resolve) => setTimeout(resolve, 300));
statsData.value = {
...MOCK_STATS,
pendingCount: MOCK_STATS.pendingCount + Math.floor(Math.random() * 3),
inProgressCount:
MOCK_STATS.inProgressCount + Math.floor(Math.random() * 2),
completedTodayCount:
MOCK_STATS.completedTodayCount + Math.floor(Math.random() * 5),
onlineCleanerCount:
MOCK_STATS.onlineCleanerCount + Math.floor(Math.random() * 2) - 1,
workingCleanerCount:
MOCK_STATS.workingCleanerCount + Math.floor(Math.random() * 2) - 1,
};
} else {
const quickStats = await getQuickStats();
// TODO: 扩展 API 返回更多统计数据
statsData.value = {
...quickStats,
avgResponseTime: 0,
fastestResponseTime: 0,
responseRate: 0,
completionRate: 0,
avgCompletionTime: 0,
qualityScore: 0,
workingCleanerCount: 0,
idleCleanerCount: 0,
};
}
} catch {
// 使用默认数据
} finally {
loading.value = false;
}
}
/** 处理统计项点击 */
function handleStatClick(statKey: string) {
emit('statClick', statKey);
}
onMounted(() => {
loadStats();
refreshTimer = setInterval(loadStats, 30_000);
});
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
}
});
defineExpose({ refresh: loadStats });
</script>
<template>
<div class="stats-dashboard">
<Spin :spinning="loading">
<!-- 第一行核心指标 -->
<Row :gutter="12" class="mb-3">
<!-- 待处理 -->
<Col :xs="24" :sm="12" :md="6">
<Card
:body-style="{ padding: '12px 14px' }"
class="stats-card stats-card--clickable"
@click="handleStatClick('pendingCount')"
>
<div class="stats-content">
<div
class="stats-icon"
style="--icon-color: #ff4d4f; --icon-bg: #fff1f0"
>
<IconifyIcon icon="solar:clock-circle-bold-duotone" />
</div>
<div class="stats-info">
<div class="stats-title">待处理</div>
<div class="stats-value">{{ statsData.pendingCount }}</div>
</div>
</div>
</Card>
</Col>
<!-- 进行中 -->
<Col :xs="24" :sm="12" :md="6">
<Card
:body-style="{ padding: '12px 14px' }"
class="stats-card stats-card--clickable"
@click="handleStatClick('inProgressCount')"
>
<div class="stats-content">
<div
class="stats-icon"
style="--icon-color: #1677ff; --icon-bg: #e6f4ff"
>
<IconifyIcon icon="solar:play-circle-bold-duotone" />
</div>
<div class="stats-info">
<div class="stats-title">进行中</div>
<div class="stats-value">{{ statsData.inProgressCount }}</div>
</div>
</div>
</Card>
</Col>
<!-- 今日完成 -->
<Col :xs="24" :sm="12" :md="6">
<Card
:body-style="{ padding: '12px 14px' }"
class="stats-card stats-card--clickable"
@click="handleStatClick('completedTodayCount')"
>
<div class="stats-content">
<div
class="stats-icon"
style="--icon-color: #52c41a; --icon-bg: #f6ffed"
>
<IconifyIcon icon="solar:check-circle-bold-duotone" />
</div>
<div class="stats-info">
<div class="stats-title">今日完成</div>
<div class="stats-value">
{{ statsData.completedTodayCount }}
</div>
</div>
</div>
</Card>
</Col>
<!-- 在线人员 -->
<Col :xs="24" :sm="12" :md="6">
<Card :body-style="{ padding: '12px 14px' }" class="stats-card">
<div class="stats-content">
<div
class="stats-icon"
style="--icon-color: #722ed1; --icon-bg: #f9f0ff"
>
<IconifyIcon
icon="solar:users-group-two-rounded-bold-duotone"
/>
</div>
<div class="stats-info">
<div class="stats-title">在线人员</div>
<div class="stats-value">
{{ statsData.onlineCleanerCount }}
</div>
</div>
</div>
</Card>
</Col>
</Row>
</Spin>
</div>
</template>
<style scoped lang="scss">
.stats-dashboard {
margin-bottom: 12px;
}
// 核心指标卡片
.stats-card {
position: relative;
overflow: hidden;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.3s;
&:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 6px rgb(0 0 0 / 5%);
}
&--clickable {
cursor: pointer;
&:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
transform: translateY(-1px);
}
}
.stats-content {
display: flex;
gap: 10px;
align-items: center;
}
.stats-icon {
--icon-color: #8c8c8c;
--icon-bg: #f5f5f5;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: var(--icon-color);
background: var(--icon-bg);
border-radius: 8px;
transition: all 0.3s;
:deep(svg) {
width: 20px;
height: 20px;
}
}
.stats-info {
flex: 1;
min-width: 0;
}
.stats-title {
margin-bottom: 2px;
font-size: 13px;
font-weight: 500;
line-height: 1.3;
color: #595959;
}
.stats-subtitle {
margin-bottom: 4px;
font-size: 11px;
line-height: 1.3;
color: #8c8c8c;
}
.stats-value {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto,
sans-serif;
font-size: 20px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.2;
color: #262626;
}
}
// 态势数据卡片
.metric-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: all 0.3s;
&:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
}
.metric-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.metric-title {
display: flex;
gap: 8px;
align-items: center;
font-size: 15px;
font-weight: 600;
color: #262626;
}
.metric-icon {
font-size: 18px;
color: #1677ff;
}
.metric-status {
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
background: rgb(22 119 255 / 10%);
border-radius: 4px;
}
.metric-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.metric-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto,
sans-serif;
font-size: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #262626;
&--primary {
color: #1677ff;
}
&--success {
color: #52c41a;
}
}
.metric-progress {
margin-top: 4px;
}
.progress-label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
color: #8c8c8c;
}
.progress-value {
font-size: 13px;
font-weight: 600;
color: #262626;
}
}
// 暗色模式
html.dark {
.stats-card {
background: #1f1f1f;
border-color: #303030;
&:hover {
border-color: #434343;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
}
&--clickable:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 40%);
}
.stats-title {
color: rgb(255 255 255 / 85%);
}
.stats-subtitle {
color: rgb(255 255 255 / 45%);
}
.stats-value {
color: rgb(255 255 255 / 85%);
}
}
.metric-card {
background: #1f1f1f;
border-color: #303030;
&:hover {
border-color: #434343;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
}
.metric-title {
color: rgb(255 255 255 / 85%);
}
.metric-status {
background: rgb(22 119 255 / 20%);
}
.metric-label {
color: rgb(255 255 255 / 45%);
}
.metric-value {
color: rgb(255 255 255 / 85%);
}
.progress-label {
color: rgb(255 255 255 / 45%);
}
.progress-value {
color: rgb(255 255 255 / 85%);
}
}
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { upgradePriority } from '#/api/ops/cleaning';
defineOptions({ name: 'UpgradePriorityForm' });
const emit = defineEmits<{ success: [] }>();
const [Modal, modalApi] = useVbenModal({
onOpenChange: (isOpen) => {
if (isOpen) {
const data = modalApi.getData<{
currentPriority: number;
orderCode: string;
orderId: number;
}>();
if (data) {
orderId.value = data.orderId;
orderCode.value = data.orderCode;
currentPriority.value = data.currentPriority;
}
}
},
onConfirm: handleSubmit,
});
const orderId = ref<number>();
const orderCode = ref<string>('');
const currentPriority = ref<number>(2);
const loading = ref(false);
const [Form, formApi] = useVbenForm({
schema: [
{
fieldName: 'reason',
label: '升级原因',
component: 'Textarea',
componentProps: {
placeholder:
'请输入升级为P0紧急工单的原因例如领导临时检查、VIP客户投诉等',
rows: 4,
maxLength: 200,
showCount: true,
},
rules: z
.string()
.min(5, '原因至少5个字符')
.max(200, '原因不能超过200字符'),
},
],
showDefaultActions: false,
});
/** 获取优先级文本 */
function getPriorityText(priority: number) {
const map: Record<number, string> = {
0: 'P0 (紧急)',
1: 'P1 (重要)',
2: 'P2 (普通)',
};
return map[priority] || 'P2';
}
/** 提交表单 */
async function handleSubmit() {
const { valid, values } = await formApi.validate();
if (!valid || !values) return;
loading.value = true;
try {
await upgradePriority({
orderId: orderId.value!,
reason: values.reason,
});
message.success('已升级为P0紧急工单');
modalApi.close();
emit('success');
} finally {
loading.value = false;
}
}
</script>
<template>
<Modal title="升级优先级" class="w-[500px]">
<div class="upgrade-form">
<!-- 警告提示 -->
<Alert
type="warning"
show-icon
class="mb-4"
message="升级为P0紧急工单后该工单将插队优先处理"
>
<template #icon>
<IconifyIcon icon="lucide:zap" class="text-orange-500" />
</template>
</Alert>
<!-- 工单信息 -->
<div class="mb-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm text-gray-500">工单编号</span>
<span class="font-medium">{{ orderCode }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">当前优先级</span>
<div class="flex items-center gap-2">
<span class="text-gray-600 dark:text-gray-300">
{{ getPriorityText(currentPriority) }}
</span>
<IconifyIcon icon="lucide:arrow-right" class="text-gray-400" />
<span class="font-bold text-red-500">P0 (紧急)</span>
</div>
</div>
</div>
<!-- 表单 -->
<Form />
</div>
</Modal>
</template>
<style scoped>
.upgrade-form {
:deep(.ant-form-item) {
margin-bottom: 0;
}
}
</style>