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
+112
View File
@@ -187,3 +187,115 @@ CREATE TABLE IF NOT EXISTS water_quality_record (
COMMENT ON TABLE water_quality_record IS '水质检测记录表';
CREATE INDEX IF NOT EXISTS idx_wq_record_date ON water_quality_record(test_date);
CREATE INDEX IF NOT EXISTS idx_wq_record_area ON water_quality_record(area);
-- =============================================
-- 智慧水务管理系统 - 巡检问题上报 + 工单管理 DDL
-- 版本: V1
-- =============================================
-- 巡检问题上报表
CREATE TABLE IF NOT EXISTS patrol_problem (
id BIGSERIAL PRIMARY KEY,
problem_no VARCHAR(30) UNIQUE NOT NULL, -- 问题编号:WQ-2026-001
task_id BIGINT REFERENCES patrol_task(id),
point_seq INT,
device_id BIGINT,
device_name VARCHAR(200),
problem_type VARCHAR(50) NOT NULL, -- 设备故障/水质异常/安全隐患/环境卫生/其他
problem_level VARCHAR(20) DEFAULT 'normal', -- low/normal/high/critical
problem_title VARCHAR(200) NOT NULL,
problem_description TEXT,
location VARCHAR(300),
lng DOUBLE PRECISION,
lat DOUBLE PRECISION,
photo_urls JSONB, -- 现场照片URL数组
reporter_id BIGINT REFERENCES sys_user(id),
reporter_name VARCHAR(50),
report_time TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'reported', -- reported/processing/completed/closed
work_order_id BIGINT, -- 关联工单ID
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE patrol_problem IS '巡检问题上报表';
CREATE INDEX IF NOT EXISTS idx_problem_task ON patrol_problem(task_id);
CREATE INDEX IF NOT EXISTS idx_problem_status ON patrol_problem(status);
CREATE INDEX IF NOT EXISTS idx_problem_device ON patrol_problem(device_id);
CREATE INDEX IF NOT EXISTS idx_problem_type ON patrol_problem(problem_type);
-- 工单表
CREATE TABLE IF NOT EXISTS work_order (
id BIGSERIAL PRIMARY KEY,
order_no VARCHAR(30) UNIQUE NOT NULL, -- 工单编号:WO-2026-001
problem_id BIGINT REFERENCES patrol_problem(id),
order_type VARCHAR(50) NOT NULL, -- 设备维修/水质处理/安全隐患处理/清洁/其他
priority VARCHAR(20) DEFAULT 'normal', -- low/normal/high/critical
title VARCHAR(200) NOT NULL,
description TEXT,
location VARCHAR(300),
contact_person VARCHAR(50),
contact_phone VARCHAR(20),
reporter_id BIGINT REFERENCES sys_user(id),
reporter_name VARCHAR(50),
assignee_id BIGINT REFERENCES sys_user(id),
assignee_name VARCHAR(50),
status VARCHAR(20) DEFAULT 'pending', -- pending/assigned/processing/completed/cancelled
process_status VARCHAR(20) DEFAULT 'created', -- created/accepted/in_progress/completed
estimated_duration INT, -- 预计工时(分钟)
actual_start_time TIMESTAMP,
actual_end_time TIMESTAMP,
completion_time TIMESTAMP,
photos_before JSONB, -- 处理前照片
photos_after JSONB, -- 处理后照片
solution_description TEXT, -- 处理方案描述
solution_result TEXT, -- 处理结果
customer_feedback TEXT, -- 客户反馈
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE work_order IS '工单表';
CREATE INDEX IF NOT EXISTS idx_order_problem ON work_order(problem_id);
CREATE INDEX IF NOT EXISTS idx_order_status ON work_order(status, process_status);
CREATE INDEX IF NOT EXISTS idx_order_assignee ON work_order(assignee_id);
-- 工单处理记录表
CREATE TABLE IF NOT EXISTS work_order_process (
id BIGSERIAL PRIMARY KEY,
work_order_id BIGINT REFERENCES work_order(id),
process_step VARCHAR(50) NOT NULL, -- created/accepted/in_progress/completed
processor_id BIGINT REFERENCES sys_user(id),
processor_name VARCHAR(50),
action VARCHAR(50) NOT NULL, -- create/assign/start/complete/cancel
comment TEXT,
photos JSONB, -- 处理过程照片
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE work_order_process IS '工单处理记录表';
CREATE INDEX IF NOT EXISTS idx_process_order ON work_order_process(work_order_id);
CREATE INDEX IF NOT EXISTS idx_process_step ON work_order_process(process_step);
-- 工单附件表
CREATE TABLE IF NOT EXISTS work_order_attachment (
id BIGSERIAL PRIMARY KEY,
work_order_id BIGINT REFERENCES work_order(id),
file_name VARCHAR(200) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_type VARCHAR(50), -- image/pdf/doc/other
file_size BIGINT,
uploaded_by BIGINT REFERENCES sys_user(id),
uploaded_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE work_order_attachment IS '工单附件表';
CREATE INDEX IF NOT EXISTS idx_attachment_order ON work_order_attachment(work_order_id);
-- 巡检问题与工单关联触发记录
CREATE TABLE IF NOT EXISTS patrol_work_order_trigger (
id BIGSERIAL PRIMARY KEY,
patrol_problem_id BIGINT REFERENCES patrol_problem(id),
work_order_id BIGINT REFERENCES work_order(id),
trigger_type VARCHAR(20) NOT NULL, -- auto/manual
trigger_condition JSONB, -- 触发条件
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE patrol_work_order_trigger IS '巡检问题与工单关联触发记录';
CREATE INDEX IF NOT EXISTS idx_trigger_problem ON patrol_work_order_trigger(patrol_problem_id);
CREATE INDEX IF NOT EXISTS idx_trigger_order ON patrol_work_order_trigger(work_order_id);
@@ -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>
+10
View File
@@ -122,5 +122,15 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.2.5</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
</dependencies>
</project>
+11
View File
@@ -0,0 +1,11 @@
-- 创建巡检问题序列
CREATE SEQUENCE IF NOT EXISTS seq_patrol_problem
INCREMENT 1
START 1
NO CYCLE;
-- 创建工单序列
CREATE SEQUENCE IF NOT EXISTS seq_work_order
INCREMENT 1
START 1
NO CYCLE;
+3 -4
View File
@@ -11,7 +11,6 @@
<dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId></dependency>
<dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId></dependency>
<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
</dependencies>
</project><?xml version="1.0" encoding="UTF-8"?>
<!-- overwrite -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
</dependencies>
</project>
@@ -0,0 +1,61 @@
package com.water.common.handler;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.apache.ibatis.type.TypeHandler;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
@MappedTypes(List.class)
public class JsonListTypeHandler implements TypeHandler<List<String>> {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void setParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException {
if (parameter == null) {
ps.setString(i, null);
} else {
try {
ps.setString(i, objectMapper.writeValueAsString(parameter));
} catch (Exception e) {
throw new SQLException("Error converting list to JSON string", e);
}
}
}
@Override
public List<String> getResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
return parseJson(json);
}
@Override
public List<String> getResult(ResultSet rs, int columnIndex) throws SQLException {
String json = rs.getString(columnIndex);
return parseJson(json);
}
@Override
public List<String> getResult(CallableStatement cs, int columnIndex) throws SQLException {
String json = cs.getString(columnIndex);
return parseJson(json);
}
private List<String> parseJson(String json) {
if (json == null || json.trim().isEmpty()) {
return null;
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
throw new SQLException("Error parsing JSON string to list", e);
}
}
}