merge: 合并 feature/issue-72 到 feature/dev (阈值管理+信息发布+设备管理)

- 合并冲突解决: 保留 issue-72 的完善版本(支持 MyBatis-Plus、AND/OR 组合条件引擎、逻辑删除)
- 覆盖 feature/dev 中的早期简化版 AlertRule 相关代码
- 新增: 阈值管理 CRUD + 信息发布 + 设备管理功能
This commit is contained in:
2026-06-14 16:02:50 +08:00
274 changed files with 18370 additions and 117 deletions
+68
View File
@@ -0,0 +1,68 @@
import request from './request'
const BASE = '/api/production/dispatch-command'
// 创建指令
export function createCommand(data: any) {
return request.post(BASE, data)
}
// 下发指令
export function issueCommand(id: number, issuedBy: number, operatorName?: string) {
return request.post(`${BASE}/${id}/issue`, null, {
params: { issuedBy, operatorName: operatorName || 'system' }
})
}
// 指令台账
export function listCommands(params: {
page?: number; size?: number; status?: string;
commandType?: string; keyword?: string; startDate?: string; endDate?: string
}) {
return request.get(BASE, { params })
}
// 指令详情
export function getCommandDetail(id: number) {
return request.get(`${BASE}/${id}`)
}
// 状态统计
export function getCommandStats() {
return request.get(`${BASE}/stats`)
}
// 接收确认
export function receiveCommand(id: number, userId: number, userName?: string) {
return request.post(`${BASE}/${id}/receive`, null, {
params: { userId, userName: userName || '' }
})
}
// 开始执行
export function startExecute(id: number, userId: number, userName?: string) {
return request.post(`${BASE}/${id}/start-execute`, null, {
params: { userId, userName: userName || '' }
})
}
// 完成执行
export function completeExecution(id: number, userId: number, data: {
userName?: string; feedback?: string; feedbackImages?: string
}) {
return request.post(`${BASE}/${id}/complete`, null, {
params: { userId, ...data }
})
}
// 驳回
export function rejectExecution(id: number, userId: number, reason: string, userName?: string) {
return request.post(`${BASE}/${id}/reject`, null, {
params: { userId, userName: userName || '', reason }
})
}
// 追踪日志
export function getTrackingLogs(id: number) {
return request.get(`${BASE}/${id}/tracking`)
}
+2
View File
@@ -11,6 +11,8 @@ const routes = [
{ path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
{ path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
{ path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
{ path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
{ path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
@@ -0,0 +1,135 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="$emit('update:visible', $event)"
title="创建调度指令"
width="600"
:close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="指令标题" prop="commandTitle">
<el-input v-model="form.commandTitle" placeholder="请输入指令标题" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="指令类型" prop="commandType">
<el-select v-model="form.commandType" placeholder="请选择">
<el-option label="常规" value="normal" />
<el-option label="应急" value="emergency" />
<el-option label="维护" value="maintenance" />
<el-option label="巡检" value="inspection" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-radio-group v-model="form.priority">
<el-radio value="low">低</el-radio>
<el-radio value="normal">普通</el-radio>
<el-radio value="high">高</el-radio>
<el-radio value="urgent">紧急</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="来源" prop="source">
<el-input v-model="form.source" placeholder="手动/系统/报警联动" />
</el-form-item>
<el-form-item label="指令内容" prop="commandContent">
<el-input v-model="form.commandContent" type="textarea" :rows="5"
placeholder="请输入指令详细内容" maxlength="2000" show-word-limit />
</el-form-item>
<el-form-item label="目标类型" prop="targetType">
<el-select v-model="form.targetType" placeholder="请选择">
<el-option label="指定人员" value="user" />
<el-option label="部门" value="dept" />
<el-option label="角色" value="role" />
</el-select>
</el-form-item>
<el-form-item label="目标人员" prop="targetIds">
<el-input v-model="form.targetIds" placeholder="目标ID列表,多个用逗号分隔,如: 1,2,3" />
<div class="form-tip">输入用户ID,多个用逗号分隔</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
创建指令
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { createCommand } from '@/api/dispatchCommand'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'created': []
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const defaultForm = () => ({
commandTitle: '',
commandType: 'normal',
priority: 'normal',
source: '手动',
commandContent: '',
targetType: 'user',
targetIds: '',
remark: ''
})
const form = reactive(defaultForm())
const rules: FormRules = {
commandTitle: [{ required: true, message: '请输入指令标题', trigger: 'blur' }],
commandType: [{ required: true, message: '请选择指令类型', trigger: 'change' }],
priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
commandContent: [{ required: true, message: '请输入指令内容', trigger: 'blur' }],
targetType: [{ required: true, message: '请选择目标类型', trigger: 'change' }],
targetIds: [{ required: true, message: '请输入目标ID', trigger: 'blur' }]
}
watch(() => props.visible, (val) => {
if (val) {
Object.assign(form, defaultForm())
formRef.value?.resetFields()
}
})
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate()
submitting.value = true
try {
// 将 targetIds 转为 JSON 数组格式
const targetIdsArr = form.targetIds.split(',').map((s: string) => s.trim()).filter(Boolean)
await createCommand({
...form,
targetIds: JSON.stringify(targetIdsArr.map(Number))
})
ElMessage.success('指令创建成功')
emit('update:visible', false)
emit('created')
} catch (e: any) {
ElMessage.error(e.message || '创建失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
</style>
@@ -0,0 +1,304 @@
<template>
<div class="command-detail" v-loading="loading">
<!-- 返回按钮 -->
<el-page-header @back="router.back()" :title="'返回'" style="margin-bottom: 16px">
<template #content>
<span class="page-title">指令详情</span>
<el-tag :type="statusTag(detail.status)" style="margin-left: 12px">{{ statusLabel(detail.status) }}</el-tag>
</template>
</el-page-header>
<el-row :gutter="16">
<!-- 左侧:基本信息 + 状态流转图 -->
<el-col :span="14">
<el-card>
<template #header>
<span>{{ detail.command_title }}</span>
<el-tag size="small" style="margin-left: 8px">{{ detail.command_no }}</el-tag>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="指令编号">{{ detail.command_no }}</el-descriptions-item>
<el-descriptions-item label="类型">
<el-tag size="small">{{ typeLabel(detail.command_type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detail.priority)" size="small">{{ priorityLabel(detail.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="来源">{{ detail.source || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.created_at }}</el-descriptions-item>
<el-descriptions-item label="下发时间">{{ detail.issued_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ detail.completed_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标类型">{{ detail.target_type || '-' }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 16px">
<h4>指令内容</h4>
<div class="content-block">{{ detail.command_content }}</div>
</div>
<!-- 状态流转图 -->
<div style="margin-top: 24px">
<h4>状态流转</h4>
<el-steps :active="statusStep(detail.status)" finish-status="success" align-center>
<el-step title="草稿" description="创建指令" />
<el-step title="已下发" description="下发给执行人" />
<el-step title="已接收" description="执行人确认" />
<el-step title="执行中" description="正在执行" />
<el-step title="完成/驳回" description="归档" />
</el-steps>
</div>
</el-card>
</el-col>
<!-- 右侧:执行记录列表 -->
<el-col :span="10">
<el-card>
<template #header>执行记录</template>
<el-timeline v-if="executions.length">
<el-timeline-item
v-for="exec in executions" :key="exec.id"
:type="executionTimelineType(exec.execute_status)"
:timestamp="exec.received_at || exec.created_at"
placement="top">
<div class="exec-card">
<div class="exec-header">
<span class="exec-user">{{ exec.user_name || `用户${exec.user_id}` }}</span>
<el-tag :type="executionStatusTag(exec.execute_status)" size="small">
{{ executionStatusLabel(exec.execute_status) }}
</el-tag>
</div>
<div v-if="exec.feedback" class="exec-feedback">
反馈: {{ exec.feedback }}
</div>
<div v-if="exec.rejected_reason" class="exec-reject">
驳回原因: {{ exec.rejected_reason }}
</div>
<div class="exec-actions" v-if="canOperate(exec)">
<el-button size="small" type="primary"
v-if="exec.execute_status === 'pending'"
@click="handleReceive(exec)">接收</el-button>
<el-button size="small" type="success"
v-if="exec.execute_status === 'received'"
@click="handleStartExecute(exec)">开始执行</el-button>
<el-button size="small" type="success"
v-if="exec.execute_status === 'executing'"
@click="showCompleteDialog(exec)">完成</el-button>
<el-button size="small" type="danger"
v-if="exec.execute_status !== 'completed' && exec.execute_status !== 'rejected'"
@click="handleReject(exec)">驳回</el-button>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无执行记录" />
</el-card>
</el-col>
</el-row>
<!-- 追踪日志 -->
<el-card style="margin-top: 16px">
<template #header>全过程追踪日志</template>
<el-timeline>
<el-timeline-item
v-for="log in trackingLogs" :key="log.id"
:timestamp="log.created_at" placement="top"
:type="trackingType(log.action)">
<div>
<el-tag size="small" :type="trackingType(log.action)">{{ trackingActionLabel(log.action) }}</el-tag>
<span style="margin-left: 8px">{{ log.operator_name || '' }}</span>
<span v-if="log.from_status" style="margin-left: 8px; color: #909399">
{{ log.from_status }} → {{ log.to_status }}
</span>
<div v-if="log.remark" style="color: #606266; margin-top: 4px">{{ log.remark }}</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!trackingLogs.length" description="暂无追踪日志" />
</el-card>
<!-- 完成弹窗 -->
<el-dialog v-model="completeVisible" title="完成执行" width="500">
<el-form label-width="80px">
<el-form-item label="反馈说明">
<el-input v-model="completeForm.feedback" type="textarea" :rows="3" placeholder="请输入执行反馈" />
</el-form-item>
<el-form-item label="反馈图片">
<el-input v-model="completeForm.feedbackImages" placeholder="图片URL,多个用逗号分隔" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="completeVisible = false">取消</el-button>
<el-button type="primary" @click="handleComplete">确认完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCommandDetail, receiveCommand, startExecute, completeExecution, rejectExecution } from '@/api/dispatchCommand'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<any>({})
const executions = ref<any[]>([])
const trackingLogs = ref<any[]>([])
const completeVisible = ref(false)
const currentExec = ref<any>(null)
const completeForm = reactive({ feedback: '', feedbackImages: '' })
const commandId = Number(route.params.id)
// 模拟当前用户ID(实际应从登录态获取)
const currentUserId = 1
const currentUserName = 'admin'
const statusMap: Record<string, { label: string; type: string; step: number }> = {
draft: { label: '草稿', type: 'info', step: 0 },
issued: { label: '已下发', type: 'warning', step: 1 },
received: { label: '已接收', type: '', step: 2 },
executing: { label: '执行中', type: 'primary', step: 3 },
completed: { label: '已完成', type: 'success', step: 4 },
rejected: { label: '已驳回', type: 'danger', step: 4 }
}
const statusLabel = (s: string) => statusMap[s]?.label || s
const statusTag = (s: string) => (statusMap[s]?.type || 'info') as any
const statusStep = (s: string) => statusMap[s]?.step || 0
const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
const typeLabel = (t: string) => typeMap[t] || t
const priorityMap: Record<string, { label: string; type: string }> = {
low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
}
const priorityLabel = (p: string) => priorityMap[p]?.label || p
const priorityTag = (p: string) => (priorityMap[p]?.type || 'info') as any
const executionStatusLabel = (s: string) => {
const map: Record<string, string> = {
pending: '待接收', received: '已接收', executing: '执行中', completed: '已完成', rejected: '已驳回'
}
return map[s] || s
}
const executionStatusTag = (s: string) => {
const map: Record<string, string> = {
pending: 'info', received: '', executing: 'primary', completed: 'success', rejected: 'danger'
}
return (map[s] || 'info') as any
}
const executionTimelineType = (s: string) => {
const map: Record<string, string> = {
pending: 'info', received: 'primary', executing: 'primary', completed: 'success', rejected: 'danger'
}
return (map[s] || 'info') as any
}
const trackingActionLabel = (a: string) => {
const map: Record<string, string> = {
create: '创建', issue: '下发', receive: '接收', start_execute: '开始执行',
complete: '完成', reject: '驳回', cancel: '取消'
}
return map[a] || a
}
const trackingType = (a: string) => {
const map: Record<string, string> = {
create: 'info', issue: 'warning', receive: 'primary', start_execute: 'primary',
complete: 'success', reject: 'danger', cancel: 'danger'
}
return (map[a] || 'info') as any
}
function canOperate(exec: any) {
return exec.user_id === currentUserId || true // 简化:所有人可操作
}
async function fetchDetail() {
loading.value = true
try {
const res = await getCommandDetail(commandId)
detail.value = res.data || {}
executions.value = res.data?.executions || []
trackingLogs.value = res.data?.tracking_logs || res.data?.trackingLogs || []
} finally {
loading.value = false
}
}
async function handleReceive(exec: any) {
try {
await receiveCommand(commandId, exec.user_id, exec.user_name)
ElMessage.success('接收成功')
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleStartExecute(exec: any) {
try {
await startExecute(commandId, exec.user_id, exec.user_name)
ElMessage.success('已开始执行')
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
function showCompleteDialog(exec: any) {
currentExec.value = exec
completeForm.feedback = ''
completeForm.feedbackImages = ''
completeVisible.value = true
}
async function handleComplete() {
try {
await completeExecution(commandId, currentExec.value.user_id, {
userName: currentExec.value.user_name,
feedback: completeForm.feedback,
feedbackImages: completeForm.feedbackImages
})
ElMessage.success('执行完成')
completeVisible.value = false
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleReject(exec: any) {
try {
const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '驳回原因不能为空'
})
await rejectExecution(commandId, exec.user_id, value, exec.user_name)
ElMessage.success('已驳回')
fetchDetail()
} catch { /* cancel */ }
}
onMounted(fetchDetail)
</script>
<style scoped>
.page-title { font-size: 16px; font-weight: 600; }
.content-block {
padding: 12px; background: #f5f7fa; border-radius: 4px;
white-space: pre-wrap; line-height: 1.6;
}
.exec-card { padding: 4px 0; }
.exec-header { display: flex; justify-content: space-between; align-items: center; }
.exec-user { font-weight: 600; }
.exec-feedback { margin-top: 6px; color: #606266; font-size: 13px; }
.exec-reject { margin-top: 6px; color: #f56c6c; font-size: 13px; }
.exec-actions { margin-top: 8px; }
</style>
@@ -0,0 +1,193 @@
<template>
<div class="command-list">
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="编号/标题" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" value="draft" />
<el-option label="已下发" value="issued" />
<el-option label="已接收" value="received" />
<el-option label="执行中" value="executing" />
<el-option label="已完成" value="completed" />
<el-option label="已驳回" value="rejected" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filterForm.commandType" placeholder="全部" clearable @change="handleSearch">
<el-option label="常规" value="normal" />
<el-option label="应急" value="emergency" />
<el-option label="维护" value="maintenance" />
<el-option label="巡检" value="inspection" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
@change="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-row :gutter="12" style="margin-top: 12px">
<el-col :span="4" v-for="stat in stats" :key="stat.status">
<el-card shadow="hover" class="stat-card" @click="filterByStatus(stat.status)">
<div class="stat-value">{{ stat.count }}</div>
<div class="stat-label">{{ statusLabel(stat.status) }}</div>
</el-card>
</el-col>
</el-row>
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
<el-button type="primary" @click="showCreateDialog = true"><el-icon><Plus /></el-icon> 创建指令</el-button>
<el-button @click="handleBatchIssue" :disabled="!selectedIds.length">批量下发</el-button>
</div>
<el-table :data="tableData" border style="margin-top: 10px"
@selection-change="handleSelectionChange" v-loading="loading">
<el-table-column type="selection" width="50" />
<el-table-column prop="command_no" label="指令编号" width="220" />
<el-table-column prop="command_title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="command_type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="typeTag(row.command_type)" size="small">{{ typeLabel(row.command_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="80">
<template #default="{ row }">
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="执行进度" width="120">
<template #default="{ row }">
<span>{{ row.completed_count || 0 }}/{{ row.total_executions || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">详情</el-button>
<el-button link type="success" v-if="row.status === 'draft'" @click="handleIssue(row)">下发</el-button>
<el-button link type="danger" v-if="row.status === 'draft'" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<CommandCreate v-model:visible="showCreateDialog" @created="handleCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import { listCommands, issueCommand, getCommandStats } from '@/api/dispatchCommand'
import CommandCreate from './CommandCreate.vue'
const router = useRouter()
const loading = ref(false)
const tableData = ref<any[]>([])
const stats = ref<any[]>([])
const selectedIds = ref<number[]>([])
const showCreateDialog = ref(false)
const dateRange = ref<[string, string] | null>(null)
const filterForm = reactive({ keyword: '', status: '', commandType: '' })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const statusMap: Record<string, { label: string; type: string }> = {
draft: { label: '草稿', type: 'info' },
issued: { label: '已下发', type: 'warning' },
received: { label: '已接收', type: '' },
executing: { label: '执行中', type: 'primary' },
completed: { label: '已完成', type: 'success' },
rejected: { label: '已驳回', type: 'danger' }
}
const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
const priorityMap: Record<string, { label: string; type: string }> = {
low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
}
function statusLabel(s: string) { return statusMap[s]?.label || s }
function statusTag(s: string) { return (statusMap[s]?.type || 'info') as any }
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string) { return t === 'emergency' ? 'danger' : t === 'maintenance' ? 'warning' : '' }
function priorityLabel(p: string) { return priorityMap[p]?.label || p }
function priorityTag(p: string) { return (priorityMap[p]?.type || 'info') as any }
async function fetchData() {
loading.value = true
try {
const res = await listCommands({
page: pagination.page, size: pagination.size,
status: filterForm.status || undefined,
commandType: filterForm.commandType || undefined,
keyword: filterForm.keyword || undefined,
startDate: dateRange.value?.[0], endDate: dateRange.value?.[1]
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally { loading.value = false }
}
async function fetchStats() {
try { const res = await getCommandStats(); stats.value = res.data || [] } catch { /* ignore */ }
}
function handleSearch() { pagination.page = 1; fetchData() }
function handleReset() {
filterForm.keyword = ''; filterForm.status = ''; filterForm.commandType = ''; dateRange.value = null; handleSearch()
}
function filterByStatus(status: string) { filterForm.status = status; handleSearch() }
function handleSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id) }
function viewDetail(row: any) { router.push({ path: `/dispatch-command/${row.id}` }) }
async function handleIssue(row: any) {
try {
await ElMessageBox.confirm(`确认下发指令 "${row.command_title}" ?`, '下发确认')
await issueCommand(row.id, 1, 'admin'); ElMessage.success('指令已下发'); fetchData(); fetchStats()
} catch { /* cancel */ }
}
async function handleBatchIssue() {
try {
await ElMessageBox.confirm(`确认批量下发 ${selectedIds.value.length} 条指令?`, '批量下发')
for (const id of selectedIds.value) { await issueCommand(id, 1, 'admin') }
ElMessage.success('批量下发完成'); fetchData(); fetchStats()
} catch { /* cancel */ }
}
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除指令 "${row.command_title}" ?`, '删除确认', { type: 'warning' })
.then(() => { ElMessage.info('删除功能待实现(逻辑删除)') }).catch(() => { /* cancel */ })
}
function handleCreated() { showCreateDialog.value = false; fetchData(); fetchStats() }
onMounted(() => { fetchData(); fetchStats() })
</script>
<style scoped>
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
.stat-card { cursor: pointer; text-align: center; }
.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
</style>
@@ -0,0 +1,558 @@
<template>
<div class="problem-reporting">
<el-card class="reporting-form">
<template #header>
<div class="card-header">
<span>巡检问题上报</span>
<el-tag type="success">{{ problemCount }} 个问题待处理</el-tag>
</div>
</template>
<el-form
ref="problemForm"
:model="problemForm"
:rules="rules"
label-width="120px"
@submit.prevent="submitProblem"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="问题类型" prop="problemType">
<el-select
v-model="problemForm.problemType"
placeholder="请选择问题类型"
style="width: 100%"
>
<el-option label="设备故障" value="设备故障" />
<el-option label="水质异常" value="水质异常" />
<el-option label="安全隐患" value="安全隐患" />
<el-option label="环境卫生" value="环境卫生" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="问题级别" prop="problemLevel">
<el-select
v-model="problemForm.problemLevel"
placeholder="请选择问题级别"
style="width: 100%"
>
<el-option label="低" value="low" />
<el-option label="普通" value="normal" />
<el-option label="高" value="high" />
<el-option label="紧急" value="critical" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="问题标题" prop="problemTitle">
<el-input
v-model="problemForm.problemTitle"
placeholder="请输入问题标题"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="问题描述" prop="problemDescription">
<el-input
v-model="problemForm.problemDescription"
type="textarea"
:rows="4"
placeholder="请详细描述问题情况"
maxlength="1000"
show-word-limit
/>
</el-form-item>
<el-form-item label="问题位置" prop="location">
<el-input
v-model="problemForm.location"
placeholder="请输入问题发生位置"
maxlength="300"
show-word-limit
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="problemForm.deviceName"
placeholder="请输入相关设备名称"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="经纬度">
<el-input
v-model="coordinates"
placeholder="经度, 纬度"
readonly
>
<template #append>
<el-button @click="getCurrentLocation">获取位置</el-button>
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="现场照片">
<el-upload
v-model:file-list="fileList"
action="/api/upload"
list-type="picture-card"
:limit="5"
:on-success="handleUploadSuccess"
:on-remove="handleRemove"
:before-upload="beforeUpload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="upload-tip">最多上传5张照片,支持JPG、PNG格式</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="submitProblem"
:loading="submitting"
>
{{ isEditing ? '更新问题' : '提交问题' }}
</el-button>
<el-button @click="resetForm">重置</el-button>
<el-button v-if="isEditing" @click="cancelEdit">取消编辑</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 问题列表 -->
<el-card class="problem-list">
<template #header>
<div class="card-header">
<span>问题列表</span>
<el-input
v-model="searchQuery"
placeholder="搜索问题..."
style="width: 200px"
clearable
/>
</div>
</template>
<el-table
:data="filteredProblems"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="problemNo" label="问题编号" width="120" />
<el-table-column prop="problemTitle" label="问题标题" min-width="200" />
<el-table-column prop="problemType" label="问题类型" width="120" />
<el-table-column prop="problemLevel" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getLevelType(row.problemLevel)">
{{ getLevelText(row.problemLevel) }}
</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="reportTime" label="上报时间" width="180">
<template #default="{ row }">
{{ formatDate(row.reportTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
size="small"
@click="viewProblem(row)"
>
查看
</el-button>
<el-button
size="small"
type="primary"
@click="editProblem(row)"
v-if="row.status === 'reported'"
>
编辑
</el-button>
<el-button
size="small"
type="success"
@click="createWorkOrder(row)"
v-if="row.status === 'reported' && !row.workOrderId"
>
创建工单
</el-button>
<el-button
size="small"
type="info"
@click="viewWorkOrder(row)"
v-if="row.workOrderId"
>
查看工单
</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="totalProblems"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import axios from 'axios'
const problemForm = ref({
id: null,
taskId: null,
pointSeq: null,
deviceId: null,
deviceName: '',
problemType: '',
problemLevel: 'normal',
problemTitle: '',
problemDescription: '',
location: '',
lng: null,
lat: null,
photoUrls: [],
reporterId: 1, // 当前用户ID
reporterName: '巡检员',
status: 'reported'
})
const fileList = ref([])
const coordinates = ref('')
const submitting = ref(false)
const loading = ref(false)
const problems = ref([])
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const totalProblems = ref(0)
const isEditing = ref(false)
// 表单验证规则
const rules = {
problemType: [{ required: true, message: '请选择问题类型', trigger: 'change' }],
problemTitle: [{ required: true, message: '请输入问题标题', trigger: 'blur' }],
problemDescription: [{ required: true, message: '请输入问题描述', trigger: 'blur' }],
location: [{ required: true, message: '请输入问题位置', trigger: 'blur' }]
}
// 计算属性
const problemCount = computed(() => {
return problems.value.filter(p => p.status === 'reported').length
})
const filteredProblems = computed(() => {
let filtered = problems.value
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(p =>
p.problemTitle.toLowerCase().includes(query) ||
p.problemType.toLowerCase().includes(query) ||
p.problemNo.toLowerCase().includes(query)
)
}
return filtered
})
// 获取问题列表
const fetchProblems = async () => {
loading.value = true
try {
const response = await axios.get('/api/patrol/problems/status/reported')
problems.value = response.data
totalProblems.value = problems.value.length
} catch (error) {
console.error('获取问题列表失败:', error)
ElMessage.error('获取问题列表失败')
} finally {
loading.value = false
}
}
// 提交问题
const submitProblem = async () => {
try {
const formRef = document.querySelector('.problem-reporting .reporting-form form')
if (!formRef) return
// 这里可以添加表单验证逻辑
submitting.value = true
// 处理文件上传
const photoUrls = []
for (const file of fileList.value) {
if (file.response) {
photoUrls.push(file.response.url)
}
}
problemForm.value.photoUrls = photoUrls
// 解析坐标
if (coordinates.value) {
const [lng, lat] = coordinates.value.split(',').map(s => parseFloat(s.trim()))
problemForm.value.lng = lng
problemForm.value.lat = lat
}
const response = await axios.post('/api/patrol/problems', problemForm.value)
if (response.data) {
ElMessage.success('问题提交成功')
resetForm()
fetchProblems()
}
} catch (error) {
console.error('提交问题失败:', error)
ElMessage.error('提交问题失败')
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
problemForm.value = {
id: null,
taskId: null,
pointSeq: null,
deviceId: null,
deviceName: '',
problemType: '',
problemLevel: 'normal',
problemTitle: '',
problemDescription: '',
location: '',
lng: null,
lat: null,
photoUrls: [],
reporterId: 1,
reporterName: '巡检员',
status: 'reported'
}
fileList.value = []
coordinates.value = ''
isEditing.value = false
}
// 编辑问题
const editProblem = (problem) => {
problemForm.value = { ...problem }
fileList.value = problem.photoUrls.map(url => ({ url, name: url }))
coordinates.value = problem.lng && problem.lat ? `${problem.lng}, ${problem.lat}` : ''
isEditing.value = true
}
// 取消编辑
const cancelEdit = () => {
resetForm()
}
// 查看问题详情
const viewProblem = (problem) => {
ElMessageBox.alert(
`问题编号:${problem.problemNo}\n` +
`问题类型:${problem.problemType}\n` +
`问题级别:${problem.problemLevel}\n` +
`问题标题:${problem.problemTitle}\n` +
`问题位置:${problem.location}\n` +
`问题描述:${problem.problemDescription}\n` +
`上报时间:${formatDate(problem.reportTime)}`,
'问题详情',
{ confirmButtonText: '确定' }
)
}
// 创建工单
const createWorkOrder = (problem) => {
ElMessageBox.confirm(
`确认为问题 "${problem.problemTitle}" 创建工单吗?`,
'创建工单',
{ confirmButtonText: '确定', cancelButtonText: '取消' }
).then(async () => {
try {
const response = await axios.post(`/api/patrol/problems/${problem.id}/auto-create-work-order`)
if (response.data) {
ElMessage.success('工单创建成功')
fetchProblems()
}
} catch (error) {
console.error('创建工单失败:', error)
ElMessage.error('创建工单失败')
}
})
}
// 查看工单
const viewWorkOrder = (problem) => {
// 这里可以跳转到工单详情页
ElMessage.info(`查看工单 ${problem.workOrderId}`)
}
// 获取当前位置
const getCurrentLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords
coordinates.value = `${longitude}, ${latitude}`
problemForm.value.lng = longitude
problemForm.value.lat = latitude
},
(error) => {
ElMessage.error('获取位置失败:' + error.message)
}
)
} else {
ElMessage.error('浏览器不支持地理位置定位')
}
}
// 文件上传相关
const handleUploadSuccess = (response, file) => {
file.url = response.url
}
const handleRemove = (file, fileList) => {
fileList.value = fileList
}
const beforeUpload = (file) => {
const isJPG = file.type === 'image/jpeg'
const isPNG = file.type === 'image/png'
const isLt5M = file.size / 1024 / 1024 < 5
if (!isJPG && !isPNG) {
ElMessage.error('上传图片只能是 JPG 或 PNG 格式!')
return false
}
if (!isLt5M) {
ElMessage.error('上传图片大小不能超过 5MB!')
return false
}
return true
}
// 辅助函数
const getLevelType = (level) => {
switch (level) {
case 'low': return 'info'
case 'normal': return ''
case 'high': return 'warning'
case 'critical': return 'danger'
default: return ''
}
}
const getLevelText = (level) => {
switch (level) {
case 'low': return '低'
case 'normal': return '普通'
case 'high': return '高'
case 'critical': return '紧急'
default: return level
}
}
const getStatusType = (status) => {
switch (status) {
case 'reported': return 'warning'
case 'processing': return 'primary'
case 'completed': return 'success'
case 'closed': return 'info'
default: return ''
}
}
const getStatusText = (status) => {
switch (status) {
case 'reported': return '已上报'
case 'processing': return '处理中'
case 'completed': return '已完成'
case 'closed': return '已关闭'
default: return status
}
}
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleString()
}
const handleSizeChange = (val) => {
pageSize.value = val
fetchProblems()
}
const handleCurrentChange = (val) => {
currentPage.value = val
fetchProblems()
}
// 初始化
onMounted(() => {
fetchProblems()
})
</script>
<style scoped>
.problem-reporting {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.upload-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.pagination {
margin-top: 20px;
text-align: right;
}
.problem-list {
margin-top: 20px;
}
</style>