- 从物料跟踪页面移除订单号列和表单字段 - 从导航菜单移除PDI管理,添加设备巡检 - 新增InspectionLocation和InspectionRecord后端模型和API - 新增设备巡检前端页面(左侧点位列表,右侧设备和历史记录)
247 lines
9.1 KiB
Vue
247 lines
9.1 KiB
Vue
<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>
|