Files
pickling-mes/frontend/src/views/EntryTracking.vue
wangyu 4567b87314 feat(linkage): 鞍座自动投入生产 + 实绩生产阶段数据 + 去掉口语化提示
- 产线空闲时自动把上卷鞍座预备卷投入生产;产线在产则等上一卷完成生成实绩后自动进入
- 引擎循环间隔降到5s,自动推进更及时
- 实绩页:ProductionRecordOut 增加生产阶段字段;点击行在下方展示生产阶段数据
- 移除入口跟踪/物料跟踪/成本页的口语化提示小字

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

272 lines
13 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">
<!-- 入口跟踪8 个设备工位 + 下方单个上卷鞍座 -->
<div class="card">
<div class="card-header">
入口跟踪
<span class="ch-badge">{{ saddle ? '上卷鞍座:预备生产' : '上卷鞍座:空闲' }}</span>
<span style="margin-left:auto;display:flex;gap:8px;align-items:center;">
<button v-if="saddle" class="btn btn-primary" @click="commit(saddle)">投入生产</button>
<button class="btn btn-outline" @click="refreshAll">刷新</button>
</span>
</div>
<div class="entry-grid">
<!-- 8 个设备工位 -->
<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 v-if="occupantOf(pos)" 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-else class="pos-empty">空闲</div>
<div v-if="occupantOf(pos)" class="pos-act"><span class="action-link" @click="openMove(occupantOf(pos))">移动</span></div>
</div>
</div>
<!-- 下方单个上卷鞍座预备生产位投入生产后离开鞍座进入物料跟踪 -->
<div :class="['pos-cell', 'saddle-cell', { filled: !!saddle }]">
<div class="pos-title saddle-title">上卷鞍座<span class="st-tag">预备生产位</span></div>
<div v-if="saddle" class="saddle-body">
<div class="sb-info">
<span><i>冷卷号</i>{{ saddle.cold_coil_no || saddle.plan_no }}</span>
<span><i>热卷号</i>{{ saddle.hot_coil_no || '—' }}</span>
<span><i>钢种</i>{{ saddle.steel_grade || '—' }}</span>
<span><i>规格</i>{{ fmt(saddle.product_thickness, 2) }}×{{ fmt(saddle.product_width, 0) }}</span>
<span><i>来料重[t]</i>{{ fmt(saddle.incoming_weight, 3) }}</span>
<span><i>轧制模式</i>{{ saddle.rolling_mode || '—' }}</span>
</div>
<div class="sb-stage">
<span class="badge badge-yellow">预备生产</span>
</div>
</div>
<div v-else class="pos-empty">空闲</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,
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.refreshAll()
} 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; }
// ── 入口设备网格 ──
.entry-grid { padding: 8px; display: flex; flex-direction: column; gap: 6px; overflow-x: auto; }
.entry-row { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 6px; }
.pos-cell {
background: $bg-panel; border: 1px solid $border; border-radius: 4px; padding: 4px 8px 4px;
height: 192px; overflow: hidden; display: flex; flex-direction: column;
&.filled { border-color: $sms-teal; background: rgba($sms-teal, .04); }
}
.pos-title {
text-align: center; font-size: 11px; font-weight: 700; color: $text-primary;
padding: 1px 0 3px; border-bottom: 1px dashed $border; margin-bottom: 3px; letter-spacing: .3px;
}
.pos-table {
width: 100%; border-collapse: collapse; font-size: 10.5px; line-height: 1.3;
td { padding: 0 0 1px; vertical-align: baseline; white-space: nowrap; }
td.k { color: $text-muted; text-align: left; font-size: 10px; padding-right: 6px; }
td.v { color: $sms-teal; text-align: right; font-family: $font-mono; font-weight: 600; width: 100%; }
}
.pos-act { margin-top: auto; text-align: right; padding-top: 3px; border-top: 1px dashed $border; flex-shrink: 0; }
.pos-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: $text-muted; font-size: 11px; }
// ── 下方单个上卷鞍座(全宽) ──
.saddle-cell {
height: 132px;
&.filled { border-color: $accent-yellow; background: rgba($accent-yellow, .05); }
}
.saddle-title { border-bottom-color: rgba($accent-yellow, .4); display: flex; align-items: center; justify-content: center; gap: 8px;
.st-tag { font-size: 9px; color: $accent-yellow; border: 1px solid rgba($accent-yellow, .5); border-radius: 2px; padding: 0 5px; font-weight: 600; } }
.saddle-body { flex: 1; display: grid; grid-template-columns: 3fr 2fr; gap: 16px; align-items: center; }
.sb-info { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px 18px;
span { font-size: 12px; color: $text-primary; font-family: $font-mono; font-weight: 600; display: flex; gap: 6px;
i { color: $text-muted; font-style: normal; font-family: $font-main; font-weight: 400; min-width: 56px; } }
}
.sb-stage { display: flex; flex-direction: column; align-items: center; gap: 8px; justify-content: center;
border-left: 1px solid $border; padding-left: 16px;
.sb-hint { font-size: 11px; color: $text-muted; text-align: center; } }
.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>