feat: 移除PDI和订单号字段,新增设备巡检模块

- 从物料跟踪页面移除订单号列和表单字段
- 从导航菜单移除PDI管理,添加设备巡检
- 新增InspectionLocation和InspectionRecord后端模型和API
- 新增设备巡检前端页面(左侧点位列表,右侧设备和历史记录)
This commit is contained in:
2026-05-27 16:38:40 +08:00
commit 193da0018f
86 changed files with 11379 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
<template>
<div>
<!-- 指标卡 -->
<div class="grid-5" style="gap:10px;">
<div class="metric-box" v-for="c in statCards" :key="c.label">
<div class="mb-label">{{ c.label }}</div>
<div class="mb-value" :style="{ color: c.color || 'var(--sms-highlight)' }">{{ c.value }}</div>
<div class="mb-unit">{{ c.unit }}</div>
</div>
</div>
<!-- 当前在线卷 -->
<div class="card">
<div class="card-header">
当前在线钢卷状态
<span class="ch-badge">实时</span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>卷号</th><th>钢种</th><th>厚度(mm)</th><th>宽度(mm)</th>
<th>当前位置</th><th>速度(m/min)</th><th>入线时间</th><th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="r in onlineCoils" :key="r.coil_no">
<td class="td-num">{{ r.coil_no }}</td>
<td>{{ r.steel_grade || '—' }}</td>
<td class="td-num">{{ r.spec_thickness || '—' }}</td>
<td class="td-num">{{ r.spec_width || '—' }}</td>
<td>{{ r.position || '酸洗段' }}</td>
<td class="td-num">{{ r.speed || '85.3' }}</td>
<td class="td-muted">{{ formatTime(r.created_at) }}</td>
<td><span class="badge badge-green">在线</span></td>
</tr>
<tr v-if="!onlineCoils.length">
<td colspan="8" class="td-muted" style="text-align:center;padding:20px;">暂无在线钢卷</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 趋势 + 停机统计 -->
<div class="section-row">
<div class="card" style="flex:2;">
<div class="card-header">今日产量趋势/小时</div>
<div class="card-body">
<canvas ref="trendChart" style="width:100%;height:140px;display:block;"></canvas>
</div>
</div>
<div class="card" style="flex:1;">
<div class="card-header">今日停机统计</div>
<div class="card-body" style="padding:0;">
<table class="data-table">
<thead><tr><th>停机类别</th><th>次数</th><th>时长(min)</th></tr></thead>
<tbody>
<tr v-for="d in downtimeStat" :key="d.name">
<td>{{ d.name }}</td>
<td class="td-num">{{ d.count }}</td>
<td class="td-warn">{{ d.duration }}</td>
</tr>
<tr v-if="!downtimeStat.length">
<td colspan="3" class="td-muted" style="text-align:center;padding:16px;">今日无停机记录</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 班次实绩 -->
<div class="card">
<div class="card-header">今日班次实绩汇总</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr><th>班次</th><th>计划()</th><th>实际()</th><th>完成率</th><th>产量(t)</th><th>平均速度</th><th>停机时长</th></tr>
</thead>
<tbody>
<tr v-for="s in shiftSummary" :key="s.shift">
<td>{{ s.shift }}</td>
<td class="td-num">{{ s.plan }}</td>
<td class="td-num">{{ s.actual }}</td>
<td>
<div class="prog-bar-wrap" style="width:80px;display:inline-block;vertical-align:middle;margin-right:6px;">
<div class="prog-bar-fill" :style="{ width: s.rate + '%', background: s.rate >= 90 ? 'var(--accent-green)' : s.rate >= 70 ? 'var(--accent-yellow)' : 'var(--accent-red)' }"></div>
</div>
<span :class="s.rate >= 90 ? 'td-ok' : s.rate >= 70 ? 'td-warn' : 'td-err'">{{ s.rate }}%</span>
</td>
<td class="td-num">{{ s.weight }}</td>
<td class="td-num">{{ s.speed }} m/min</td>
<td :class="s.downtime > 30 ? 'td-warn' : 'td-ok'">{{ s.downtime }} min</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
import { getDashboardSummary, getCoils, getDowntimeRecords, getProductionRecords } from '@/api'
export default {
name: 'Dashboard',
data() {
return {
summary: null,
onlineCoils: [],
downtimeStat: [],
shiftSummary: [],
chart: null,
}
},
computed: {
statCards() {
const s = this.summary
return [
{ label: '今日产量', value: s?.today_production?.coil_count ?? 0, unit: '卷', color: 'var(--sms-highlight)' },
{ label: '今日产量', value: s ? (s.today_production.weight_kg / 1000).toFixed(1) : '0', unit: '吨' },
{ label: '在线钢卷', value: s?.online_coils ?? 0, unit: '卷', color: 'var(--accent-green)' },
{ label: '今日停机', value: s?.today_downtime_min?.toFixed(0) ?? '0', unit: 'min', color: s?.today_downtime_min > 60 ? 'var(--accent-red)' : 'var(--accent-yellow)' },
{ label: '机组速度', value: '85.3', unit: 'm/min' },
]
}
},
async mounted() {
await this.fetchData()
},
methods: {
async fetchData() {
try {
const [summaryRes, coilRes, downtimeRes, prodRes] = await Promise.all([
getDashboardSummary(),
getCoils({ status: 'on_line', page_size: 10 }),
getDowntimeRecords({ page_size: 50 }),
getProductionRecords({ page_size: 100 }),
])
this.summary = summaryRes.data
this.onlineCoils = coilRes.data?.items || []
// 停机统计(按类别汇总)
const dtItems = downtimeRes.data?.items || []
const dtMap = {}
dtItems.forEach(d => {
const k = d.category_name || d.category_code || '其他'
if (!dtMap[k]) dtMap[k] = { name: k, count: 0, duration: 0 }
dtMap[k].count++
dtMap[k].duration += d.duration_min || 0
})
this.downtimeStat = Object.values(dtMap).sort((a, b) => b.duration - a.duration).slice(0, 6)
// 班次汇总(按班次聚合产量)
const prods = prodRes.data?.items || []
const shiftMap = {}
prods.forEach(p => {
const k = p.shift || '—'
if (!shiftMap[k]) shiftMap[k] = { shift: k, count: 0, weight: 0, speeds: [] }
shiftMap[k].count++
shiftMap[k].weight += (p.coil_weight_t || 0)
if (p.avg_speed) shiftMap[k].speeds.push(p.avg_speed)
})
this.shiftSummary = Object.values(shiftMap).map(s => ({
shift: s.shift,
actual: s.count,
weight: s.weight.toFixed(1),
speed: s.speeds.length ? (s.speeds.reduce((a,b)=>a+b,0)/s.speeds.length).toFixed(1) : '—',
}))
// 图表数据:按小时统计产量卷数
const hourMap = Array(12).fill(0)
prods.forEach(p => {
if (p.start_time) {
const h = Math.floor(new Date(p.start_time).getHours() / 2)
if (h >= 0 && h < 12) hourMap[h]++
}
})
this.$nextTick(() => this.drawChart(hourMap))
} catch (e) {
this.$nextTick(() => this.drawChart(Array(12).fill(0)))
}
},
formatTime(t) {
if (!t) return '—'
return t.replace('T', ' ').slice(0, 16)
},
drawChart(data) {
const canvas = this.$refs.trendChart
if (!canvas) return
const ctx = canvas.getContext('2d')
canvas.width = canvas.offsetWidth * devicePixelRatio
canvas.height = canvas.offsetHeight * devicePixelRatio
ctx.scale(devicePixelRatio, devicePixelRatio)
const W = canvas.offsetWidth, H = canvas.offsetHeight
data = data || Array(12).fill(0)
const labels = Array.from({length:12}, (_,i) => `${i*2}:00`)
const max = Math.max(...data) + 2
ctx.clearRect(0,0,W,H)
// 网格线
ctx.strokeStyle = '#30363d'
ctx.lineWidth = .5
for (let i = 0; i <= 4; i++) {
const y = H - (i / 4) * (H - 20) - 10
ctx.beginPath(); ctx.moveTo(40, y); ctx.lineTo(W - 10, y); ctx.stroke()
}
// 折线
const stepX = (W - 50) / (data.length - 1)
const toY = v => H - (v / max) * (H - 30) - 10
const pts = data.map((v, i) => [40 + i * stepX, toY(v)])
// 填充
ctx.beginPath()
ctx.moveTo(pts[0][0], H - 10)
pts.forEach(([x, y]) => ctx.lineTo(x, y))
ctx.lineTo(pts[pts.length-1][0], H - 10)
ctx.closePath()
const grad = ctx.createLinearGradient(0, 0, 0, H)
grad.addColorStop(0, 'rgba(0,200,255,.25)')
grad.addColorStop(1, 'rgba(0,200,255,0)')
ctx.fillStyle = grad
ctx.fill()
// 线条
ctx.beginPath()
ctx.strokeStyle = '#00c8ff'
ctx.lineWidth = 1.5
pts.forEach(([x,y], i) => i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y))
ctx.stroke()
// 数据点
ctx.fillStyle = '#00c8ff'
pts.forEach(([x,y]) => { ctx.beginPath(); ctx.arc(x,y,3,0,Math.PI*2); ctx.fill() })
// X轴标签
ctx.fillStyle = '#6e7681'
ctx.font = '10px Consolas'
ctx.textAlign = 'center'
labels.forEach((l, i) => ctx.fillText(l, 40 + i * stepX, H))
}
}
}
</script>