Files
pickling-mes/frontend/src/views/EntryTracking.vue

290 lines
14 KiB
Vue
Raw Normal View History

<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">
<template v-if="saddle.status === 'producing'">带头到达 {{ TARGET }} m 后自动产出实绩并完成</template>
<template v-else>已在鞍座等待速度信号可点投入生产手动开始</template>
</div>
</div>
</div>
</div>
<div v-else class="saddle-empty">
上卷鞍座空闲 从队列点击移动在弹窗中选择上卷鞍座即可开始生产
</div>
</div>
</div>
<!-- 入口位置图 -->
<div class="card">
<div class="card-header">入口位置 <span class="ch-badge">{{ positions.length }} 个工位</span></div>
<div class="card-body">
<div class="pos-grid">
<div
v-for="pos in positions"
:key="pos"
:class="['pos-cell', { saddle: pos === SADDLE, filled: !!occupantOf(pos) }]"
>
<div class="pos-title">{{ pos }}<span v-if="pos === SADDLE" class="pos-tag">生产</span></div>
<div v-if="occupantOf(pos)" class="pos-occ">
<div class="po-coil">{{ occupantOf(pos).cold_coil_no || occupantOf(pos).plan_no }}</div>
<div class="po-sub">{{ occupantOf(pos).steel_grade || '—' }}</div>
<div class="po-sub">{{ fmt(occupantOf(pos).product_thickness, 2) }}×{{ fmt(occupantOf(pos).product_width, 0) }}</div>
<span :class="['badge', badgeOf(occupantOf(pos).status)]" style="margin-top:3px;">{{ statusLabel(occupantOf(pos).status) }}</span>
</div>
<div v-else class="pos-empty"></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:480px;">
<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 POSITIONS = [
'1#上卷小车', '2#上卷小车',
'1#称重位', '2#称重位',
'1#地辊', '2#地辊',
'1#倒卷小车', '2#倒卷小车',
SADDLE,
]
const TARGET = 2000
export default {
name: 'EntryTracking',
data() {
return {
plans: [], saddle: null, TARGET, SADDLE, positions: POSITIONS,
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) {
if (pos === SADDLE) return this.saddle || this.plans.find(p => p.on_saddle === 1) || null
return this.plans.find(p => p.position === pos && p.on_saddle !== 1) || null
},
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; }
// ── 位置图 ──
.pos-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.pos-cell {
background: $bg-panel; border: 1px solid $border; border-radius: 6px; padding: 8px; min-height: 110px;
&.filled { border-color: $sms-teal; background: rgba($sms-teal, .04); }
&.saddle { border-style: dashed; border-color: $accent-yellow;
&.filled { border-style: solid; border-color: $accent-yellow; background: rgba($accent-yellow, .06); } }
}
.pos-title { font-size: 11.5px; font-weight: 700; color: $text-primary; padding-bottom: 5px; border-bottom: 1px dashed $border; margin-bottom: 6px; display: flex; justify-content: space-between; }
.pos-tag { font-size: 9px; color: $accent-yellow; border: 1px solid rgba($accent-yellow, .5); border-radius: 2px; padding: 0 4px; }
.pos-occ { display: flex; flex-direction: column; gap: 2px; }
.po-coil { font-family: $font-mono; font-weight: 700; color: $sms-teal; font-size: 12.5px; }
.po-sub { font-size: 11px; color: $text-secondary; }
.pos-empty { color: $text-muted; font-size: 12px; text-align: center; padding-top: 16px; }
.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>