Files
pickling-mes/frontend/src/views/Plan.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

470 lines
23 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>
<div class="card">
<div class="card-body" style="padding:10px 14px;">
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
<div class="flex-row">
<span class="kv-label">状态</span>
<select v-model="query.status" class="kv-input" style="width:110px;">
<option value="">全部</option>
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</div>
<div class="flex-row">
<span class="kv-label">冷卷号</span>
<input v-model="query.cold_coil_no" class="kv-input" style="width:140px;" @keyup.enter="fetchData" />
</div>
<div class="flex-row">
<span class="kv-label">日期</span>
<input v-model="query.start_date" type="date" class="kv-input" style="width:130px;" />
<span class="kv-label">~</span>
<input v-model="query.end_date" type="date" class="kv-input" style="width:130px;" />
</div>
<div class="flex-row">
<button class="btn btn-primary" @click="fetchData">查询</button>
<button class="btn btn-outline" @click="openDialog()"> 新增计划</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
生产计划
<span class="ch-badge"> {{ total }} </span>
</div>
<div class="table-scroll" v-loading="loading">
<table class="data-table">
<thead>
<tr>
<th style="width:48px;">序号</th>
<th>冷卷号</th>
<th>热卷号</th>
<th>钢种</th>
<th>来料厚度</th>
<th>产品厚度</th>
<th>偏差上限</th>
<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="(row, idx) in tableData" :key="row.id"
:class="{ 'row-selected': selectedRow && selectedRow.id === row.id }"
@click="selectRow(row)" style="cursor:pointer;">
<td class="td-num">{{ idx + 1 }}</td>
<td class="td-num">{{ row.cold_coil_no || row.plan_no || '—' }}</td>
<td class="td-num">{{ row.hot_coil_no || '—' }}</td>
<td>{{ row.steel_grade || '—' }}</td>
<td class="td-num">{{ fmtNum(row.incoming_thickness) }}</td>
<td class="td-num">{{ fmtNum(row.product_thickness) }}</td>
<td class="td-num">{{ fmtNum(row.deviation_upper, 3) }}</td>
<td class="td-num">{{ fmtNum(row.deviation_lower, 3) }}</td>
<td class="td-num">{{ fmtNum(row.incoming_width, 0) }}</td>
<td class="td-num">{{ fmtNum(row.product_width, 0) }}</td>
<td>{{ row.packaging_req || '—' }}</td>
<td class="td-num">{{ fmtNum(row.coil_diameter, 0) }}</td>
<td class="td-num">{{ row.split_count != null ? row.split_count : 1 }}</td>
<td class="td-num">{{ row.next_process != null ? row.next_process : '—' }}</td>
<td class="td-muted">{{ fmtTime(row.plan_date) }}</td>
<td><span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span></td>
<td @click.stop>
<span class="action-link" @click="openDialog(row)">编辑</span>
<span v-if="row.status === 'ready' || row.status === 'online'"
class="action-link" style="color:var(--accent-green)" @click="openMove(row)">移动</span>
<span v-if="row.status !== 'producing'"
class="action-link" style="color:var(--accent-red)" @click="removeRow(row)">删除</span>
</td>
</tr>
<tr v-if="!tableData.length && !loading">
<td colspan="17" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 计划详细 -->
<div class="card" v-if="selectedRow">
<div class="card-header">计划详细 {{ selectedRow.cold_coil_no || selectedRow.plan_no }}</div>
<div class="detail-grid">
<div class="dt-row"><span class="dt-k">钢卷号</span><span class="dt-v">{{ selectedRow.cold_coil_no || '—' }}</span></div>
<div class="dt-row"><span class="dt-k">来料厚度</span><span class="dt-v">{{ fmtNum(selectedRow.incoming_thickness) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">来料宽度</span><span class="dt-v">{{ fmtNum(selectedRow.incoming_width, 0) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">1#分卷</span><span class="dt-v">{{ fmtNum(splitW(0), 3) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">钢种</span><span class="dt-v">{{ selectedRow.steel_grade || '—' }}</span></div>
<div class="dt-row"><span class="dt-k">产品厚度</span><span class="dt-v">{{ fmtNum(selectedRow.product_thickness) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">产品宽度</span><span class="dt-v">{{ fmtNum(selectedRow.product_width, 0) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">2#分卷</span><span class="dt-v">{{ fmtNum(splitW(1), 3) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">轧制模式</span><span class="dt-v">{{ selectedRow.rolling_mode || '—' }}</span></div>
<div class="dt-row"><span class="dt-k">偏差上限</span><span class="dt-v">{{ fmtNum(selectedRow.deviation_upper, 3) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">分卷数</span><span class="dt-v">{{ selectedRow.split_count != null ? selectedRow.split_count : 1 }}</span></div>
<div class="dt-row"><span class="dt-k">3#分卷</span><span class="dt-v">{{ fmtNum(splitW(2), 3) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">状态</span><span class="dt-v"><span :class="['badge', statusBadge(selectedRow.status)]">{{ statusLabel(selectedRow.status) }}</span></span></div>
<div class="dt-row"><span class="dt-k">偏差下限</span><span class="dt-v">{{ fmtNum(selectedRow.deviation_lower, 3) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">下工序</span><span class="dt-v">{{ selectedRow.next_process || '—' }}</span></div>
<div class="dt-row"><span class="dt-k">4#分卷</span><span class="dt-v">{{ fmtNum(splitW(3), 3) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">下达时间</span><span class="dt-v">{{ fmtTime(selectedRow.plan_date) }}</span></div>
<div class="dt-row"><span class="dt-k">来料重量</span><span class="dt-v">{{ fmtNum(selectedRow.incoming_weight, 2) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">来料外径</span><span class="dt-v">{{ fmtNum(selectedRow.incoming_od, 0) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">5#分卷</span><span class="dt-v">{{ fmtNum(splitW(4), 3) }} <i>[t]</i></span></div>
<div class="dt-row"></div>
<div class="dt-row"></div>
<div class="dt-row"></div>
<div class="dt-row"><span class="dt-k">6#分卷</span><span class="dt-v">{{ fmtNum(splitW(5), 3) }} <i>[t]</i></span></div>
</div>
</div>
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
<div class="modal-box" style="width:880px;">
<div class="modal-header">
{{ editRow ? '编辑计划' : '新增计划' }}
<span class="modal-close" @click="dialogVisible=false"></span>
</div>
<div class="modal-body">
<div class="grid-3" style="gap:12px;">
<div class="form-field">
<div class="kv-label">计划号 *</div>
<input v-model="form.plan_no" class="kv-input" :disabled="!!editRow" />
</div>
<div class="form-field">
<div class="kv-label">冷卷号</div>
<input v-model="form.cold_coil_no" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">热卷号</div>
<input v-model="form.hot_coil_no" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">钢种</div>
<input v-model="form.steel_grade" class="kv-input" placeholder="QTGLG-2019" />
</div>
<div class="form-field">
<div class="kv-label">轧制模式</div>
<select v-model="form.rolling_mode" class="kv-input">
<option value=""></option>
<option value="冷轧">冷轧</option>
<option value="热轧">热轧</option>
<option value="温轧">温轧</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">来料厚度 (mm)</div>
<input v-model.number="form.incoming_thickness" type="number" step="0.01" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">产品厚度 (mm)</div>
<input v-model.number="form.product_thickness" type="number" step="0.01" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">偏差上限</div>
<input v-model.number="form.deviation_upper" type="number" step="0.001" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">偏差下限</div>
<input v-model.number="form.deviation_lower" type="number" step="0.001" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">来料宽度 (mm)</div>
<input v-model.number="form.incoming_width" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">产品宽度 (mm)</div>
<input v-model.number="form.product_width" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">包装要求</div>
<select v-model="form.packaging_req" class="kv-input">
<option value=""></option>
<option value="裸包">裸包</option>
<option value="筒包">筒包</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">卷径 (mm)</div>
<input v-model.number="form.coil_diameter" type="number" step="1" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">来料外径 (mm)</div>
<input v-model.number="form.incoming_od" type="number" step="1" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">来料重量 (t)</div>
<input v-model.number="form.incoming_weight" type="number" step="0.001" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">分卷数</div>
<input v-model.number="form.split_count" type="number" min="1" max="6" class="kv-input" @change="syncSplitWeights" />
</div>
<div class="form-field">
<div class="kv-label">下工序</div>
<input v-model="form.next_process" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">计划时间</div>
<input v-model="form.plan_date" type="datetime-local" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">状态</div>
<select v-model="form.status" class="kv-input">
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</div>
</div>
<div style="margin-top:12px;border-top:1px dashed var(--border);padding-top:10px;">
<div class="kv-label" style="margin-bottom:6px;">分卷重量 (t)</div>
<div class="grid-6" style="gap:8px;">
<div v-for="i in 6" :key="i" class="form-field">
<div class="kv-label" style="font-size:11px;">{{ i }}#分卷</div>
<input v-model.number="form.split_weights[i-1]" type="number" step="0.001"
:disabled="i > (form.split_count || 1)" class="kv-input" />
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</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 === '上卷鞍座' }]"
@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, createPlan, updatePlan, deletePlan, confirmPlan as apiConfirm, movePlan, getLastPlanTemplate } from '@/api'
const SADDLE = '上卷鞍座'
const POSITIONS = [
'1#上卷小车', '2#上卷小车', '1#称重位', '2#称重位',
'1#地辊', '2#地辊', '1#倒卷小车', '2#倒卷小车', SADDLE,
]
const STATUS_MAP = {
ready: { label: '准备好', badge: 'badge-gray' },
online: { label: '在线', badge: 'badge-green' },
producing: { label: '生产中', badge: 'badge-yellow' },
produced: { label: '生产完成', badge: 'badge-blue' },
}
const TEMPLATE_KEYS = [
'steel_grade','incoming_thickness','product_thickness','deviation_upper','deviation_lower',
'incoming_width','product_width','packaging_req','trim_req','rolling_mode',
'coil_diameter','split_count','next_process','incoming_weight','incoming_od','split_weights',
]
export default {
name: 'Plan',
data() {
return {
loading: false, saving: false,
tableData: [], total: 0,
query: { page: 1, page_size: 50, status: '', cold_coil_no: '', start_date: '', end_date: '' },
statusOptions: Object.entries(STATUS_MAP).map(([value, { label }]) => ({ value, label })),
dialogVisible: false, editRow: null,
form: this.emptyForm(),
selectedRow: null,
positions: POSITIONS, moving: false,
moveDialog: { visible: false, plan: null, target: '' },
}
},
created() { this.fetchData() },
methods: {
emptyForm() {
return { plan_no: '', split_count: 1, status: 'ready', split_weights: [null,null,null,null,null,null] }
},
async fetchData() {
this.loading = true
const params = { page: this.query.page, page_size: this.query.page_size }
if (this.query.status) params.status = this.query.status
if (this.query.start_date) params.start_date = this.query.start_date + 'T00:00:00'
if (this.query.end_date) params.end_date = this.query.end_date + 'T23:59:59'
try {
const res = await getPlans(params)
let items = res.data.items
if (this.query.cold_coil_no) {
items = items.filter(x => (x.cold_coil_no || '').includes(this.query.cold_coil_no))
}
this.tableData = items
this.total = res.data.total
if (this.selectedRow) {
const fresh = items.find(x => x.id === this.selectedRow.id)
if (fresh) this.selectedRow = fresh
}
} finally { this.loading = false }
},
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
fmtNum(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
statusLabel(s) { return STATUS_MAP[s]?.label || s || '—' },
statusBadge(s) { return STATUS_MAP[s]?.badge || 'badge-gray' },
selectRow(row) { this.selectedRow = row },
splitW(i) {
const arr = this.selectedRow && this.selectedRow.split_weights
return Array.isArray(arr) ? arr[i] : null
},
syncSplitWeights() {
const n = Math.max(1, Math.min(6, Number(this.form.split_count) || 1))
const arr = (this.form.split_weights || []).slice(0, 6)
while (arr.length < 6) arr.push(null)
for (let i = n; i < 6; i++) arr[i] = null
this.$set(this.form, 'split_weights', arr)
},
async openDialog(row = null) {
this.editRow = row
if (row) {
const r = { ...row }
if (r.plan_date) r.plan_date = r.plan_date.slice(0, 16)
if (!Array.isArray(r.split_weights)) r.split_weights = [null,null,null,null,null,null]
else { while (r.split_weights.length < 6) r.split_weights.push(null) }
this.form = r
} else {
this.form = this.emptyForm()
this.form.plan_date = this.nowDT()
try {
const res = await getLastPlanTemplate()
const t = res.data || {}
for (const k of TEMPLATE_KEYS) {
if (t[k] != null) this.$set(this.form, k, t[k])
}
if (!Array.isArray(this.form.split_weights)) {
this.form.split_weights = [null,null,null,null,null,null]
} else {
while (this.form.split_weights.length < 6) this.form.split_weights.push(null)
}
} catch (e) { /* 无历史可回填,忽略 */ }
}
this.dialogVisible = true
},
nowDT() {
const d = new Date(); const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
},
async confirmPlan(row) {
if (!confirm(`将计划 ${row.plan_no} 上线?`)) return
await apiConfirm(row.id)
this.$message.success('已上线')
this.fetchData()
},
async removeRow(row) {
if (!confirm(`确认删除计划 ${row.cold_coil_no || row.plan_no}`)) return
try {
await deletePlan(row.id)
this.$message.success('已删除')
if (this.selectedRow && this.selectedRow.id === row.id) this.selectedRow = null
this.fetchData()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '删除失败')
}
},
openMove(row) {
this.moveDialog = { visible: true, plan: row, target: row.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.fetchData()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
} finally { this.moving = false }
},
async save() {
if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return }
if (!this.form.plan_date) { this.$message.error('计划时间不能为空'); return }
this.saving = true
try {
const d = { ...this.form }
if (d.plan_date && !d.plan_date.includes(':')) d.plan_date += 'T00:00:00'
else if (d.plan_date && d.plan_date.length === 16) d.plan_date += ':00'
// 修剪 split_weights 到 split_count 长度
const n = Math.max(1, Math.min(6, Number(d.split_count) || 1))
if (Array.isArray(d.split_weights)) {
d.split_weights = d.split_weights.slice(0, n).map(v => v === '' ? null : v)
}
if (this.editRow) await updatePlan(this.editRow.id, d)
else await createPlan(d)
this.$message.success('保存成功')
this.dialogVisible = false; this.fetchData()
} finally { this.saving = false }
}
}
}
</script>
<style lang="scss" scoped>
@import '@/assets/styles/variables';
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 12px; &:hover { text-decoration: underline; } }
.row-selected { background: rgba(29, 142, 255, 0.12) !important; }
.form-field { display: flex; flex-direction: column; gap: 5px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; }
.grid-6 { display: grid; grid-template-columns: repeat(6, 1fr); }
.detail-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px 24px;
padding: 14px 18px;
border: 1px solid #c43a3a;
margin: 0 6px 8px;
background: rgba(196, 58, 58, 0.04);
}
.dt-row { display: flex; align-items: center; gap: 10px; font-size: 12px; min-height: 22px; }
.dt-k { color: $text-muted; min-width: 64px; }
.dt-v { color: $text-primary; flex: 1; i { color: $text-muted; font-style: normal; font-size: 11px; margin-left: 2px; } }
.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; }
.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; } }
}
</style>