feat: 实时数据持久化到计划/实绩 + 质量管理改版(钢卷信息+异常记录+继承)

- 在产计划持久化实时数据快照(run_data),物料跟踪每次轮询上报;生产完成写入实绩 process_data
- 实绩页点击行展开生产阶段数据 + 实时数据快照(测点列表)
- 质量管理默认进入异常管理,改为全宽:顶部选卷 + 钢卷信息(5列) + 异常记录表
- 异常记录新增「继承来源」列与「继承」按钮(从来源卷复制缺陷),进入即预置6行
- qc_defect 增加 inherit_source 字段

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:01:34 +08:00
parent 4567b87314
commit 649e667ad0
13 changed files with 139 additions and 40 deletions

View File

@@ -27,6 +27,7 @@ export const movePlan = (id, position) => request.patch(`/plan/${id}/move`, null
export const getPositions = () => request.get('/plan/positions/all')
export const commitProducing = id => request.patch(`/plan/${id}/commit`) // 投入生产
export const getSaddle = () => request.get('/plan/saddle/current')
export const saveRuntime = (id, data) => request.patch(`/plan/${id}/runtime`, data)
export const seedPlans = (count = 50) => request.post('/plan/seed', null, { params: { count } })
export const getLastPlanTemplate = () => request.get('/plan/last-template')

View File

@@ -337,7 +337,7 @@
</template>
<script>
import { getPlans, startProducing } from '@/api'
import { getPlans, startProducing, saveRuntime } from '@/api'
function rnd(base, amp) { return base + (Math.random() - 0.5) * amp }
function fix(v, n = 1) { return Number(v).toFixed(n) }
@@ -560,6 +560,20 @@ export default {
const pp = this.producingPlan
if (pp && pp.cold_coil_no) this.current.coil_no = pp.cold_coil_no
this.prodBase = pp ? { id: pp.id, len: pp.run_length_m || 0, at: Date.now(), speed: pp.run_speed || 0 } : null
// 持久化实时数据快照到在产计划
if (pp) this.persistRuntime(pp)
} catch (e) { /* ignore */ }
},
async persistRuntime(pp) {
try {
const snapshot = {
time: new Date().toISOString(),
coil_no: pp.cold_coil_no || pp.plan_no,
line_speed: Number(this.current.speed.toFixed(1)),
weld_position: Number((this.weld.position * 100).toFixed(1)),
items: this.rtItems.map(it => ({ label: it.label, value: it.val, unit: it.unit })),
}
await saveRuntime(pp.id, snapshot)
} catch (e) { /* ignore */ }
},
async movePlan(p) {

View File

@@ -117,6 +117,20 @@
<div class="sg-row"><span class="sg-k">成品质量</span><span class="sg-v">{{ fmt(selectedRow.product_quality) }} <i>%</i></span></div>
<div class="sg-row"><span class="sg-k">操作员</span><span class="sg-v">{{ selectedRow.operator || '—' }}</span></div>
</div>
<template v-if="selectedRow.process_data && selectedRow.process_data.items && selectedRow.process_data.items.length">
<div class="card-header" style="border-top:1px solid var(--border);">
实时数据快照
<span class="ch-badge">{{ selectedRow.process_data.items.length }} 测点</span>
<span class="td-muted" style="margin-left:auto;font-size:11px;">采集 {{ fmtTime(selectedRow.process_data.time) }}</span>
</div>
<div class="rt-grid">
<div v-for="(it, i) in selectedRow.process_data.items" :key="i" class="rt-cell">
<span class="rt-l">{{ it.label }}</span>
<span class="rt-v">{{ it.value }}<i>{{ it.unit }}</i></span>
</div>
</div>
</template>
</div>
<!-- 新增/编辑弹窗 -->
@@ -354,6 +368,10 @@ export default {
.sg-row { display: flex; align-items: center; gap: 10px; font-size: 12px; min-height: 22px; }
.sg-k { color: $text-muted; min-width: 64px; }
.sg-v { color: $text-primary; font-family: $font-mono; font-weight: 600; i { color: $text-muted; font-style: normal; font-family: $font-main; font-weight: 400; font-size: 11px; margin-left: 2px; } }
.rt-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px 24px; padding: 10px 18px 16px; }
.rt-cell { display: flex; justify-content: space-between; gap: 10px; font-size: 11.5px; border-bottom: 1px dashed $border; padding: 2px 0; }
.rt-l { color: $text-secondary; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-v { color: $sms-teal; font-family: $font-mono; font-weight: 600; white-space: nowrap; i { color: $text-muted; font-style: normal; font-family: $font-main; font-weight: 400; margin-left: 3px; } }
.form-field { display: flex; flex-direction: column; gap: 5px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; }
.data-table.compact th, .data-table.compact td { padding: 4px 6px; font-size: 11.5px; }

View File

@@ -90,38 +90,24 @@
<!-- 异常管理 Tab -->
<template v-if="activeTab === 'abnormal'">
<div class="abn-layout">
<!-- 左侧钢卷列表 -->
<div class="abn-sidebar">
<div class="sidebar-header">
钢卷列表
<span class="add-btn" title="刷新" @click="fetchCoils"></span>
</div>
<div class="sidebar-search">
<input v-model="coilQuery.coil_no" class="kv-input" placeholder="搜索卷号..." style="width:100%;" @keyup.enter="fetchCoils" />
</div>
<div class="cl-list">
<div
v-for="c in coils"
:key="c.id"
:class="['cl-item', { active: selectedCoil && selectedCoil.id === c.id }]"
@click="selectCoil(c)"
>
<div class="cl-name">{{ c.coil_no }}</div>
<div class="cl-meta">
<span class="td-muted" style="font-size:10px;">{{ c.steel_grade || '—' }}</span>
<span class="td-muted" style="font-size:10px;">{{ c.spec_thickness ? c.spec_thickness + '×' + (c.spec_width || '?') : '' }}</span>
</div>
</div>
<div v-if="!coils.length" class="cl-empty">暂无钢卷</div>
<!-- 顶部选择钢卷 -->
<div class="card">
<div class="card-body" style="padding:10px 14px;">
<div class="flex-row" style="gap:12px;flex-wrap:wrap;">
<span class="kv-label">钢卷</span>
<select v-model="selectedCoilNo" class="kv-input" style="width:240px;" @change="onCoilChange">
<option value="">请选择钢卷</option>
<option v-for="c in coils" :key="c.id" :value="c.coil_no">{{ c.coil_no }} · {{ c.steel_grade || '' }}</option>
</select>
<input v-model="coilQuery.coil_no" class="kv-input" style="width:160px;" placeholder="搜索卷号" @keyup.enter="fetchCoils" />
<button class="btn btn-outline" @click="fetchCoils">刷新列表</button>
</div>
</div>
</div>
<!-- 右侧异常管理面板 -->
<div class="abn-main">
<div v-if="!selectedCoil" class="empty-tip">请从左侧选择钢卷</div>
<div v-if="!selectedCoil" class="card"><div class="card-body"><div class="empty-tip">请选择钢卷</div></div></div>
<template v-else>
<template v-else>
<!-- 钢卷信息 -->
<div class="card">
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
@@ -167,9 +153,10 @@
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
<span>异常记录 <span class="ch-badge">{{ defects.length }} </span></span>
<div class="flex-row" style="gap:8px;">
<button class="btn btn-primary" style="padding:2px 14px;font-size:11px;" :disabled="saving" @click="saveDefects">{{ saving ? '保存中...' : '保存' }}</button>
<button class="btn btn-outline" style="padding:2px 12px;font-size:11px;" @click="openInherit">继承</button>
<button class="btn btn-outline" style="padding:2px 10px;font-size:11px;" @click="addDefectRow"> 新增行</button>
<button class="btn btn-outline" style="padding:2px 10px;font-size:11px;" @click="loadDefects"> 刷新</button>
<button class="btn btn-primary" style="padding:2px 14px;font-size:11px;" :disabled="saving" @click="saveDefects">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
<div class="table-scroll">
@@ -185,6 +172,7 @@
<th style="width:180px;">断面位置</th>
<th style="width:160px;">缺陷代码</th>
<th style="width:110px;">程度</th>
<th style="width:90px;">继承来源</th>
<th style="width:60px;">主缺陷</th>
<th style="min-width:120px;">缺陷图片</th>
<th style="width:60px;">操作</th>
@@ -218,6 +206,7 @@
<input type="radio" :value="opt.value" v-model="d.degree" />{{ opt.label }}
</label>
</td>
<td class="td-muted" style="text-align:center;">{{ d.inherit_source || '—' }}</td>
<td style="text-align:center;"><input type="checkbox" v-model="d.is_main" /></td>
<td><input v-model="d.image_url" class="kv-input" style="width:100%;" placeholder="图片URL" /></td>
<td>
@@ -226,16 +215,34 @@
</td>
</tr>
<tr v-if="!defects.length">
<td colspan="12" class="td-muted" style="text-align:center;padding:18px;">暂无异常点击 新增行开始录入</td>
<td colspan="13" class="td-muted" style="text-align:center;padding:18px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</template>
</template>
<!-- 继承来源选择弹窗 -->
<div v-if="inheritDialog.visible" class="modal-mask" @click.self="inheritDialog.visible=false">
<div class="modal-box" style="width:420px;">
<div class="modal-header">继承缺陷 选择来源钢卷<span class="modal-close" @click="inheritDialog.visible=false"></span></div>
<div class="modal-body">
<div class="form-field">
<div class="kv-label">来源钢卷</div>
<select v-model="inheritDialog.source" class="kv-input">
<option value="">请选择</option>
<option v-for="c in coils" :key="c.id" :value="c.coil_no" v-show="c.coil_no !== selectedCoilNo">{{ c.coil_no }} · {{ c.steel_grade || '' }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="inheritDialog.visible=false">取消</button>
<button class="btn btn-primary" @click="doInherit">确定继承</button>
</div>
</div>
</template>
</div>
<!-- 新增/编辑任务弹窗 -->
<div v-if="taskDialogVisible" class="modal-mask" @click.self="taskDialogVisible = false">
@@ -344,6 +351,7 @@ function blankDefect() {
defect_code: '',
degree: '',
is_main: false,
inherit_source: '',
image_url: '',
}
}
@@ -352,8 +360,10 @@ export default {
name: 'Quality',
data() {
return {
activeTab: 'tasks',
activeTab: 'abnormal',
saving: false,
selectedCoilNo: '',
inheritDialog: { visible: false, source: '' },
// 任务
taskData: [], taskTotal: 0,
@@ -377,7 +387,7 @@ export default {
return '—'
},
},
created() { this.fetchTasks() },
created() { this.fetchCoils(); this.fetchTasks() },
methods: {
// ── 任务 ──────────────────────────────────────
async fetchTasks() {
@@ -435,8 +445,38 @@ export default {
},
async selectCoil(c) {
this.selectedCoil = c
this.selectedCoilNo = c ? c.coil_no : ''
await this.loadDefects()
},
async onCoilChange() {
const c = this.coils.find(x => x.coil_no === this.selectedCoilNo)
if (c) await this.selectCoil(c)
else { this.selectedCoil = null; this.defects = [] }
},
openInherit() {
if (!this.selectedCoil) return
this.inheritDialog = { visible: true, source: '' }
},
async doInherit() {
const src = this.inheritDialog.source
if (!src) { this.$message.error('请选择继承来源钢卷'); return }
try {
const res = await getQcDefectsByCoil(src)
const rows = (res.data || []).map(d => ({
defect_desc: d.defect_desc || '',
start_position: d.start_position || 0,
end_position: d.end_position || 0,
upper_surface: !!d.upper_surface, lower_surface: !!d.lower_surface,
side_op: !!d.side_op, side_middle: !!d.side_middle, side_drive: !!d.side_drive,
defect_code: d.defect_code || '', degree: d.degree || '',
is_main: !!d.is_main, inherit_source: src, image_url: d.image_url || '',
}))
if (!rows.length) { this.$message.warning('来源钢卷无缺陷记录'); return }
this.defects = rows
this.inheritDialog.visible = false
this.$message.success(`已继承 ${rows.length} 条缺陷`)
} catch (e) { this.$message.error('继承失败') }
},
async reloadCoil() {
if (!this.selectedCoil) return
try {
@@ -460,10 +500,11 @@ export default {
defect_code: d.defect_code || '',
degree: d.degree || '',
is_main: !!d.is_main,
inherit_source: d.inherit_source || '',
image_url: d.image_url || '',
}))
if (!this.defects.length) this.addDefectRow()
} catch (e) { this.defects = [blankDefect()] }
while (this.defects.length < 6) this.defects.push(blankDefect())
} catch (e) { this.defects = Array.from({ length: 6 }, () => blankDefect()) }
},
addDefectRow() { this.defects.push(blankDefect()) },
removeRow(i) { this.defects.splice(i, 1) },
@@ -496,8 +537,9 @@ export default {
defect_code: d.defect_code || null,
degree: d.degree || null,
is_main: !!d.is_main,
inherit_source: d.inherit_source || null,
image_url: d.image_url || null,
}))
})).filter(d => d.defect_desc || d.defect_code || d.start_position || d.end_position)
await bulkSaveQcDefects({ coil_no: this.selectedCoil.coil_no, defects: list })
this.$message.success('保存成功')
await this.loadDefects()