feat(linkage): 计划-鞍座-实绩-停机联动 + 成本管理页

后端:
- 计划录入即「准备好」,队首(最早)自动「在线」(唯一)
- 新增上卷鞍座联动引擎 line_service:移动→鞍座→(有速度/投入生产)→生产中
  →带头达2000m→生产完成并自动产生实绩、持久化运行数据
- 停机自动检测:线速度为0持续>10min 自动新增待补充停机记录,恢复后自动结束
- /plan/start=移动到鞍座, 新增 /plan/{id}/commit 投入生产, /plan/saddle/current,
  /plan/seed 批量插入(轧制力模式);后台引擎循环自动推进
- 新增成本管理:CostRecord 模型 + /cost CRUD + 9 类成本项(乳化液/盐酸/碱/电/水/蒸汽…)

前端:
- 入口跟踪重构为单个上卷鞍座工位(实时速度/带头长度进度/投入生产)+待上卷卡片+队列,
  计划列表/卡片/队列均可「移动」
- 新增成本管理页(成本项切换 + 柱+线图 + 明细表 + 时间筛选 + 新增),布局参考乳化液耗量统计

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 13:57:59 +08:00
parent 2144f13b88
commit 9fb3dcb785
18 changed files with 969 additions and 149 deletions

View File

@@ -20,9 +20,20 @@ export const getPlans = params => request.get('/plan/', { params })
export const createPlan = data => request.post('/plan/', data)
export const updatePlan = (id, data) => request.put(`/plan/${id}`, data)
export const confirmPlan = id => request.patch(`/plan/${id}/confirm`)
export const startProducing = id => request.patch(`/plan/${id}/start`)
export const startProducing = id => request.patch(`/plan/${id}/start`) // 移动到上卷鞍座
export const moveToSaddle = id => request.patch(`/plan/${id}/start`)
export const commitProducing = id => request.patch(`/plan/${id}/commit`) // 投入生产
export const getSaddle = () => request.get('/plan/saddle/current')
export const seedPlans = (count = 50) => request.post('/plan/seed', null, { params: { count } })
export const getLastPlanTemplate = () => request.get('/plan/last-template')
// 成本管理
export const getCostItems = () => request.get('/cost/items')
export const getCostRecords = params => request.get('/cost/', { params })
export const createCostRecord = data => request.post('/cost/', data)
export const updateCostRecord = (id, data) => request.put(`/cost/${id}`, data)
export const deleteCostRecord = id => request.delete(`/cost/${id}`)
// 停机管理
export const getDowntimeCategories = () => request.get('/downtime/categories')
export const getDowntimeRecords = params => request.get('/downtime/', { params })

View File

@@ -70,6 +70,12 @@ const routes = [
component: () => import('@/views/Quality.vue'),
meta: { title: '质量管理', icon: 'el-icon-medal', requiresAuth: true }
},
{
path: 'cost',
name: 'CostManagement',
component: () => import('@/views/CostManagement.vue'),
meta: { title: '成本管理', icon: 'el-icon-coin', requiresAuth: true }
},
]
},
{ path: '*', redirect: '/' }

View File

@@ -0,0 +1,254 @@
<template>
<div class="cost-page">
<!-- 成本项切换 -->
<div class="card">
<div class="card-body" style="padding:8px 12px;">
<div class="item-tabs">
<span
v-for="it in items"
:key="it.item"
:class="['item-tab', { active: it.item === curItem }]"
@click="selectItem(it.item)"
>{{ it.item_name }}</span>
</div>
</div>
</div>
<!-- 图表 + 记录表 -->
<div class="cost-main">
<div class="card chart-card">
<div class="card-header">{{ curMeta.item_name }}耗量统计</div>
<div class="card-body">
<v-chart v-if="records.length" class="cost-chart" :option="chartOption" autoresize />
<div v-else class="empty-tip">暂无数据请调整时间范围或点击新增录入</div>
</div>
</div>
<div class="card table-card">
<div class="card-header">记录明细 <span class="ch-badge">{{ records.length }} </span></div>
<div class="table-scroll" style="max-height:420px;">
<table class="data-table compact">
<thead>
<tr>
<th>A班量</th><th>B班量</th><th>吨耗量</th><th>记录时间</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="r in recordsDesc" :key="r.id">
<td class="td-num td-ok">{{ fmt(r.shift_a) }}</td>
<td class="td-num td-warn">{{ fmt(r.shift_b) }}</td>
<td class="td-num">{{ fmt(r.unit_cost) }}</td>
<td class="td-muted">{{ fmtTime(r.record_date) }}</td>
<td>
<span class="action-link" @click="openDialog(r)">编辑</span>
<span class="action-link del" @click="remove(r)">删除</span>
</td>
</tr>
<tr v-if="!records.length">
<td colspan="5" class="td-muted" style="text-align:center;padding:14px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</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>
<input v-model="query.start_date" type="date" class="kv-input" style="width:150px;" />
</div>
<div class="flex-row">
<span class="kv-label">结束时间</span>
<input v-model="query.end_date" type="date" class="kv-input" style="width:150px;" />
</div>
<div class="flex-row" style="margin-left:auto;gap:8px;">
<button class="btn btn-primary" @click="fetchData">查找</button>
<button class="btn btn-outline" @click="openDialog()"> 新增</button>
</div>
</div>
</div>
</div>
<!-- 新增/编辑 -->
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
<div class="modal-box" style="width:420px;">
<div class="modal-header">
{{ editRow ? '编辑' : '新增' }}{{ curMeta.item_name }}记录
<span class="modal-close" @click="dialogVisible=false"></span>
</div>
<div class="modal-body">
<div class="form-field">
<div class="kv-label">记录时间 *</div>
<input v-model="form.record_date" type="datetime-local" class="kv-input" />
</div>
<div class="grid-2" style="gap:12px;margin-top:10px;">
<div class="form-field">
<div class="kv-label">A班量 ({{ curMeta.unit }})</div>
<input v-model.number="form.shift_a" type="number" step="0.01" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">B班量 ({{ curMeta.unit }})</div>
<input v-model.number="form.shift_b" type="number" step="0.01" class="kv-input" />
</div>
</div>
<div class="form-field" style="margin-top:10px;">
<div class="kv-label">吨耗量 ({{ curMeta.unit_cost_label }})</div>
<input v-model.number="form.unit_cost" type="number" step="0.001" class="kv-input" />
</div>
<div class="form-field" style="margin-top:10px;">
<div class="kv-label">备注</div>
<input v-model="form.remark" class="kv-input" />
</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>
</template>
<script>
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getCostItems, getCostRecords, createCostRecord, updateCostRecord, deleteCostRecord } from '@/api'
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent])
export default {
name: 'CostManagement',
components: { VChart },
data() {
return {
items: [], curItem: '',
records: [],
query: { start_date: '', end_date: '' },
dialogVisible: false, editRow: null, saving: false,
form: this.emptyForm(),
}
},
computed: {
curMeta() { return this.items.find(x => x.item === this.curItem) || { item_name: '', unit: '', unit_cost_label: '' } },
recordsDesc() { return [...this.records].slice().reverse() },
chartOption() {
const dates = this.records.map(r => this.fmtDay(r.record_date))
const meta = this.curMeta
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['A班耗量', 'B班耗量', '吨耗量'], top: 4, textStyle: { color: '#606266' } },
grid: { left: 50, right: 56, top: 40, bottom: 40 },
xAxis: [{ type: 'category', data: dates, axisLabel: { color: '#909399' }, axisLine: { lineStyle: { color: '#dcdfe6' } } }],
yAxis: [
{ type: 'value', name: `耗量 ${meta.unit}`, nameTextStyle: { color: '#909399' }, axisLabel: { color: '#909399' }, splitLine: { lineStyle: { color: '#ebeef5' } } },
{ type: 'value', name: `吨耗 ${meta.unit_cost_label}`, nameTextStyle: { color: '#909399' }, axisLabel: { color: '#909399' }, splitLine: { show: false } },
],
series: [
{ name: 'A班耗量', type: 'bar', data: this.records.map(r => r.shift_a), itemStyle: { color: '#67C23A' }, barMaxWidth: 22 },
{ name: 'B班耗量', type: 'bar', data: this.records.map(r => r.shift_b), itemStyle: { color: '#E6A23C' }, barMaxWidth: 22 },
{ name: '吨耗量', type: 'line', yAxisIndex: 1, data: this.records.map(r => r.unit_cost), itemStyle: { color: '#C03639' }, lineStyle: { color: '#C03639', width: 2 }, symbol: 'circle', symbolSize: 7 },
],
}
},
},
async created() {
await this.fetchItems()
const today = new Date()
const past = new Date(today.getTime() - 7 * 86400000)
this.query.start_date = this.toDay(past)
this.query.end_date = this.toDay(today)
this.fetchData()
},
methods: {
emptyForm() { return { record_date: '', shift_a: null, shift_b: null, unit_cost: null, remark: '' } },
async fetchItems() {
try {
const res = await getCostItems()
this.items = res.data || []
if (this.items.length && !this.curItem) this.curItem = this.items[0].item
} catch (e) { /* ignore */ }
},
selectItem(item) { this.curItem = item; this.fetchData() },
async fetchData() {
const params = { item: this.curItem }
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 getCostRecords(params)
this.records = res.data || []
} catch (e) { /* ignore */ }
},
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
fmtDay(t) { return t ? t.slice(5, 10) : '' },
toDay(d) { const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}` },
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())}` },
openDialog(row = null) {
this.editRow = row
if (row) {
this.form = { record_date: (row.record_date || '').slice(0, 16), shift_a: row.shift_a, shift_b: row.shift_b, unit_cost: row.unit_cost, remark: row.remark || '' }
} else {
this.form = this.emptyForm(); this.form.record_date = this.nowDT()
}
this.dialogVisible = true
},
async save() {
if (!this.form.record_date) { this.$message.error('记录时间不能为空'); return }
this.saving = true
try {
const d = { ...this.form }
if (d.record_date && d.record_date.length === 16) d.record_date += ':00'
if (this.editRow) await updateCostRecord(this.editRow.id, d)
else await createCostRecord({ ...d, item: this.curItem })
this.$message.success('保存成功')
this.dialogVisible = false; this.fetchData()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '保存失败')
} finally { this.saving = false }
},
async remove(row) {
if (!confirm('确认删除该记录?')) return
try { await deleteCostRecord(row.id); this.$message.success('已删除'); this.fetchData() }
catch (e) { this.$message.error('删除失败') }
},
},
}
</script>
<style lang="scss" scoped>
@import '@/assets/styles/variables';
.cost-page { display: flex; flex-direction: column; gap: 12px; }
.item-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.item-tab {
padding: 5px 14px; font-size: 12px; border-radius: 4px; cursor: pointer;
color: $text-secondary; border: 1px solid $border; background: $bg-card;
&:hover { color: $sms-teal; border-color: $sms-teal; }
&.active { color: #fff; background: $sms-teal; border-color: $sms-teal; }
}
.cost-main { display: grid; grid-template-columns: 3fr 2fr; gap: 12px; align-items: stretch; }
.chart-card, .table-card { display: flex; flex-direction: column; }
.cost-chart { height: 420px; width: 100%; }
.empty-tip { text-align: center; padding: 60px 20px; color: $text-muted; font-size: 13px; }
.form-field { display: flex; flex-direction: column; gap: 5px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; }
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 10px; &:hover { text-decoration: underline; } &.del { color: $accent-red; } }
.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; }
@media (max-width: 1100px) { .cost-main { grid-template-columns: 1fr; } }
</style>

View File

@@ -1,67 +1,115 @@
<template>
<div class="entry-page">
<!-- 上卷鞍座单个生产工位 -->
<div class="card">
<div class="card-header">
入口跟踪
<span class="ch-badge">在线 {{ onlinePlans.length }} / 生产中 {{ producingPlan ? 1 : 0 }}</span>
上卷鞍座
<span class="ch-badge">{{ saddle ? statusLabel(saddle.status) : '空闲' }}</span>
<span style="margin-left:auto;display:flex;gap:8px;align-items:center;">
<button class="btn btn-outline" @click="fetchPlans">刷新</button>
<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="entry-grid">
<div v-for="row in rows" :key="row.key" class="entry-row">
<div
v-for="pos in row.positions"
:key="pos.name"
:class="['pos-cell', { filled: !!pos.plan, highlight: pos.highlight }]"
>
<div class="pos-title">{{ pos.name }}</div>
<table class="pos-table">
<tbody>
<tr><td class="k">冷卷号</td><td class="v">{{ pos.plan ? (pos.plan.cold_coil_no || '—') : '' }}</td></tr>
<tr><td class="k">热卷号</td><td class="v">{{ pos.plan ? (pos.plan.hot_coil_no || '—') : '' }}</td></tr>
<tr><td class="k">钢种</td><td class="v">{{ pos.plan ? (pos.plan.steel_grade || '—') : '' }}</td></tr>
<tr><td class="k">来料厚度[mm]</td><td class="v">{{ pos.plan ? fmt(pos.plan.incoming_thickness, 2) : '' }}</td></tr>
<tr><td class="k">成品厚度[mm]</td><td class="v">{{ pos.plan ? fmt(pos.plan.product_thickness, 2) : '' }}</td></tr>
<tr><td class="k">厚差范围[mm]</td><td class="v">{{ pos.plan ? devRange(pos.plan) : '' }}</td></tr>
<tr><td class="k">来料宽度[mm]</td><td class="v">{{ pos.plan ? fmt(pos.plan.incoming_width, 0) : '' }}</td></tr>
<tr><td class="k">成品宽度[mm]</td><td class="v">{{ pos.plan ? fmt(pos.plan.product_width, 0) : '' }}</td></tr>
<tr><td class="k">来料重量[t]</td><td class="v">{{ pos.plan ? fmt(pos.plan.incoming_weight, 4) : '' }}</td></tr>
<tr><td class="k">轧制模式</td><td class="v">{{ pos.plan ? (pos.plan.rolling_mode || '—') : '' }}</td></tr>
</tbody>
</table>
<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">{{ onlinePlans.length }} </span>
待上卷计划
<span class="ch-badge">在线/准备 {{ queuePlans.length }} </span>
</div>
<div class="card-body">
<div v-if="queueCards.length" class="card-grid">
<div v-for="p in queueCards" :key="p.id" :class="['plan-card', { online: p.status === 'online' }]">
<div class="pc-head">
<span class="pc-coil">{{ p.cold_coil_no || p.plan_no }}</span>
<span :class="['badge', p.status === 'online' ? 'badge-green' : 'badge-gray']">{{ statusLabel(p.status) }}</span>
</div>
<div class="pc-body">
<div class="pc-row"><span>钢种</span><b>{{ p.steel_grade || '—' }}</b></div>
<div class="pc-row"><span>规格</span><b>{{ fmt(p.product_thickness, 2) }}×{{ fmt(p.product_width, 0) }}</b></div>
<div class="pc-row"><span>重量[t]</span><b>{{ fmt(p.incoming_weight, 3) }}</b></div>
<div class="pc-row"><span>轧制模式</span><b>{{ p.rolling_mode || '—' }}</b></div>
</div>
<button class="btn btn-primary fw" :disabled="saddleOccupied" @click="move(p)">移动到鞍座</button>
</div>
</div>
<div v-else class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</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><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in onlinePlans" :key="p.id">
<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', p.status === 'online' ? 'badge-green' : 'badge-gray']">{{ p.status === 'online' ? '在线' : '准备好' }}</span></td>
<td><span class="action-link" @click="moveToProducing(p)">移动</span></td>
<td><span :class="['badge', p.status === 'online' ? 'badge-green' : 'badge-gray']">{{ statusLabel(p.status) }}</span></td>
<td><span :class="['action-link', { disabled: saddleOccupied }]" @click="!saddleOccupied && move(p)">移动</span></td>
</tr>
<tr v-if="!onlinePlans.length">
<td colspan="8" class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</td>
<tr v-if="!queuePlans.length">
<td colspan="9" class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</td>
</tr>
</tbody>
</table>
@@ -71,59 +119,68 @@
</template>
<script>
import { getPlans, startProducing } from '@/api'
import { getPlans, getSaddle, moveToSaddle, commitProducing } from '@/api'
const ROW1 = ['1#上卷小车','1#称重位','1#地辊','13#上卷鞍座','11#上卷鞍座','9#上卷鞍座','7#上卷鞍座','1#倒卷小车','5#上卷鞍座','3#上卷鞍座','1#上卷鞍座']
const ROW2 = ['2#上卷小车','2#称重位','2#地辊','14#上卷鞍座','12#上卷鞍座','10#上卷鞍座','8#上卷鞍座','2#倒卷小车','6#上卷鞍座','4#上卷鞍座','2#上卷鞍座']
const STATUS_LABEL = { ready: '准备好', online: '在线', producing: '生产中', produced: '生产完成' }
const TARGET = 2000
export default {
name: 'EntryTracking',
data() {
return { plans: [], timer: null }
return { plans: [], saddle: null, TARGET, timer: null, fastTimer: null }
},
computed: {
onlinePlans() { return this.plans.filter(p => p.status === 'online' || p.status === 'ready') },
producingPlan() { return this.plans.find(p => p.status === 'producing') || null },
rows() {
// 主辊位填充1#地辊 = 生产中卷; 2#地辊 = 在线队首
const onl = this.onlinePlans
const map1 = { '1#地辊': this.producingPlan }
const map2 = { '2#地辊': onl[0] || null }
const build = (names, map) => names.map(n => ({
name: n,
plan: map[n] || null,
highlight: n === '1#地辊' || n === '2#地辊',
}))
return [
{ key: 'r1', positions: build(ROW1, map1) },
{ key: 'r2', positions: build(ROW2, map2) },
]
queuePlans() {
// 在线 + 准备好,且不在鞍座上;在线排前
return this.plans
.filter(p => (p.status === 'online' || p.status === 'ready') && p.on_saddle !== 1)
.sort((a, b) => (a.status === 'online' ? -1 : 1) - (b.status === 'online' ? -1 : 1))
},
queueCards() { return this.queuePlans.slice(0, 8) },
saddleOccupied() { return !!this.saddle && this.saddle.status !== 'produced' },
},
created() {
this.fetchPlans()
this.timer = setInterval(this.fetchPlans, 5000)
this.refreshAll()
this.fastTimer = setInterval(this.fetchSaddle, 2000) // 鞍座实时进度
this.timer = setInterval(this.fetchPlans, 5000) // 队列
},
beforeDestroy() { clearInterval(this.timer) },
beforeDestroy() { clearInterval(this.timer); clearInterval(this.fastTimer) },
methods: {
refreshAll() { this.fetchPlans(); this.fetchSaddle() },
async fetchPlans() {
try {
const res = await getPlans({ page: 1, page_size: 50 })
const res = await getPlans({ page: 1, page_size: 100 })
this.plans = res.data.items || []
} catch (e) { /* ignore */ }
},
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
devRange(p) {
const u = p.deviation_upper, l = p.deviation_lower
if (u == null && l == null) return '—'
return `${l != null ? Number(l).toFixed(3) : '—'} / ${u != null ? Number(u).toFixed(3) : '—'}`
},
async moveToProducing(p) {
if (!confirm(`将计划 ${p.cold_coil_no || p.plan_no} 移动到入口并开始生产?`)) return
async fetchSaddle() {
try {
await startProducing(p.id)
this.$message.success('已开始生产')
this.fetchPlans()
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 || '—' },
progPct(p) { return Math.max(0, Math.min(100, (p.run_length_m || 0) / TARGET * 100)) },
progColor(p) {
const pct = this.progPct(p)
return pct >= 100 ? 'var(--accent-green)' : 'var(--sms-teal)'
},
async move(p) {
if (this.saddleOccupied) { this.$message.warning('鞍座已被占用,请等待当前钢卷生产完成'); return }
try {
await moveToSaddle(p.id)
this.$message.success('已移动到上卷鞍座')
this.refreshAll()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
}
},
async commit(p) {
try {
await commitProducing(p.id)
this.$message.success('已投入生产')
this.fetchSaddle()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
}
@@ -137,71 +194,41 @@ export default {
.entry-page { display: flex; flex-direction: column; gap: 12px; }
.entry-grid {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-x: auto;
// ── 鞍座工位 ──
.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; } }
.entry-row {
display: grid;
grid-template-columns: repeat(11, minmax(118px, 1fr));
gap: 6px;
}
.pos-cell {
background: $bg-panel;
border: 1px solid $border;
border-radius: 3px;
padding: 4px 5px 6px;
min-height: 220px;
&.filled { border-color: $sms-highlight; background: rgba($sms-highlight, .04); }
&.highlight { box-shadow: 0 0 0 1px rgba($sms-highlight, .35) inset; }
}
.pos-title {
text-align: center;
font-size: 11px;
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.45;
td {
padding: 1px 2px;
vertical-align: top;
white-space: nowrap;
}
td.k {
color: $text-muted;
text-align: right;
width: 56%;
font-size: 10px;
}
td.v {
color: $sms-highlight;
text-align: right;
font-family: $font-mono;
font-weight: 600;
}
.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; }
// ── 待上卷卡片 ──
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.plan-card {
background: $bg-panel; border: 1px solid $border; border-radius: 6px;
padding: 10px; display: flex; flex-direction: column; gap: 8px;
&.online { border-color: $accent-green; box-shadow: 0 0 0 1px rgba($accent-green, .25) inset; }
}
.pc-head { display: flex; align-items: center; justify-content: space-between; }
.pc-coil { font-family: $font-mono; font-weight: 700; color: $sms-teal; font-size: 13px; }
.pc-body { display: flex; flex-direction: column; gap: 3px; }
.pc-row { display: flex; justify-content: space-between; font-size: 11.5px; span { color: $text-muted; } b { color: $text-primary; font-family: $font-mono; } }
.action-link {
color: $accent-green;
cursor: pointer;
font-size: 12px;
color: $accent-green; cursor: pointer; font-size: 12px;
&:hover { text-decoration: underline; }
&.disabled { color: $text-muted; cursor: not-allowed; text-decoration: none; }
}
</style>

View File

@@ -79,6 +79,7 @@ const MENU = [
{ path: '/downtime', title: '停机管理', icon: IC.downtime },
{ path: '/inspection', title: '设备巡检', icon: IC.inspection },
{ path: '/quality', title: '质量管理', icon: IC.quality },
{ path: '/cost', title: '成本管理', icon: IC.capacity },
]
export default {

View File

@@ -78,7 +78,7 @@
<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'"
<span v-if="row.status === 'online'"
class="action-link" style="color:var(--accent-green)" @click="moveToProducing(row)">移动</span>
</td>
</tr>
@@ -274,7 +274,7 @@ export default {
created() { this.fetchData() },
methods: {
emptyForm() {
return { plan_no: '', split_count: 1, status: 'online', split_weights: [null,null,null,null,null,null] }
return { plan_no: '', split_count: 1, status: 'ready', split_weights: [null,null,null,null,null,null] }
},
async fetchData() {
this.loading = true
@@ -349,10 +349,14 @@ export default {
this.fetchData()
},
async moveToProducing(row) {
if (!confirm(`将计划 ${row.cold_coil_no || row.plan_no} 移动到入口并开始生产`)) return
await startProducing(row.id)
this.$message.success('已开始生产')
this.fetchData()
if (!confirm(`将计划 ${row.cold_coil_no || row.plan_no} 移动到上卷鞍座`)) return
try {
await startProducing(row.id)
this.$message.success('已移动到上卷鞍座')
this.fetchData()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '操作失败')
}
},
async save() {
if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return }