Files
pickling-mes/frontend/src/views/EntryTracking.vue
wangyu 72004568e1 fix(entry): 恢复入口跟踪全部设备工位,修复鞍座联动状态竞争
- 还原入口跟踪原有设备网格(2行):上卷小车/称重位/地辊/倒卷小车,仅上卷鞍座保留一个
- 移动可在所有入口设备间进行,仅「上卷鞍座」进入生产环节
- 修复 ensure_online 误将鞍座暂存计划回退导致卡死:移动到鞍座直接置生产中,
  ensure_online 排除 on_saddle 计划

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:44:51 +08:00

293 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="entry-page">
<!-- 上卷鞍座唯一进入生产的工位 -->
<div class="card">
<div class="card-header">
上卷鞍座
<span class="ch-badge">{{ saddle ? statusLabel(saddle.status) : '空闲' }}</span>
<span style="margin-left:auto;display:flex;gap:8px;align-items:center;">
<button v-if="saddle && saddle.status !== 'producing'" class="btn btn-primary" @click="commit(saddle)">投入生产</button>
<button class="btn btn-outline" @click="refreshAll">刷新</button>
</span>
</div>
<div class="card-body">
<div v-if="saddle" class="saddle-station">
<div class="saddle-info">
<div class="si-row"><span class="si-k">冷卷号</span><span class="si-v hl">{{ saddle.cold_coil_no || saddle.plan_no }}</span></div>
<div class="si-row"><span class="si-k">热卷号</span><span class="si-v">{{ saddle.hot_coil_no || '—' }}</span></div>
<div class="si-row"><span class="si-k">钢种</span><span class="si-v">{{ saddle.steel_grade || '—' }}</span></div>
<div class="si-row"><span class="si-k">规格(×)</span><span class="si-v">{{ fmt(saddle.product_thickness, 2) }} × {{ fmt(saddle.product_width, 0) }}</span></div>
<div class="si-row"><span class="si-k">来料重量[t]</span><span class="si-v">{{ fmt(saddle.incoming_weight, 3) }}</span></div>
<div class="si-row"><span class="si-k">轧制模式</span><span class="si-v">{{ saddle.rolling_mode || '—' }}</span></div>
</div>
<div class="saddle-run">
<div class="metric-box">
<span class="mb-label">线速度</span>
<span class="mb-value">{{ fmt(saddle.run_speed, 0) }}</span>
<span class="mb-unit">m/min</span>
</div>
<div class="metric-box">
<span class="mb-label">带头长度 / 目标</span>
<span class="mb-value">{{ fmt(saddle.run_length_m, 0) }}</span>
<span class="mb-unit">/ {{ TARGET }} m</span>
</div>
<div class="run-prog">
<div class="rp-head">
<span>生产进度</span>
<span class="rp-pct">{{ progPct(saddle).toFixed(1) }}%</span>
</div>
<div class="prog-bar-wrap" style="height:10px;">
<div class="prog-bar-fill" :style="{ width: progPct(saddle) + '%', background: progColor(saddle) }"></div>
</div>
<div class="rp-tip">带头到达 {{ TARGET }} m 后自动产出实绩并完成下一卷需从队列再次移动到鞍座</div>
</div>
</div>
</div>
<div v-else class="saddle-empty">
上卷鞍座空闲 点击计划移动并选择上卷鞍座即可上卷生产
</div>
</div>
</div>
<!-- 入口跟踪设备位置 -->
<div class="card">
<div class="card-header">入口跟踪 <span class="ch-badge">{{ equipCols.length }} × 2 工位</span></div>
<div class="entry-grid">
<div v-for="(row, ri) in equipRows" :key="ri" class="entry-row">
<div
v-for="pos in row"
:key="pos"
:class="['pos-cell', { filled: !!occupantOf(pos) }]"
>
<div class="pos-title">{{ pos }}</div>
<table class="pos-table">
<tbody>
<tr><td class="k">冷卷号</td><td class="v">{{ occ(pos,'cold_coil_no') }}</td></tr>
<tr><td class="k">热卷号</td><td class="v">{{ occ(pos,'hot_coil_no') }}</td></tr>
<tr><td class="k">钢种</td><td class="v">{{ occ(pos,'steel_grade') }}</td></tr>
<tr><td class="k">来料厚[mm]</td><td class="v">{{ occNum(pos,'incoming_thickness',2) }}</td></tr>
<tr><td class="k">成品厚[mm]</td><td class="v">{{ occNum(pos,'product_thickness',2) }}</td></tr>
<tr><td class="k">成品宽[mm]</td><td class="v">{{ occNum(pos,'product_width',0) }}</td></tr>
<tr><td class="k">来料重[t]</td><td class="v">{{ occNum(pos,'incoming_weight',3) }}</td></tr>
<tr><td class="k">轧制模式</td><td class="v">{{ occ(pos,'rolling_mode') }}</td></tr>
</tbody>
</table>
<div v-if="occupantOf(pos)" class="pos-act">
<span class="action-link" @click="openMove(occupantOf(pos))">移动</span>
</div>
</div>
</div>
</div>
</div>
<!-- 入口队列表 -->
<div class="card">
<div class="card-header">
入口队列
<span class="ch-badge">{{ queuePlans.length }} </span>
</div>
<div class="table-scroll">
<table class="data-table compact">
<thead>
<tr>
<th>序号</th><th>冷卷号</th><th>热卷号</th><th>钢种</th>
<th>规格(×)</th><th>来料重量</th><th>轧制模式</th><th>状态</th><th>当前位置</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(p, i) in queuePlans" :key="p.id">
<td class="td-num">{{ i + 1 }}</td>
<td class="td-num">{{ p.cold_coil_no || p.plan_no }}</td>
<td class="td-num">{{ p.hot_coil_no || '—' }}</td>
<td>{{ p.steel_grade || '—' }}</td>
<td class="td-num">{{ fmt(p.product_thickness, 2) }} × {{ fmt(p.product_width, 0) }}</td>
<td class="td-num">{{ fmt(p.incoming_weight, 2) }}</td>
<td>{{ p.rolling_mode || '—' }}</td>
<td><span :class="['badge', badgeOf(p.status)]">{{ statusLabel(p.status) }}</span></td>
<td class="td-muted">{{ p.position || '—' }}</td>
<td><span class="action-link" @click="openMove(p)">移动</span></td>
</tr>
<tr v-if="!queuePlans.length">
<td colspan="10" class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 移动-位置选择弹窗 -->
<div v-if="moveDialog.visible" class="modal-mask" @click.self="moveDialog.visible=false">
<div class="modal-box" style="width:520px;">
<div class="modal-header">
移动计划 {{ moveDialog.plan && (moveDialog.plan.cold_coil_no || moveDialog.plan.plan_no) }}
<span class="modal-close" @click="moveDialog.visible=false"></span>
</div>
<div class="modal-body">
<div class="kv-label" style="margin-bottom:8px;">选择目标位置只有上卷鞍座会进入生产环节</div>
<div class="pos-pick">
<span
v-for="pos in positions"
:key="pos"
:class="['pick-item', { active: moveDialog.target === pos, saddle: pos === SADDLE }]"
@click="moveDialog.target = pos"
>{{ pos }}</span>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="moveDialog.visible=false">取消</button>
<button class="btn btn-primary" :disabled="!moveDialog.target || moving" @click="confirmMove">{{ moving ? '移动中...' : '确定' }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { getPlans, getSaddle, movePlan, commitProducing } from '@/api'
const STATUS_LABEL = { ready: '准备好', online: '在线', producing: '生产中', produced: '生产完成' }
const STATUS_BADGE = { ready: 'badge-gray', online: 'badge-green', producing: 'badge-yellow', produced: 'badge-blue' }
const SADDLE = '上卷鞍座'
const EQUIP_ROWS = [
['1#上卷小车', '1#称重位', '1#地辊', '1#倒卷小车'],
['2#上卷小车', '2#称重位', '2#地辊', '2#倒卷小车'],
]
const POSITIONS = [...EQUIP_ROWS[0], ...EQUIP_ROWS[1], SADDLE]
const TARGET = 2000
export default {
name: 'EntryTracking',
data() {
return {
plans: [], saddle: null, TARGET, SADDLE,
positions: POSITIONS, equipRows: EQUIP_ROWS, equipCols: EQUIP_ROWS[0],
timer: null, fastTimer: null, moving: false,
moveDialog: { visible: false, plan: null, target: '' },
}
},
computed: {
queuePlans() {
return this.plans
.filter(p => (p.status === 'online' || p.status === 'ready') && p.on_saddle !== 1)
.sort((a, b) => (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1))
},
},
created() {
this.refreshAll()
this.fastTimer = setInterval(this.fetchSaddle, 2000)
this.timer = setInterval(this.fetchPlans, 5000)
},
beforeDestroy() { clearInterval(this.timer); clearInterval(this.fastTimer) },
methods: {
refreshAll() { this.fetchPlans(); this.fetchSaddle() },
async fetchPlans() {
try {
const res = await getPlans({ page: 1, page_size: 100 })
this.plans = res.data.items || []
} catch (e) { /* ignore */ }
},
async fetchSaddle() {
try {
const res = await getSaddle()
this.saddle = res.data || null
} catch (e) { /* ignore */ }
},
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
statusLabel(s) { return STATUS_LABEL[s] || s || '—' },
badgeOf(s) { return STATUS_BADGE[s] || 'badge-gray' },
progPct(p) { return Math.max(0, Math.min(100, (p.run_length_m || 0) / TARGET * 100)) },
progColor(p) { return this.progPct(p) >= 100 ? 'var(--accent-green)' : 'var(--sms-teal)' },
occupantOf(pos) {
return this.plans.find(p => p.position === pos && p.on_saddle !== 1) || null
},
occ(pos, key) { const p = this.occupantOf(pos); return p ? (p[key] || '—') : '' },
occNum(pos, key, n) { const p = this.occupantOf(pos); return p && p[key] != null ? Number(p[key]).toFixed(n) : '' },
openMove(plan) { this.moveDialog = { visible: true, plan, target: plan.position || '' } },
async confirmMove() {
const { plan, target } = this.moveDialog
if (!target) return
this.moving = true
try {
await movePlan(plan.id, target)
this.$message.success(target === SADDLE ? '已移动到上卷鞍座' : `已移动到 ${target}`)
this.moveDialog.visible = false
this.refreshAll()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
} finally { this.moving = false }
},
async commit(p) {
try {
await commitProducing(p.id)
this.$message.success('已投入生产')
this.fetchSaddle()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
}
},
},
}
</script>
<style lang="scss" scoped>
@import '@/assets/styles/variables';
.entry-page { display: flex; flex-direction: column; gap: 12px; }
// ── 鞍座工位 ──
.saddle-station { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
.saddle-info {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 18px; align-content: start;
border-right: 1px solid $border; padding-right: 16px;
}
.si-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; }
.si-k { color: $text-muted; }
.si-v { color: $text-primary; font-family: $font-mono; font-weight: 600; &.hl { color: $sms-teal; } }
.saddle-run { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-content: start; }
.saddle-run .metric-box { min-width: 0; }
.run-prog { grid-column: 1 / -1; }
.rp-head { display: flex; justify-content: space-between; font-size: 12px; color: $text-secondary; margin-bottom: 5px; }
.rp-pct { font-family: $font-mono; font-weight: 700; color: $sms-teal; }
.rp-tip { font-size: 11px; color: $text-muted; margin-top: 6px; }
.saddle-empty { text-align: center; padding: 30px; color: $text-muted; font-size: 13px; }
// ── 入口设备网格恢复原布局2 行)──
.entry-grid { padding: 10px; display: flex; flex-direction: column; gap: 8px; overflow-x: auto; }
.entry-row { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 8px; }
.pos-cell {
background: $bg-panel; border: 1px solid $border; border-radius: 4px; padding: 4px 6px 6px; min-height: 200px;
display: flex; flex-direction: column;
&.filled { border-color: $sms-teal; background: rgba($sms-teal, .04); }
}
.pos-title {
text-align: center; font-size: 11.5px; font-weight: 700; color: $text-primary;
padding: 3px 0 5px; border-bottom: 1px dashed $border; margin-bottom: 4px; letter-spacing: .3px;
}
.pos-table {
width: 100%; border-collapse: collapse; font-size: 10.5px; line-height: 1.5; flex: 1;
td { padding: 1px 2px; vertical-align: top; white-space: nowrap; }
td.k { color: $text-muted; text-align: right; width: 52%; font-size: 10px; }
td.v { color: $sms-teal; text-align: right; font-family: $font-mono; font-weight: 600; }
}
.pos-act { text-align: center; padding-top: 4px; border-top: 1px dashed $border; }
.action-link { color: $accent-green; cursor: pointer; font-size: 12px; &:hover { text-decoration: underline; } }
// ── 移动弹窗 ──
.pos-pick { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.pick-item {
padding: 9px 12px; font-size: 12px; text-align: center; border-radius: 6px; cursor: pointer;
border: 1px solid $border; color: $text-secondary; background: $bg-card;
&:hover { border-color: $sms-teal; color: $sms-teal; }
&.active { color: #fff; background: $sms-teal; border-color: $sms-teal; }
&.saddle { grid-column: 1 / -1; border-style: dashed; border-color: $accent-yellow; color: $accent-yellow;
&.active { color: #fff; background: $accent-yellow; border-style: solid; } }
}
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
.modal-body { padding: 16px; overflow-y: auto; }
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
</style>