Files
pickling-mes/frontend/src/views/EntryTracking.vue
wangyu 62c484411e feat(linkage): 鞍座改为预备生产位 + 在线改人工触发 + 计划删除
- 上卷鞍座=预备生产位(不生产);投入生产后离开鞍座、进入生产中并转入物料跟踪,鞍座随即空出
- 在线状态改为人工触发:移动到入口端才变在线,引擎不再自动上线(ensure_online 置空)
- 单卷在产:投入生产时若已有在产卷则拒绝
- 物料跟踪显示在产卷实时进度(客户端外推带头长度/进度条)
- 入口跟踪鞍座卡片改为预备生产展示(去掉进度)
- 计划管理新增删除按钮 + DELETE /plan/{id}(生产中不可删)

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

273 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">
<!-- 入口跟踪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>
<span class="sb-hint">点击右上投入生产 进入生产中并转入物料跟踪</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>