feat(@vben/web-antd): 新增通用工单中心模块,支持多类型工单管理
添加工单中心路由(详情页和统计看板),工单列表、详情、看板等页面 支持保洁、安保、维修等多类型工单的统一管理。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,26 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () =>
|
||||
import('#/views/ops/cleaning/work-order/dashboard/index.vue'),
|
||||
},
|
||||
// 工单中心详情(通用,支持所有工单类型)
|
||||
{
|
||||
path: 'work-order/detail/:id',
|
||||
name: 'WorkOrderDetail',
|
||||
meta: {
|
||||
title: '工单详情',
|
||||
activePath: '/ops/work-order',
|
||||
},
|
||||
component: () => import('#/views/ops/work-order/detail/index.vue'),
|
||||
},
|
||||
// 工单中心统计看板
|
||||
{
|
||||
path: 'work-order/dashboard',
|
||||
name: 'WorkOrderDashboard',
|
||||
meta: {
|
||||
title: '统计看板',
|
||||
activePath: '/ops/work-order',
|
||||
},
|
||||
component: () => import('#/views/ops/work-order/dashboard/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
1402
apps/web-antd/src/views/ops/work-order/dashboard/index.vue
Normal file
1402
apps/web-antd/src/views/ops/work-order/dashboard/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
362
apps/web-antd/src/views/ops/work-order/data.ts
Normal file
362
apps/web-antd/src/views/ops/work-order/data.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
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', // 橙色 - 已暂停
|
||||
RESUMED: '#52c41a', // 绿色 - 已恢复
|
||||
COMPLETED: '#389e0d', // 深绿 - 已完成
|
||||
CANCELLED: '#ff4d4f', // 红色 - 已取消
|
||||
};
|
||||
|
||||
/** 状态文本映射 */
|
||||
export const STATUS_TEXT_MAP: Record<string, string> = {
|
||||
PENDING: '待分配',
|
||||
QUEUED: '排队中',
|
||||
DISPATCHED: '已推送',
|
||||
CONFIRMED: '已确认',
|
||||
ARRIVED: '作业中',
|
||||
PAUSED: '已暂停',
|
||||
RESUMED: '已恢复',
|
||||
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', // 已暂停
|
||||
RESUMED: 'solar:play-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: 'lucide:info', // Added 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 },
|
||||
{ label: '视频告警', value: OpsOrderCenterApi.TriggerSource.VIDEO_ALARM },
|
||||
{ label: '门禁告警', value: OpsOrderCenterApi.TriggerSource.ACCESS_ALARM },
|
||||
{ label: '巡更告警', value: OpsOrderCenterApi.TriggerSource.PATROL_ALARM },
|
||||
{ label: '紧急按钮', value: OpsOrderCenterApi.TriggerSource.PANIC_BUTTON },
|
||||
];
|
||||
|
||||
/** 触发来源文本映射 */
|
||||
export const TRIGGER_SOURCE_TEXT_MAP: Record<string, string> = {
|
||||
IOT_BEACON: '蓝牙信标',
|
||||
IOT_TRAFFIC: '客流阈值',
|
||||
TRAFFIC: '客流阈值',
|
||||
PEOPLE_FLOW: '客流阈值',
|
||||
MANUAL: '手动创建',
|
||||
VIDEO_ALARM: '视频告警',
|
||||
ACCESS_ALARM: '门禁告警',
|
||||
PATROL_ALARM: '巡更告警',
|
||||
PANIC_BUTTON: '紧急按钮',
|
||||
};
|
||||
|
||||
/** 列表表格列定义 */
|
||||
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',
|
||||
},
|
||||
];
|
||||
}
|
||||
1955
apps/web-antd/src/views/ops/work-order/detail/index.vue
Normal file
1955
apps/web-antd/src/views/ops/work-order/detail/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
863
apps/web-antd/src/views/ops/work-order/index.vue
Normal file
863
apps/web-antd/src/views/ops/work-order/index.vue
Normal file
@@ -0,0 +1,863 @@
|
||||
<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 AreaFilterDrawer from '../components/AreaFilterDrawer.vue';
|
||||
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: 'WorkOrderCenter' });
|
||||
|
||||
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();
|
||||
|
||||
/** 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: '',
|
||||
areaId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
/** 搜索 */
|
||||
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: '',
|
||||
areaId: undefined,
|
||||
};
|
||||
selectedAreaId.value = undefined;
|
||||
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);
|
||||
if (tabConfig) {
|
||||
queryParams.value.status = tabConfig.statuses 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: 'WorkOrderDetail', 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({
|
||||
badgeId: 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 handleAreaConfirm(id: number | undefined) {
|
||||
selectedAreaId.value = id;
|
||||
queryParams.value.areaId = id;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 处理统计卡片点击 */
|
||||
function handleStatClick(statKey: string) {
|
||||
// 重置筛选条件
|
||||
queryParams.value = {
|
||||
orderType: undefined,
|
||||
status: undefined,
|
||||
priority: undefined,
|
||||
orderCode: '',
|
||||
title: '',
|
||||
areaId: queryParams.value.areaId,
|
||||
};
|
||||
|
||||
switch (statKey) {
|
||||
case 'completedTodayCount': {
|
||||
queryParams.value.status = [
|
||||
'COMPLETED',
|
||||
] as OpsOrderCenterApi.OrderStatus[];
|
||||
break;
|
||||
}
|
||||
case 'inProgressCount': {
|
||||
queryParams.value.status = [
|
||||
'DISPATCHED',
|
||||
'CONFIRMED',
|
||||
'ARRIVED',
|
||||
'QUEUED',
|
||||
];
|
||||
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 };
|
||||
}) => {
|
||||
const params = {
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...queryParams.value,
|
||||
};
|
||||
|
||||
// 处理状态数组
|
||||
// 如果后端接收 List<String>,通常不需要手动 join(','),直接传数组即可
|
||||
// axios/requestClient 会自动处理为 status=A&status=B 的形式
|
||||
// 这里移除 .join(',') 的逻辑,直接保留数组
|
||||
|
||||
return await getOrderPage(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
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" />
|
||||
|
||||
<!-- 区域筛选抽屉 -->
|
||||
<AreaFilterDrawer
|
||||
v-model:open="showAreaFilter"
|
||||
v-model:model-value="selectedAreaId"
|
||||
@confirm="handleAreaConfirm"
|
||||
/>
|
||||
|
||||
<!-- 快速统计栏 -->
|
||||
<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"
|
||||
: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" />
|
||||
</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>
|
||||
227
apps/web-antd/src/views/ops/work-order/modules/assign-form.vue
Normal file
227
apps/web-antd/src/views/ops/work-order/modules/assign-form.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<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 { getBadgeStatusList } 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 loadBadges();
|
||||
}
|
||||
},
|
||||
onConfirm: handleSubmit,
|
||||
});
|
||||
|
||||
const orderId = ref<number>();
|
||||
const orderCode = ref<string>('');
|
||||
const loading = ref(false);
|
||||
const badgeLoading = ref(false);
|
||||
const badgeList = ref<OpsCleaningApi.BadgeStatusItem[]>([]);
|
||||
const selectedBadgeId = ref<number>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: [
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '派单备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入派单备注(选填)',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
/** 加载工牌列表 */
|
||||
async function loadBadges() {
|
||||
badgeLoading.value = true;
|
||||
try {
|
||||
const res = await getBadgeStatusList();
|
||||
badgeList.value = res || [];
|
||||
} catch {
|
||||
badgeList.value = [];
|
||||
} finally {
|
||||
badgeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择工牌 */
|
||||
function selectBadge(badge: OpsCleaningApi.BadgeStatusItem) {
|
||||
selectedBadgeId.value = badge.deviceId;
|
||||
}
|
||||
|
||||
/** 获取状态颜色 */
|
||||
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 (!selectedBadgeId.value) {
|
||||
message.warning('请选择执行工牌');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const formData = await formApi.getValues();
|
||||
await assignOrder({
|
||||
orderId: orderId.value!,
|
||||
assigneeId: selectedBadgeId.value,
|
||||
remark: formData.remark,
|
||||
});
|
||||
message.success('派单成功');
|
||||
modalApi.close();
|
||||
emit('success');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 可用工牌(优先显示空闲) */
|
||||
const sortedBadges = computed(() => {
|
||||
return [...badgeList.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="badgeLoading">
|
||||
<div
|
||||
v-if="sortedBadges.length > 0"
|
||||
class="badge-grid grid max-h-64 gap-3 overflow-y-auto"
|
||||
>
|
||||
<Card
|
||||
v-for="badge in sortedBadges"
|
||||
:key="badge.deviceId"
|
||||
size="small"
|
||||
class="badge-card cursor-pointer transition-all"
|
||||
:class="[
|
||||
selectedBadgeId === badge.deviceId
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'hover:border-gray-300',
|
||||
badge.status === 'OFFLINE' ? 'opacity-50' : '',
|
||||
]"
|
||||
@click="selectBadge(badge)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge :color="getStatusColor(badge.status)" :offset="[-4, 28]">
|
||||
<Avatar size="small" class="bg-blue-500">
|
||||
{{ 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">{{
|
||||
badge.deviceKey
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-2 text-xs"
|
||||
:style="{ color: getStatusColor(badge.status) }"
|
||||
>
|
||||
{{ getStatusText(badge.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 flex items-center justify-between text-xs text-gray-400"
|
||||
>
|
||||
<span>{{ badge.currentAreaName || '未知区域' }}</span>
|
||||
<span
|
||||
:style="{ color: getBatteryColor(badge.batteryLevel) }"
|
||||
>
|
||||
{{ badge.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 {
|
||||
.badge-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.badge-card {
|
||||
border-radius: 8px;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
865
apps/web-antd/src/views/ops/work-order/modules/card-view.vue
Normal file
865
apps/web-antd/src/views/ops/work-order/modules/card-view.vue
Normal file
@@ -0,0 +1,865 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { useDictStore } from '@vben/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Menu,
|
||||
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,
|
||||
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 = false;
|
||||
|
||||
// 模拟工单数据
|
||||
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: 8,
|
||||
});
|
||||
|
||||
/** 字典相关 */
|
||||
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;
|
||||
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 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: Date | string) {
|
||||
if (!time) return '-';
|
||||
|
||||
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({
|
||||
reload: getList,
|
||||
query: () => {
|
||||
queryParams.value.pageNo = 1;
|
||||
getList();
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="work-order-card-view">
|
||||
<!-- 工单卡片列表 -->
|
||||
<Spin :spinning="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
|
||||
class="priority-tag"
|
||||
:style="getPriorityInfo(item.priority).style"
|
||||
>
|
||||
<IconifyIcon
|
||||
v-if="getPriorityInfo(item.priority).icon"
|
||||
:icon="getPriorityInfo(item.priority).icon"
|
||||
/>
|
||||
{{ getPriorityInfo(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: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 location-text">
|
||||
{{ item.location || '未指定位置' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 执行人 -->
|
||||
<div class="info-row">
|
||||
<IconifyIcon
|
||||
icon="solar:user-bold-duotone"
|
||||
class="info-icon"
|
||||
/>
|
||||
<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]"
|
||||
class="type-tag"
|
||||
>
|
||||
{{ ORDER_TYPE_TEXT_MAP[item.orderType] }}
|
||||
</Tag>
|
||||
<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" />
|
||||
</Spin>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="pagination-wrapper">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
:page-size="8"
|
||||
:total="total"
|
||||
:show-total="(t) => `共 ${t} 条`"
|
||||
size="small"
|
||||
show-quick-jumper
|
||||
@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: 100%;
|
||||
min-height: 200px;
|
||||
padding: 12px;
|
||||
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: 8px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// 主体
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: -webkit-box;
|
||||
margin: 0 0 8px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #262626;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.info-rows {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
|
||||
.info-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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;
|
||||
line-height: 16px !important;
|
||||
color: #fff;
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部区域
|
||||
.card-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
margin-top: 10px;
|
||||
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>
|
||||
486
apps/web-antd/src/views/ops/work-order/modules/stats-bar.vue
Normal file
486
apps/web-antd/src/views/ops/work-order/modules/stats-bar.vue
Normal file
@@ -0,0 +1,486 @@
|
||||
<script setup lang="ts">
|
||||
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
|
||||
|
||||
import { onActivated, onDeactivated, 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 = false;
|
||||
|
||||
/** 扩展的统计数据接口 */
|
||||
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,
|
||||
onlineBadgeCount: 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,
|
||||
onlineBadgeCount: 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;
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
loadStats();
|
||||
refreshTimer = setInterval(loadStats, 30_000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = 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),
|
||||
onlineBadgeCount:
|
||||
MOCK_STATS.onlineBadgeCount + 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(startPolling);
|
||||
onActivated(() => {
|
||||
// 首次 mount 时 onActivated 也会触发,此时 refreshTimer 已存在,跳过
|
||||
if (!refreshTimer) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
onDeactivated(stopPolling);
|
||||
onUnmounted(stopPolling);
|
||||
|
||||
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.onlineBadgeCount }}
|
||||
</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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user