feat(@vben/web-antd): 新增通用工单中心模块,支持多类型工单管理

添加工单中心路由(详情页和统计看板),工单列表、详情、看板等页面
支持保洁、安保、维修等多类型工单的统一管理。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-13 11:17:41 +08:00
parent d9a192bd07
commit 08e79ec20a
9 changed files with 6316 additions and 0 deletions

View File

@@ -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'),
},
],
},
];

File diff suppressed because it is too large Load Diff

View 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',
},
];
}

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

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>