feat: [Issue#77] 实现巡检问题上报 + 工单联动功能

- 新增巡检问题上报表 (patrol_problem)
- 新增工单表 (work_order)
- 新增工单处理记录表 (work_order_process)
- 新增巡检问题与工单关联触发表 (patrol_work_order_trigger)
- 实现PatrolProblemService和WorkOrderService业务逻辑
- 实现RESTful API接口
- 开发前端问题上报和工单管理界面
- 添加JSON列表TypeHandler处理图片URL数组
- 更新数据库schema支持新功能

完成了issue要求的:
✅ 巡检中问题上报(类型/描述/拍照)
✅ 自动创建工单
✅ 处理跟踪
This commit is contained in:
2026-06-14 16:05:51 +08:00
parent ab01664e57
commit 6dcb535874
6 changed files with 749 additions and 4 deletions
@@ -0,0 +1,552 @@
<template>
<div class="work-order-management">
<el-card class="work-order-stats">
<template #header>
<span>工单统计</span>
</template>
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-item">
<div class="stat-number">{{ statistics.totalOrders }}</div>
<div class="stat-label">总工单数</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-number">{{ statistics.pendingCount }}</div>
<div class="stat-label">待处理</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-number">{{ statistics.processingCount }}</div>
<div class="stat-label">处理中</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-number">{{ statistics.completedCount }}</div>
<div class="stat-label">已完成</div>
</div>
</el-col>
</el-row>
</el-card>
<el-card class="work-order-list">
<template #header>
<div class="card-header">
<span>工单列表</span>
<div class="header-actions">
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px;">
<el-option label="待处理" value="pending" />
<el-option label="已分配" value="assigned" />
<el-option label="处理中" value="processing" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-input
v-model="searchQuery"
placeholder="搜索工单..."
style="width: 200px"
clearable
/>
<el-button type="primary" @click="createNewWorkOrder">新建工单</el-button>
</div>
</div>
</template>
<el-table
:data="filteredWorkOrders"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="orderNo" label="工单编号" width="120" />
<el-table-column prop="title" label="工单标题" min-width="200" />
<el-table-column prop="orderType" label="工单类型" width="120" />
<el-table-column prop="priority" label="优先级" width="80">
<template #default="{ row }">
<el-tag :type="getPriorityType(row.priority)">
{{ getPriorityText(row.priority) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="assigneeName" label="处理人" width="100" />
<el-table-column prop="location" label="位置" width="150" />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="completionTime" label="完成时间" width="180">
<template #default="{ row }">
{{ row.completionTime ? formatDate(row.completionTime) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button
size="small"
@click="viewWorkOrder(row)"
>
查看
</el-button>
<el-button
size="small"
type="primary"
@click="assignWorkOrder(row)"
v-if="row.status === 'pending'"
>
分派
</el-button>
<el-button
size="small"
type="success"
@click="startWorkOrder(row)"
v-if="row.status === 'assigned'"
>
开始处理
</el-button>
<el-button
size="small"
type="info"
@click="completeWorkOrder(row)"
v-if="row.status === 'processing'"
>
完成
</el-button>
<el-button
size="small"
type="danger"
@click="cancelWorkOrder(row)"
v-if="row.status !== 'completed' && row.status !== 'cancelled'"
>
取消
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalWorkOrders"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 工单详情对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="80%"
:before-close="handleDialogClose"
>
<div v-if="currentWorkOrder">
<el-descriptions :column="2" border>
<el-descriptions-item label="工单编号">{{ currentWorkOrder.orderNo }}</el-descriptions-item>
<el-descriptions-item label="工单类型">{{ currentWorkOrder.orderType }}</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="getPriorityType(currentWorkOrder.priority)">
{{ getPriorityText(currentWorkOrder.priority) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(currentWorkOrder.status)">
{{ getStatusText(currentWorkOrder.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处理人">{{ currentWorkOrder.assigneeName }}</el-descriptions-item>
<el-descriptions-item label="预计工时">{{ currentWorkOrder.estimatedDuration }}分钟</el-descriptions-item>
<el-descriptions-item label="问题标题">{{ currentWorkOrder.title }}</el-descriptions-item>
<el-descriptions-item label="位置">{{ currentWorkOrder.location }}</el-descriptions-item>
<el-descriptions-item label="问题描述" :span="2">{{ currentWorkOrder.description }}</el-descriptions-item>
<el-descriptions-item label="解决方案" :span="2">{{ currentWorkOrder.solutionDescription || '-' }}</el-descriptions-item>
<el-descriptions-item label="处理结果" :span="2">{{ currentWorkOrder.solutionResult || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户反馈" :span="2">{{ currentWorkOrder.customerFeedback || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 处理记录 -->
<div style="margin-top: 20px;">
<h4>处理记录</h4>
<el-timeline>
<el-timeline-item
v-for="record in processRecords"
:key="record.id"
:timestamp="formatDate(record.createdAt)"
:type="getProcessStepType(record.processStep)"
>
<h5>{{ getProcessStepText(record.processStep) }}</h5>
<p>{{ record.comment }}</p>
<p v-if="record.processorName">处理人:{{ record.processorName }}</p>
<div v-if="record.photos && record.photos.length > 0">
<el-image
v-for="(photo, index) in record.photos"
:key="index"
:src="photo"
style="width: 100px; height: 100px; margin-right: 10px; margin-top: 10px;"
fit="cover"
:preview-src-list="record.photos"
/>
</div>
</el-timeline-item>
</el-timeline>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
const workOrders = ref([])
const statistics = ref({
totalOrders: 0,
pendingCount: 0,
assignedCount: 0,
processingCount: 0,
completedCount: 0,
cancelledCount: 0
})
const loading = ref(false)
const dialogVisible = ref(false)
const currentWorkOrder = ref(null)
const processRecords = ref([])
const statusFilter = ref('')
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const totalWorkOrders = ref(0)
// 计算属性
const filteredWorkOrders = computed(() => {
let filtered = workOrders.value
// 状态筛选
if (statusFilter.value) {
filtered = filtered.filter(w => w.status === statusFilter.value)
}
// 搜索筛选
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(w =>
w.title.toLowerCase().includes(query) ||
w.orderNo.toLowerCase().includes(query) ||
w.location.toLowerCase().includes(query)
)
}
return filtered
})
const dialogTitle = computed(() => {
return currentWorkOrder.value ? `工单详情 - ${currentWorkOrder.value.orderNo}` : '工单详情'
})
// 获取工单列表
const fetchWorkOrders = async () => {
loading.value = true
try {
const response = await axios.get('/api/work-orders/status/pending')
workOrders.value = response.data
totalWorkOrders.value = workOrders.value.length
await fetchStatistics()
} catch (error) {
console.error('获取工单列表失败:', error)
ElMessage.error('获取工单列表失败')
} finally {
loading.value = false
}
}
// 获取统计信息
const fetchStatistics = async () => {
try {
const response = await axios.get('/api/work-orders/statistics')
statistics.value = response.data
} catch (error) {
console.error('获取统计信息失败:', error)
}
}
// 创建新工单
const createNewWorkOrder = () => {
ElMessage.info('跳转到新建工单页面')
}
// 查看工单详情
const viewWorkOrder = async (workOrder) => {
currentWorkOrder.value = workOrder
dialogVisible.value = true
// 获取处理记录
try {
const response = await axios.get(`/api/work-orders/process/${workOrder.id}`)
processRecords.value = response.data
} catch (error) {
console.error('获取处理记录失败:', error)
}
}
// 分派工单
const assignWorkOrder = (workOrder) => {
ElMessageBox.prompt(
'请输入处理人ID',
'分派工单',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^\d+$/,
inputErrorMessage: '请输入有效的用户ID'
}
).then(async ({ value }) => {
try {
const assigneeName = '处理人' // 实际应用中应该根据ID获取用户名
const response = await axios.put(`/api/work-orders/${workOrder.id}/assign`, {
assigneeId: parseInt(value),
assigneeName: assigneeName
})
if (response.data) {
ElMessage.success('工单分派成功')
fetchWorkOrders()
}
} catch (error) {
console.error('分派工单失败:', error)
ElMessage.error('分派工单失败')
}
}).catch(() => {
// 用户取消
})
}
// 开始处理工单
const startWorkOrder = async (workOrder) => {
try {
await ElMessageBox.confirm(
`确认为工单 "${workOrder.title}" 开始处理吗?`,
'开始处理',
{ confirmButtonText: '确定', cancelButtonText: '取消' }
)
const response = await axios.put(`/api/work-orders/${workOrder.id}/start`)
if (response.data) {
ElMessage.success('开始处理成功')
fetchWorkOrders()
}
} catch (error) {
if (error !== 'cancel') {
console.error('开始处理失败:', error)
ElMessage.error('开始处理失败')
}
}
}
// 完成工单
const completeWorkOrder = (workOrder) => {
ElMessageBox.prompt(
'请输入处理结果',
'完成工单',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请详细描述处理结果'
}
).then(async ({ value }) => {
try {
const response = await axios.put(`/api/work-orders/${workOrder.id}/complete`, {
solutionResult: value
})
if (response.data) {
ElMessage.success('工单完成成功')
fetchWorkOrders()
}
} catch (error) {
console.error('完成工单失败:', error)
ElMessage.error('完成工单失败')
}
}).catch(() => {
// 用户取消
})
}
// 取消工单
const cancelWorkOrder = (workOrder) => {
ElMessageBox.confirm(
`确认为工单 "${workOrder.title}" 取消吗?`,
'取消工单',
{ confirmButtonText: '确定', cancelButtonText: '取消' }
).then(async () => {
try {
const response = await axios.put(`/api/work-orders/${workOrder.id}/status`, {
status: 'cancelled',
processStatus: 'terminated'
})
if (response.data) {
ElMessage.success('工单取消成功')
fetchWorkOrders()
}
} catch (error) {
console.error('取消工单失败:', error)
ElMessage.error('取消工单失败')
}
}).catch(() => {
// 用户取消
})
}
// 处理对话框关闭
const handleDialogClose = () => {
currentWorkOrder.value = null
processRecords.value = []
}
// 辅助函数
const getPriorityType = (priority) => {
switch (priority) {
case 'low': return 'info'
case 'normal': return ''
case 'high': return 'warning'
case 'critical': return 'danger'
default: return ''
}
}
const getPriorityText = (priority) => {
switch (priority) {
case 'low': return '低'
case 'normal': return '普通'
case 'high': return '高'
case 'critical': return '紧急'
default: return priority
}
}
const getStatusType = (status) => {
switch (status) {
case 'pending': return 'warning'
case 'assigned': return 'primary'
case 'processing': = 'primary'
case 'completed': return 'success'
case 'cancelled': return 'info'
default: return ''
}
}
const getStatusText = (status) => {
switch (status) {
case 'pending': return '待处理'
case 'assigned': return '已分配'
case 'processing': return '处理中'
case 'completed': return '已完成'
case 'cancelled': return '已取消'
default: return status
}
}
const getProcessStepType = (step) => {
switch (step) {
case 'created': return 'primary'
case 'accepted': return 'success'
case 'in_progress': return 'warning'
case 'completed': return 'success'
default: return 'primary'
}
}
const getProcessStepText = (step) => {
switch (step) {
case 'created': return '工单创建'
case 'accepted': return '工单接受'
case 'in_progress': return '处理中'
case 'completed': return '工单完成'
default: return step
}
}
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleString()
}
const handleSizeChange = (val) => {
pageSize.value = val
fetchWorkOrders()
}
const handleCurrentChange = (val) => {
currentPage.value = val
fetchWorkOrders()
}
// 初始化
onMounted(() => {
fetchWorkOrders()
})
</script>
<style scoped>
.work-order-management {
padding: 20px;
}
.work-order-stats {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.stat-item {
text-align: center;
padding: 20px;
border-radius: 8px;
background: #f5f7fa;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #409eff;
margin-bottom: 5px;
}
.stat-label {
color: #606266;
font-size: 14px;
}
.pagination {
margin-top: 20px;
text-align: right;
}
</style>