feat(wm-production): #69 调度指令管理完整实现

- 实体: DispatchCommand/DispatchExecution/DispatchTracking
- Mapper: MyBatis-Plus + XML (含台账分页/详情/统计)
- Service: 完整状态机 (draft→issued→received→executing→completed/rejected)
- Controller: /api/production/dispatch-command (全生命周期API)
- SQL DDL: 三表+索引
- 前端: CommandList/CommandDetail/CommandCreate (Vue3+TS+Element Plus)
- 单元测试: DispatchCommandServiceTest + DispatchTrackingServiceTest
This commit is contained in:
2026-06-14 15:30:52 +08:00
parent 21fa7cffd2
commit 6c6db59ba9
18 changed files with 1715 additions and 0 deletions
@@ -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>