feat: 重构质量管理和设备巡检模块

质量管理: 由平铺记录改为任务制工作流(qc_task/qc_task_item/qc_defect三表)
设备巡检: 由点位+记录改为巡检模板制(eqp_checklist/item/record/detail四表)
前端: Quality.vue 支持任务列表+检验项详情+缺陷记录双Tab
前端: Inspection.vue 支持模板管理+项目维护+巡检记录+明细查看

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 16:29:02 +08:00
parent 6ae24cb14d
commit b461f0d2f8
11 changed files with 1645 additions and 749 deletions

View File

@@ -52,13 +52,24 @@ export const calibrateQuality = data => request.post('/prediction/calibr
export const resetCalibration = model => request.post(`/prediction/calibration/reset/${model}`)
// 设备巡检
export const getInspectionLocations = () => request.get('/inspection/locations')
export const createInspectionLocation = data => request.post('/inspection/locations', data)
export const getChecklists = params => request.get('/inspection/checklists', { params })
export const createChecklist = data => request.post('/inspection/checklists', data)
export const updateChecklist = (id, data) => request.put(`/inspection/checklists/${id}`, data)
export const getChecklistItems = id => request.get(`/inspection/checklists/${id}/items`)
export const createChecklistItem = (checklistId, data) => request.post('/inspection/checklist-items', data, { params: { checklist_id: checklistId } })
export const getInspectionRecords = params => request.get('/inspection/records', { params })
export const createInspectionRecord = data => request.post('/inspection/records', data)
export const getInspectionRecordDetails = id => request.get(`/inspection/records/${id}/details`)
// 质量管理
export const getQualityList = params => request.get('/quality/', { params })
export const createQuality = data => request.post('/quality/', data)
export const updateQuality = (id, data) => request.put(`/quality/${id}`, data)
export const getQualitySummary = () => request.get('/quality/summary')
export const getQcTasks = params => request.get('/quality/tasks', { params })
export const createQcTask = data => request.post('/quality/tasks', data)
export const updateQcTask = (id, data) => request.put(`/quality/tasks/${id}`, data)
export const deleteQcTask = id => request.delete(`/quality/tasks/${id}`)
export const getQcTaskItems = taskId => request.get(`/quality/tasks/${taskId}/items`)
export const createQcTaskItem = data => request.post('/quality/task-items', data)
export const updateQcTaskItem = (id, data) => request.put(`/quality/task-items/${id}`, data)
export const getQcDefects = params => request.get('/quality/defects', { params })
export const createQcDefect = data => request.post('/quality/defects', data)
export const updateQcDefect = (id, data) => request.put(`/quality/defects/${id}`, data)
export const deleteQcDefect = id => request.delete(`/quality/defects/${id}`)

View File

@@ -1,168 +1,374 @@
<template>
<div class="insp-layout">
<!-- 左侧巡检模板列表 -->
<div class="insp-sidebar">
<div class="sidebar-header">
巡检点位
<span class="add-btn" @click="openLocDialog()"></span>
巡检模板
<span class="add-btn" @click="openChecklistDialog()"></span>
</div>
<div class="loc-list">
<div class="cl-list">
<div
v-for="loc in locations"
:key="loc.id"
:class="['loc-item', { active: selectedLoc && selectedLoc.id === loc.id }]"
@click="selectLocation(loc)"
v-for="cl in checklists"
:key="cl.id"
:class="['cl-item', { active: selectedCl && selectedCl.id === cl.id }]"
@click="selectChecklist(cl)"
>
<div class="loc-code">{{ loc.code }}</div>
<div class="loc-name">{{ loc.name }}</div>
<div class="cl-name">{{ cl.name }}</div>
<div class="cl-meta">
<span class="td-muted" style="font-size:10px;">{{ cl.equipment_name || cl.equipment_code || '通用' }}</span>
<span :class="['badge', periodBadge(cl.period)]" style="font-size:9px;padding:1px 5px;">{{ periodLabel(cl.period) }}</span>
<span :class="['badge', cl.is_active ? 'badge-green' : 'badge-gray']" style="font-size:9px;padding:1px 5px;">{{ cl.is_active ? '启用' : '停用' }}</span>
</div>
</div>
<div v-if="!locations.length" class="loc-empty">暂无点位</div>
<div v-if="!checklists.length" class="cl-empty">暂无模板</div>
</div>
</div>
<!-- 右侧主区域 -->
<div class="insp-main">
<div v-if="!selectedLoc" class="empty-tip">请选择点位</div>
<div v-if="!selectedCl" class="empty-tip">请选择巡检模板</div>
<template v-else>
<!-- 模板详情卡 -->
<div class="card">
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
<span>{{ selectedLoc.name }}&nbsp;<span class="td-muted" style="font-size:11px;">{{ selectedLoc.code }}</span></span>
<button class="btn btn-primary" @click="openRecordDialog(null)">录入巡检</button>
<span>{{ selectedCl.name }}</span>
<div class="flex-row" style="gap:8px;">
<span :class="['badge', cl_is_active_badge]">{{ selectedCl.is_active ? '启用中' : '已停用' }}</span>
<button class="btn btn-outline" style="padding:2px 10px;font-size:11px;" @click="openEditChecklistDialog()">编辑</button>
<button class="btn btn-primary" style="padding:2px 10px;font-size:11px;" @click="openInspectDialog()">开始巡检</button>
</div>
</div>
<div class="card-body">
<div class="flex-row" style="gap:24px;flex-wrap:wrap;">
<div class="flex-row"><span class="kv-label">设备编号</span><span class="kv-value">{{ selectedCl.equipment_code || '—' }}</span></div>
<div class="flex-row"><span class="kv-label">设备名称</span><span class="kv-value">{{ selectedCl.equipment_name || '—' }}</span></div>
<div class="flex-row"><span class="kv-label">巡检周期</span><span class="kv-value">{{ periodLabel(selectedCl.period) }}</span></div>
<div class="flex-row"><span class="kv-label">描述</span><span class="kv-value">{{ selectedCl.description || '—' }}</span></div>
</div>
</div>
</div>
<!-- 模板项目 -->
<div class="card">
<div class="card-header">关联设备</div>
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
巡检项目
<button class="btn btn-outline" style="padding:2px 10px;font-size:11px;" @click="openItemDialog()"> 添加项目</button>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>设备编号</th><th>设备名称</th><th>类别</th><th>状态</th><th>操作</th>
<th style="width:50px;">序号</th>
<th>项目名称</th>
<th>检验标准</th>
</tr>
</thead>
<tbody>
<tr v-for="eq in locationEquipments" :key="eq.id">
<td class="td-num">{{ eq.code }}</td>
<td>{{ eq.name }}</td>
<td>{{ eq.category || '—' }}</td>
<td><span :class="['badge', eqStatusBadge(eq.status)]">{{ eqStatusLabel(eq.status) }}</span></td>
<td><span class="action-link" @click="openRecordDialog(eq)">录入</span></td>
<tr v-for="item in clItems" :key="item.id">
<td class="td-num">{{ item.sort_order }}</td>
<td>{{ item.item_name }}</td>
<td class="td-muted">{{ item.item_standard || '—' }}</td>
</tr>
<tr v-if="!locationEquipments.length">
<td colspan="5" class="td-muted" style="text-align:center;padding:16px;">该点位暂无关联设备</td>
<tr v-if="!clItems.length">
<td colspan="3" class="td-muted" style="text-align:center;padding:16px;">暂无巡检项目</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 巡检记录 -->
<div class="card">
<div class="card-header">
巡检历史
<span class="ch-badge">{{ records.length }} </span>
巡检记录
<span class="ch-badge"> {{ recordTotal }} </span>
</div>
<div class="table-scroll" v-loading="loadingRecords">
<!-- 记录过滤 -->
<div class="card-body" style="padding:8px 14px;border-bottom:1px solid var(--border, #30363d);">
<div class="flex-row" style="gap:10px;flex-wrap:wrap;">
<div class="flex-row">
<span class="kv-label">状态</span>
<select v-model="recordQuery.status" class="kv-input" style="width:90px;">
<option value="">全部</option>
<option value="ok">正常</option>
<option value="issue">有问题</option>
<option value="urgent">紧急</option>
</select>
</div>
<div class="flex-row">
<span class="kv-label">日期</span>
<input v-model="recordQuery.start_date" type="date" class="kv-input" style="width:130px;" />
<span class="kv-label">~</span>
<input v-model="recordQuery.end_date" type="date" class="kv-input" style="width:130px;" />
</div>
<button class="btn btn-primary" @click="fetchRecords">查询</button>
</div>
</div>
<div class="table-scroll">
<table class="data-table">
<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="r in records" :key="r.id">
<td class="td-muted">{{ fmtTime(r.created_at) }}</td>
<td>{{ r.equipment_name || r.equipment_code || '—' }}</td>
<td class="td-num">{{ r.scan_code || '—' }}</td>
<td><span :class="['badge', resultBadge(r.result)]">{{ resultLabel(r.result) }}</span></td>
<td class="td-muted">{{ fmtTime(r.inspect_time) }}</td>
<td>{{ r.inspector }}</td>
<td>{{ r.notes || '—' }}</td>
<td><span :class="['badge', recordStatusBadge(r.status)]">{{ recordStatusLabel(r.status) }}</span></td>
<td>
<span v-if="r.overall_result" :class="['badge', r.overall_result === 'pass' ? 'badge-green' : 'badge-red']">
{{ r.overall_result === 'pass' ? '通过' : '不通过' }}
</span>
<span v-else class="td-muted"></span>
</td>
<td class="td-muted">{{ r.remark || '—' }}</td>
<td><span class="action-link" @click="viewDetails(r)">查看明细</span></td>
</tr>
<tr v-if="!records.length && !loadingRecords">
<tr v-if="!records.length">
<td colspan="6" class="td-muted" style="text-align:center;padding:16px;">暂无巡检记录</td>
</tr>
</tbody>
</table>
</div>
<div class="card-body" style="padding:8px 14px;" v-if="recordTotal > recordQuery.page_size">
<div class="flex-row">
<button class="btn btn-outline" :disabled="recordQuery.page <= 1" @click="recordQuery.page--; fetchRecords()">上一页</button>
<span class="kv-label"> {{ recordQuery.page }} / {{ Math.ceil(recordTotal / recordQuery.page_size) }} </span>
<button class="btn btn-outline" :disabled="recordQuery.page >= Math.ceil(recordTotal / recordQuery.page_size)" @click="recordQuery.page++; fetchRecords()">下一页</button>
</div>
</div>
</div>
</template>
</div>
<div v-if="recordDialogVisible" class="modal-mask" @click.self="recordDialogVisible=false">
<div class="modal-box" style="width:460px;">
<!-- 新增模板弹窗 -->
<div v-if="clDialogVisible" class="modal-mask" @click.self="clDialogVisible = false">
<div class="modal-box" style="width:480px;">
<div class="modal-header">
录入巡检
<span class="modal-close" @click="recordDialogVisible=false"></span>
新增巡检模板
<span class="modal-close" @click="clDialogVisible = false"></span>
</div>
<div class="modal-body">
<div style="display:flex;flex-direction:column;gap:12px;">
<div class="form-field">
<div class="kv-label">点位</div>
<input class="kv-input" :value="selectedLoc.name" disabled />
<div class="kv-label">模板名称 *</div>
<input v-model="clForm.name" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">设备编号</div>
<input v-model="recordForm.equipment_code" class="kv-input" />
<input v-model="clForm.equipment_code" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">设备名称</div>
<input v-model="recordForm.equipment_name" class="kv-input" />
<input v-model="clForm.equipment_name" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">二维码</div>
<input v-model="recordForm.scan_code" class="kv-input" ref="scanInput" />
</div>
<div class="form-field">
<div class="kv-label">巡检结果 *</div>
<select v-model="recordForm.result" class="kv-input">
<option value="normal">正常</option>
<option value="abnormal">异常</option>
<option value="pending">待处理</option>
<div class="kv-label">巡检周期</div>
<select v-model="clForm.period" class="kv-input">
<option value="daily">每日</option>
<option value="weekly">每周</option>
<option value="monthly">每月</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">巡检人 *</div>
<input v-model="recordForm.inspector" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">备注</div>
<textarea v-model="recordForm.notes" class="kv-input" rows="3"></textarea>
<div class="kv-label">描述</div>
<textarea v-model="clForm.description" class="kv-input" rows="2"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="recordDialogVisible=false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveRecord">{{ saving ? '保存中...' : '保存' }}</button>
<button class="btn btn-outline" @click="clDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveChecklist">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
<div v-if="locDialogVisible" class="modal-mask" @click.self="locDialogVisible=false">
<div class="modal-box" style="width:380px;">
<!-- 编辑模板弹窗 -->
<div v-if="editClDialogVisible" class="modal-mask" @click.self="editClDialogVisible = false">
<div class="modal-box" style="width:480px;">
<div class="modal-header">
新增点位
<span class="modal-close" @click="locDialogVisible=false"></span>
编辑模板
<span class="modal-close" @click="editClDialogVisible = false"></span>
</div>
<div class="modal-body">
<div style="display:flex;flex-direction:column;gap:12px;">
<div class="form-field">
<div class="kv-label">点位编号 *</div>
<input v-model="locForm.code" class="kv-input" />
<div class="kv-label">模板名称 *</div>
<input v-model="editClForm.name" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">点位名称 *</div>
<input v-model="locForm.name" class="kv-input" />
<div class="kv-label">设备编号</div>
<input v-model="editClForm.equipment_code" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">设备名称</div>
<input v-model="editClForm.equipment_name" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">巡检周期</div>
<select v-model="editClForm.period" class="kv-input">
<option value="daily">每日</option>
<option value="weekly">每周</option>
<option value="monthly">每月</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">状态</div>
<select v-model="editClForm.is_active" class="kv-input">
<option :value="true">启用</option>
<option :value="false">停用</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">描述</div>
<input v-model="locForm.description" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">排序</div>
<input v-model.number="locForm.sort_order" type="number" class="kv-input" />
<textarea v-model="editClForm.description" class="kv-input" rows="2"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="locDialogVisible=false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveLocation">{{ saving ? '保存中...' : '保存' }}</button>
<button class="btn btn-outline" @click="editClDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveEditChecklist">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
<!-- 添加巡检项弹窗 -->
<div v-if="itemDialogVisible" class="modal-mask" @click.self="itemDialogVisible = false">
<div class="modal-box" style="width:400px;">
<div class="modal-header">
添加巡检项目
<span class="modal-close" @click="itemDialogVisible = false"></span>
</div>
<div class="modal-body">
<div style="display:flex;flex-direction:column;gap:12px;">
<div class="form-field">
<div class="kv-label">项目名称 *</div>
<input v-model="itemForm.item_name" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">检验标准</div>
<input v-model="itemForm.item_standard" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">排序</div>
<input v-model.number="itemForm.sort_order" type="number" class="kv-input" />
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="itemDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveItem">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
<!-- 开始巡检弹窗 -->
<div v-if="inspectDialogVisible" class="modal-mask" @click.self="inspectDialogVisible = false">
<div class="modal-box" style="width:560px;">
<div class="modal-header">
开始巡检 {{ selectedCl && selectedCl.name }}
<span class="modal-close" @click="inspectDialogVisible = false"></span>
</div>
<div class="modal-body">
<!-- 基本信息 -->
<div class="grid-2" style="gap:12px;margin-bottom:16px;">
<div class="form-field">
<div class="kv-label">巡检人 *</div>
<input v-model="inspectForm.inspector" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">巡检时间 *</div>
<input v-model="inspectForm.inspect_time" type="datetime-local" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">状态</div>
<select v-model="inspectForm.status" class="kv-input">
<option value="ok">正常</option>
<option value="issue">有问题</option>
<option value="urgent">紧急</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">总体结果</div>
<select v-model="inspectForm.overall_result" class="kv-input">
<option value="">待定</option>
<option value="pass">通过</option>
<option value="fail">不通过</option>
</select>
</div>
<div class="form-field" style="grid-column:1/-1;">
<div class="kv-label">备注</div>
<textarea v-model="inspectForm.remark" class="kv-input" rows="2"></textarea>
</div>
</div>
<!-- 巡检项填写 -->
<div class="sec-title" style="font-size:12px;color:#8b949e;margin-bottom:8px;">巡检项目记录</div>
<div v-if="!clItems.length" class="td-muted" style="font-size:12px;padding:8px 0;">该模板暂无巡检项目</div>
<div v-for="(item, idx) in inspectDetails" :key="idx" class="inspect-item-row">
<div class="inspect-item-name">
<span class="td-muted" style="font-size:10px;">{{ item.sort_order }}</span>
{{ item.item_name }}
<span v-if="item.item_standard" class="td-muted" style="font-size:10px;">{{ item.item_standard }}</span>
</div>
<div class="inspect-item-inputs">
<input v-model="item.actual_value" class="kv-input" style="width:120px;" placeholder="实测值" />
<label class="ok-label">
<input type="checkbox" v-model="item.is_ok" />
<span :style="{ color: item.is_ok ? '#28a745' : '#da3633' }">{{ item.is_ok ? '正常' : '异常' }}</span>
</label>
<input v-model="item.notes" class="kv-input" style="width:120px;" placeholder="备注" />
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="inspectDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveInspect">{{ saving ? '提交中...' : '提交巡检' }}</button>
</div>
</div>
</div>
<!-- 巡检明细弹窗 -->
<div v-if="detailDialogVisible" class="modal-mask" @click.self="detailDialogVisible = false">
<div class="modal-box" style="width:560px;">
<div class="modal-header">
巡检明细
<span class="modal-close" @click="detailDialogVisible = false"></span>
</div>
<div class="modal-body">
<table class="data-table">
<thead>
<tr>
<th>项目名称</th>
<th>实测值</th>
<th>是否正常</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr v-for="d in recordDetails" :key="d.id">
<td>{{ d.item_name }}</td>
<td class="td-num">{{ d.actual_value || '—' }}</td>
<td>
<span :class="['badge', d.is_ok ? 'badge-green' : 'badge-red']">{{ d.is_ok ? '正常' : '异常' }}</span>
</td>
<td class="td-muted">{{ d.notes || '—' }}</td>
</tr>
<tr v-if="!recordDetails.length">
<td colspan="4" class="td-muted" style="text-align:center;padding:16px;">暂无明细</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="detailDialogVisible = false">关闭</button>
</div>
</div>
</div>
@@ -171,118 +377,204 @@
<script>
import {
getInspectionLocations, createInspectionLocation,
getInspectionRecords, createInspectionRecord,
getEquipments,
getChecklists, createChecklist, updateChecklist,
getChecklistItems, createChecklistItem,
getInspectionRecords, createInspectionRecord, getInspectionRecordDetails,
} from '@/api'
const EQ_STATUS = {
normal: { label: '正常', badge: 'badge-green' },
fault: { label: '故障', badge: 'badge-red' },
maintenance: { label: '检修', badge: 'badge-yellow' },
standby: { label: '备用', badge: 'badge-gray' },
const PERIOD_MAP = {
daily: { label: '每日', badge: 'badge-blue' },
weekly: { label: '每周', badge: 'badge-yellow' },
monthly: { label: '每月', badge: 'badge' },
}
const RESULT_MAP = {
normal: { label: '正常', badge: 'badge-green' },
abnormal: { label: '异常', badge: 'badge-red' },
pending: { label: '待处理', badge: 'badge-yellow' },
const RECORD_STATUS = {
ok: { label: '正常', badge: 'badge-green' },
issue: { label: '有问题', badge: 'badge-yellow' },
urgent: { label: '紧急', badge: 'badge-red' },
}
export default {
name: 'Inspection',
data() {
return {
locations: [],
selectedLoc: null,
allEquipments: [],
records: [],
loadingRecords: false,
checklists: [],
selectedCl: null,
clItems: [],
records: [], recordTotal: 0,
recordQuery: { page: 1, page_size: 20, status: '', start_date: '', end_date: '' },
saving: false,
recordDialogVisible: false,
locDialogVisible: false,
recordForm: { result: 'normal', inspector: '', equipment_code: '', equipment_name: '', scan_code: '', notes: '' },
locForm: { code: '', name: '', description: '', sort_order: 0 },
// 新增模板
clDialogVisible: false,
clForm: { name: '', equipment_code: '', equipment_name: '', period: 'daily', description: '' },
// 编辑模板
editClDialogVisible: false,
editClForm: {},
// 添加巡检项
itemDialogVisible: false,
itemForm: { item_name: '', item_standard: '', sort_order: 0 },
// 开始巡检
inspectDialogVisible: false,
inspectForm: { inspector: '', inspect_time: '', status: 'ok', overall_result: '', remark: '' },
inspectDetails: [],
// 明细
detailDialogVisible: false,
recordDetails: [],
}
},
computed: {
locationEquipments() {
if (!this.selectedLoc) return []
return this.allEquipments.filter(e => e.location === this.selectedLoc.name)
cl_is_active_badge() {
return this.selectedCl?.is_active ? 'badge-green' : 'badge-gray'
},
},
created() {
this.fetchLocations()
this.fetchAllEquipments()
this.fetchChecklists()
},
methods: {
async fetchLocations() {
const res = await getInspectionLocations()
this.locations = res.data || []
async fetchChecklists() {
try {
const res = await getChecklists()
this.checklists = res.data || []
} catch (e) { /* ignore */ }
},
async fetchAllEquipments() {
const res = await getEquipments({ page: 1, page_size: 200 })
this.allEquipments = res.data?.items || []
async selectChecklist(cl) {
this.selectedCl = cl
await Promise.all([this.fetchClItems(), this.fetchRecords()])
},
async selectLocation(loc) {
this.selectedLoc = loc
await this.fetchRecords()
async fetchClItems() {
if (!this.selectedCl) return
try {
const res = await getChecklistItems(this.selectedCl.id)
this.clItems = res.data || []
} catch (e) { this.clItems = [] }
},
async fetchRecords() {
if (!this.selectedLoc) return
this.loadingRecords = true
if (!this.selectedCl) return
const params = { checklist_id: this.selectedCl.id, page: this.recordQuery.page, page_size: this.recordQuery.page_size }
if (this.recordQuery.status) params.status = this.recordQuery.status
if (this.recordQuery.start_date) params.start_date = this.recordQuery.start_date
if (this.recordQuery.end_date) params.end_date = this.recordQuery.end_date
try {
const res = await getInspectionRecords({ location_id: this.selectedLoc.id, page_size: 50 })
this.records = res.data?.items || []
} finally {
this.loadingRecords = false
}
const res = await getInspectionRecords(params)
this.records = res.data.items || []
this.recordTotal = res.data.total || 0
} catch (e) { this.records = []; this.recordTotal = 0 }
},
openRecordDialog(eq) {
this.recordForm = {
result: 'normal',
inspector: '',
equipment_code: eq ? eq.code : '',
equipment_name: eq ? eq.name : '',
scan_code: '',
// ── 模板 ──────────────────────────────────────
openChecklistDialog() {
this.clForm = { name: '', equipment_code: '', equipment_name: '', period: 'daily', description: '' }
this.clDialogVisible = true
},
async saveChecklist() {
if (!this.clForm.name) { this.$message.error('模板名称不能为空'); return }
this.saving = true
try {
await createChecklist(this.clForm)
this.$message.success('创建成功')
this.clDialogVisible = false
await this.fetchChecklists()
} finally { this.saving = false }
},
openEditChecklistDialog() {
this.editClForm = { ...this.selectedCl }
this.editClDialogVisible = true
},
async saveEditChecklist() {
if (!this.editClForm.name) { this.$message.error('模板名称不能为空'); return }
this.saving = true
try {
await updateChecklist(this.selectedCl.id, this.editClForm)
this.$message.success('更新成功')
this.editClDialogVisible = false
await this.fetchChecklists()
// refresh selectedCl
const updated = this.checklists.find(c => c.id === this.selectedCl.id)
if (updated) this.selectedCl = updated
} finally { this.saving = false }
},
// ── 巡检项 ────────────────────────────────────
openItemDialog() {
this.itemForm = { item_name: '', item_standard: '', sort_order: this.clItems.length }
this.itemDialogVisible = true
},
async saveItem() {
if (!this.itemForm.item_name) { this.$message.error('项目名称不能为空'); return }
this.saving = true
try {
await createChecklistItem(this.selectedCl.id, this.itemForm)
this.$message.success('添加成功')
this.itemDialogVisible = false
await this.fetchClItems()
} finally { this.saving = false }
},
// ── 开始巡检 ──────────────────────────────────
openInspectDialog() {
const now = new Date()
const pad = n => String(n).padStart(2, '0')
const localDT = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`
this.inspectForm = { inspector: '', inspect_time: localDT, status: 'ok', overall_result: '', remark: '' }
this.inspectDetails = this.clItems.map(item => ({
checklist_item_id: item.id,
item_name: item.item_name,
item_standard: item.item_standard,
sort_order: item.sort_order,
actual_value: '',
is_ok: true,
notes: '',
}
this.recordDialogVisible = true
this.$nextTick(() => { if (this.$refs.scanInput) this.$refs.scanInput.focus() })
}))
this.inspectDialogVisible = true
},
async saveRecord() {
if (!this.recordForm.inspector) { this.$message.error('巡检人不能为空'); return }
async saveInspect() {
if (!this.inspectForm.inspector) { this.$message.error('巡检人不能为空'); return }
if (!this.inspectForm.inspect_time) { this.$message.error('巡检时间不能为空'); return }
this.saving = true
try {
await createInspectionRecord({ ...this.recordForm, location_id: this.selectedLoc.id })
this.$message.success('保存成功')
this.recordDialogVisible = false
const details = this.inspectDetails.map(d => ({
checklist_item_id: d.checklist_item_id,
item_name: d.item_name,
actual_value: d.actual_value || null,
is_ok: d.is_ok,
notes: d.notes || null,
}))
await createInspectionRecord({
checklist_id: this.selectedCl.id,
inspector: this.inspectForm.inspector,
inspect_time: this.inspectForm.inspect_time,
status: this.inspectForm.status,
overall_result: this.inspectForm.overall_result || null,
remark: this.inspectForm.remark || null,
details,
})
this.$message.success('巡检提交成功')
this.inspectDialogVisible = false
await this.fetchRecords()
} finally {
this.saving = false
}
} finally { this.saving = false }
},
openLocDialog() {
this.locForm = { code: '', name: '', description: '', sort_order: 0 }
this.locDialogVisible = true
},
async saveLocation() {
if (!this.locForm.code || !this.locForm.name) { this.$message.error('编号和名称不能为空'); return }
this.saving = true
// ── 查看明细 ──────────────────────────────────
async viewDetails(record) {
try {
await createInspectionLocation(this.locForm)
this.$message.success('保存成功')
this.locDialogVisible = false
await this.fetchLocations()
} finally {
this.saving = false
}
const res = await getInspectionRecordDetails(record.id)
this.recordDetails = res.data || []
this.detailDialogVisible = true
} catch (e) { this.$message.error('获取明细失败') }
},
eqStatusLabel(s) { return EQ_STATUS[s]?.label || s },
eqStatusBadge(s) { return EQ_STATUS[s]?.badge || 'badge-gray' },
resultLabel(r) { return RESULT_MAP[r]?.label || r },
resultBadge(r) { return RESULT_MAP[r]?.badge || 'badge-gray' },
// ── 工具方法 ──────────────────────────────────
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
periodLabel(p) { return PERIOD_MAP[p]?.label || p },
periodBadge(p) { return PERIOD_MAP[p]?.badge || 'badge-gray' },
recordStatusLabel(s) { return RECORD_STATUS[s]?.label || s },
recordStatusBadge(s) { return RECORD_STATUS[s]?.badge || 'badge-gray' },
},
}
</script>
@@ -298,7 +590,7 @@ export default {
}
.insp-sidebar {
width: 220px;
width: 230px;
flex-shrink: 0;
background: $bg-card;
border: 1px solid $border;
@@ -328,14 +620,14 @@ export default {
&:hover { opacity: .7; }
}
.loc-list {
.cl-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: $border transparent;
}
.loc-item {
.cl-item {
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid rgba($border, .5);
@@ -344,9 +636,9 @@ export default {
&.active { background: rgba(0,200,255,.08); border-left: 3px solid $sms-highlight; }
}
.loc-code { font-size: 10px; font-family: $font-mono; color: $text-muted; }
.loc-name { font-size: 12px; color: $text-primary; margin-top: 2px; }
.loc-empty { padding: 20px; text-align: center; font-size: 12px; color: $text-muted; }
.cl-name { font-size: 12px; color: $text-primary; margin-bottom: 4px; }
.cl-meta { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
.cl-empty { padding: 20px; text-align: center; font-size: 12px; color: $text-muted; }
.insp-main {
flex: 1;
@@ -366,10 +658,40 @@ export default {
font-size: 13px;
}
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; &:hover { text-decoration: underline; } }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; }
.inspect-item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba($border, .4);
&:last-child { border-bottom: none; }
}
.inspect-item-name {
flex: 1;
font-size: 12px;
color: $text-primary;
}
.inspect-item-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.ok-label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.sec-title { font-size: 11px; color: $text-muted; font-weight: 600; letter-spacing: .5px; text-transform: uppercase; }
.form-field { display: flex; flex-direction: column; gap: 5px; }
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 8px; &:hover { text-decoration: underline; } }
.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-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 96vw; max-height: 92vh; 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; }

View File

@@ -1,278 +1,484 @@
<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>
<input v-model="query.coil_no" class="kv-input" style="width:140px;" @keyup.enter="fetchData" />
</div>
<div class="flex-row">
<span class="kv-label">质量等级</span>
<select v-model="query.overall_grade" class="kv-input" style="width:100px;">
<option value="">全部</option>
<option v-for="g in ['A1','A2','B1','B2','C']" :key="g" :value="g">{{ g }}</option>
</select>
</div>
<div class="flex-row">
<span class="kv-label">日期</span>
<input v-model="query.start_date" type="date" class="kv-input" style="width:140px;" />
<span class="kv-label">~</span>
<input v-model="query.end_date" type="date" class="kv-input" style="width:140px;" />
</div>
<div class="flex-row">
<button class="btn btn-primary" @click="fetchData">查询</button>
<button class="btn btn-outline" @click="openDialog()"> 新增检验</button>
<!-- 标签页 -->
<div class="tab-bar">
<span :class="['tab-item', { active: activeTab === 'tasks' }]" @click="activeTab = 'tasks'">检验任务</span>
<span :class="['tab-item', { active: activeTab === 'defects' }]" @click="activeTab = 'defects'; fetchDefects()">缺陷记录</span>
</div>
<!-- 检验任务 Tab -->
<template v-if="activeTab === 'tasks'">
<!-- 过滤栏 -->
<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="taskQuery.task_code" class="kv-input" style="width:130px;" @keyup.enter="fetchTasks" />
</div>
<div class="flex-row">
<span class="kv-label">卷号</span>
<input v-model="taskQuery.coil_no" class="kv-input" style="width:120px;" @keyup.enter="fetchTasks" />
</div>
<div class="flex-row">
<span class="kv-label">状态</span>
<select v-model="taskQuery.status" class="kv-input" style="width:100px;">
<option value="">全部</option>
<option value="0">待检验</option>
<option value="1">检验中</option>
<option value="2">待审核</option>
<option value="3">完成</option>
</select>
</div>
<div class="flex-row">
<span class="kv-label">结果</span>
<select v-model="taskQuery.result" class="kv-input" style="width:100px;">
<option value="">全部</option>
<option value="qualified">合格</option>
<option value="unqualified">不合格</option>
</select>
</div>
<div class="flex-row">
<span class="kv-label">日期</span>
<input v-model="taskQuery.start_date" type="date" class="kv-input" style="width:140px;" />
<span class="kv-label">~</span>
<input v-model="taskQuery.end_date" type="date" class="kv-input" style="width:140px;" />
</div>
<div class="flex-row">
<button class="btn btn-primary" @click="fetchTasks">查询</button>
<button class="btn btn-outline" @click="openTaskDialog()"> 新增任务</button>
</div>
</div>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid-4">
<div class="metric-box">
<div class="mb-label">合格率</div>
<div class="mb-value" :style="{ color: summary.pass_rate >= 95 ? '#28a745' : '#f0a500' }">
{{ summary.pass_rate }}
<!-- 主体区域任务列表 + 检验项面板 -->
<div class="section-row">
<!-- 任务列表 -->
<div class="card" style="flex:3;min-width:0;">
<div class="card-header">
检验任务列表
<span class="ch-badge"> {{ taskTotal }} </span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<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 in taskData"
:key="row.id"
:class="{ 'row-selected': selectedTask && selectedTask.id === row.id }"
style="cursor:pointer;"
@click="selectTask(row)"
>
<td class="td-num">{{ row.task_code }}</td>
<td class="td-num">{{ row.coil_no || '—' }}</td>
<td>{{ row.scheme_name || '—' }}</td>
<td class="td-muted">{{ row.inspect_user || '—' }}</td>
<td class="td-muted">{{ fmtTime(row.inspect_time) }}</td>
<td><span :class="['badge', taskStatusBadge(row.status)]">{{ taskStatusLabel(row.status) }}</span></td>
<td>
<span v-if="row.result" :class="['badge', row.result === 'qualified' ? 'badge-green' : 'badge-red']">
{{ row.result === 'qualified' ? '合格' : '不合格' }}
</span>
<span v-else class="td-muted"></span>
</td>
<td class="td-muted">{{ fmtTime(row.created_at) }}</td>
<td>
<span class="action-link" @click.stop="openTaskDialog(row)">编辑</span>
<span class="action-link" style="color:#da3633;" @click.stop="deleteTask(row)">删除</span>
</td>
</tr>
<tr v-if="!taskData.length">
<td colspan="9" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<div class="card-body" style="padding:8px 14px;" v-if="taskTotal > taskQuery.page_size">
<div class="flex-row">
<button class="btn btn-outline" :disabled="taskQuery.page <= 1" @click="taskQuery.page--; fetchTasks()">上一页</button>
<span class="kv-label"> {{ taskQuery.page }} / {{ Math.ceil(taskTotal / taskQuery.page_size) }} </span>
<button class="btn btn-outline" :disabled="taskQuery.page >= Math.ceil(taskTotal / taskQuery.page_size)" @click="taskQuery.page++; fetchTasks()">下一页</button>
</div>
</div>
</div>
<div class="mb-unit">%</div>
</div>
<div class="metric-box">
<div class="mb-label">平均PI评分</div>
<div class="mb-value">{{ summary.avg_pi_score }}</div>
<div class="mb-unit">/ 100</div>
</div>
<div class="metric-box">
<div class="mb-label">平均表面评分</div>
<div class="mb-value">{{ summary.avg_surface_score }}</div>
<div class="mb-unit">/ 100</div>
</div>
<div class="metric-box">
<div class="mb-label">记录总数</div>
<div class="mb-value">{{ summary.total }}</div>
<div class="mb-unit"></div>
</div>
</div>
<!-- 主体区域 -->
<div class="section-row">
<!-- 质量记录表 -->
<div class="card" style="flex:2;">
<!-- 检验项面板 -->
<div class="card" style="flex:2;min-width:0;">
<div class="card-header">
<span v-if="selectedTask">
检验项 &nbsp;<span class="td-muted" style="font-weight:400;font-size:11px;">{{ selectedTask.task_code }}</span>
</span>
<span v-else>检验项请点击任务行</span>
<button v-if="selectedTask" class="btn btn-outline" style="padding:2px 10px;font-size:11px;" @click="openItemDialog()"> 添加项目</button>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<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="item in taskItems" :key="item.id">
<td>{{ item.item_name }}</td>
<td class="td-muted">{{ item.item_type || '—' }}</td>
<td class="td-num">{{ item.standard_value != null ? item.standard_value : '—' }}</td>
<td class="td-num">{{ item.upper_limit != null ? item.upper_limit : '—' }}</td>
<td class="td-num">{{ item.lower_limit != null ? item.lower_limit : '—' }}</td>
<td class="td-muted">{{ item.unit || '—' }}</td>
<td class="td-num">{{ item.inspect_value || '—' }}</td>
<td>
<span v-if="item.is_qualified != null" :class="['badge', item.is_qualified ? 'badge-green' : 'badge-red']">
{{ item.is_qualified ? '合格' : '不合格' }}
</span>
<span v-else class="td-muted"></span>
</td>
<td class="td-muted">{{ item.judge_result || '—' }}</td>
<td class="td-muted">{{ item.inspect_user || '—' }}</td>
<td class="td-muted">{{ fmtTime(item.inspect_time) }}</td>
</tr>
<tr v-if="!taskItems.length">
<td colspan="11" class="td-muted" style="text-align:center;padding:20px;">
{{ selectedTask ? '暂无检验项' : '请先选择任务' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<!-- 缺陷记录 Tab -->
<template v-if="activeTab === 'defects'">
<!-- 过滤栏 -->
<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="defectQuery.coil_no" class="kv-input" style="width:130px;" @keyup.enter="fetchDefects" />
</div>
<div class="flex-row">
<span class="kv-label">缺陷类型</span>
<input v-model="defectQuery.defect_type" class="kv-input" style="width:120px;" @keyup.enter="fetchDefects" />
</div>
<div class="flex-row">
<span class="kv-label">严重程度</span>
<select v-model="defectQuery.degree" class="kv-input" style="width:100px;">
<option value="">全部</option>
<option value="light">轻微</option>
<option value="normal">一般</option>
<option value="serious">严重</option>
</select>
</div>
<div class="flex-row">
<span class="kv-label">日期</span>
<input v-model="defectQuery.start_date" type="date" class="kv-input" style="width:140px;" />
<span class="kv-label">~</span>
<input v-model="defectQuery.end_date" type="date" class="kv-input" style="width:140px;" />
</div>
<div class="flex-row">
<button class="btn btn-primary" @click="fetchDefects">查询</button>
<button class="btn btn-outline" @click="openDefectDialog()"> 新增缺陷</button>
</div>
</div>
</div>
</div>
<!-- 缺陷列表 -->
<div class="card">
<div class="card-header">
质量检验记录
<span class="ch-badge"> {{ total }} </span>
缺陷记录列表
<span class="ch-badge"> {{ defectTotal }} </span>
</div>
<div class="table-scroll" v-loading="loading">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>卷号</th>
<th>实测厚度</th>
<th>实测宽度</th>
<th>PI评分</th>
<th>表面评分</th>
<th>质量等级</th>
<th>残酸(g/)</th>
<th>粗糙度Ra</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 in tableData" :key="row.id">
<td class="td-num">{{ row.coil_no }}</td>
<td class="td-num">{{ row.thickness_actual || '—' }}</td>
<td class="td-num">{{ row.width_actual || '—' }}</td>
<td class="td-num">{{ row.pi_score != null ? row.pi_score.toFixed(1) : '—' }}</td>
<td class="td-num">{{ row.surface_score != null ? row.surface_score.toFixed(1) : '—' }}</td>
<tr v-for="row in defectData" :key="row.id">
<td class="td-num">{{ row.coil_no || '—' }}</td>
<td class="td-muted">{{ row.production_line || '—' }}</td>
<td class="td-muted">{{ row.position || '—' }}</td>
<td class="td-muted">{{ row.plate_surface || '—' }}</td>
<td class="td-num">{{ row.defect_code || '—' }}</td>
<td>{{ row.defect_type || '—' }}</td>
<td class="td-num">{{ row.defect_rate != null ? row.defect_rate : '—' }}</td>
<td class="td-num">{{ row.defect_weight != null ? row.defect_weight : '—' }}</td>
<td>
<span v-if="row.overall_grade" :class="['badge', gradeBadge(row.overall_grade)]">
{{ row.overall_grade }}
</span>
<span v-if="row.degree" :class="['badge', degreeBadge(row.degree)]">{{ degreeLabel(row.degree) }}</span>
<span v-else class="td-muted"></span>
</td>
<td class="td-num">{{ row.acid_residual || '—' }}</td>
<td class="td-num">{{ row.roughness_ra || '—' }}</td>
<td class="td-muted">{{ row.judge_level || '—' }}</td>
<td class="td-muted">{{ row.judge_by || '—' }}</td>
<td class="td-muted">{{ fmtTime(row.judge_time) }}</td>
<td>
<span :class="['badge', row.is_passed ? 'badge-green' : 'badge-red']">
{{ row.is_passed ? '合格' : '不合格' }}
</span>
<span class="action-link" @click="openDefectDialog(row)">编辑</span>
<span class="action-link" style="color:#da3633;" @click="deleteDefect(row)">删除</span>
</td>
<td class="td-muted">{{ row.inspector || '—' }}</td>
<td class="td-muted">{{ fmtTime(row.inspect_time) }}</td>
<td><span class="action-link" @click="openDialog(row)">编辑</span></td>
</tr>
<tr v-if="!tableData.length && !loading">
<td colspan="12" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
<tr v-if="!defectData.length">
<td colspan="13" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<div class="card-body" style="padding:8px 14px;" v-if="total > query.page_size">
<div class="card-body" style="padding:8px 14px;" v-if="defectTotal > defectQuery.page_size">
<div class="flex-row">
<button class="btn btn-outline" :disabled="query.page<=1" @click="query.page--;fetchData()">上一页</button>
<span class="kv-label"> {{ query.page }} / {{ Math.ceil(total/query.page_size) }} </span>
<button class="btn btn-outline" :disabled="query.page>=Math.ceil(total/query.page_size)" @click="query.page++;fetchData()">下一页</button>
<button class="btn btn-outline" :disabled="defectQuery.page <= 1" @click="defectQuery.page--; fetchDefects()">上一页</button>
<span class="kv-label"> {{ defectQuery.page }} / {{ Math.ceil(defectTotal / defectQuery.page_size) }} </span>
<button class="btn btn-outline" :disabled="defectQuery.page >= Math.ceil(defectTotal / defectQuery.page_size)" @click="defectQuery.page++; fetchDefects()">下一页</button>
</div>
</div>
</div>
</template>
<!-- 右侧面板 -->
<div style="flex:1;display:flex;flex-direction:column;gap:14px;min-width:0;">
<!-- 快速预测面板 -->
<div class="card">
<div class="card-header">快速质量预测</div>
<div class="card-body">
<div class="flex-col">
<div class="form-field">
<div class="kv-label">厚度 (mm)</div>
<input v-model.number="pred.thickness" type="number" class="kv-input" step="0.1" />
</div>
<div class="form-field">
<div class="kv-label">平均速度 (m/min)</div>
<input v-model.number="pred.avg_speed" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">平均HCl浓度 (g/L)</div>
<input v-model.number="pred.acid_conc_avg" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">平均温度 (°C)</div>
<input v-model.number="pred.acid_temp_avg" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">铁皮重量 (g/)</div>
<input v-model.number="pred.scale_weight" type="number" class="kv-input" step="0.5" />
</div>
<button class="btn btn-primary fw" :disabled="predLoading" @click="doPred">
{{ predLoading ? '预测中...' : '质量预测' }}
</button>
</div>
<!-- 预测结果 -->
<div v-if="predResult" class="mt8">
<div class="kv-grid" style="margin-bottom:10px;">
<span class="kv-label">PI评分</span>
<span class="kv-value">{{ predResult.pi_score }}</span>
<span class="kv-label">表面评分</span>
<span class="kv-value">{{ predResult.surface_score }}</span>
<span class="kv-label">综合等级</span>
<span>
<span :class="['badge', gradeBadge(predResult.overall_grade)]" style="font-size:14px;padding:2px 12px;">
{{ predResult.overall_grade }}
</span>
</span>
</div>
<div class="sec-title">工艺建议</div>
<div v-for="(r, i) in predResult.recommendations" :key="i" class="rec-item">
💡 {{ r }}
</div>
</div>
</div>
</div>
<!-- 等级分布图 -->
<div class="card">
<div class="card-header">等级分布</div>
<div class="card-body">
<canvas ref="gradeChart" height="140"></canvas>
</div>
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
<div class="modal-box" style="width:680px;">
<!-- 新增/编辑任务弹窗 -->
<div v-if="taskDialogVisible" class="modal-mask" @click.self="taskDialogVisible = false">
<div class="modal-box" style="width:560px;">
<div class="modal-header">
{{ editRow ? '编辑质量记录 #' + editRow.id : '新增质量记录' }}
<span class="modal-close" @click="dialogVisible=false"></span>
{{ editTask ? '编辑任务 #' + editTask.id : '新增检验任务' }}
<span class="modal-close" @click="taskDialogVisible = false"></span>
</div>
<div class="modal-body">
<div class="grid-3" style="gap:12px;margin-bottom:14px;">
<div class="grid-2" style="gap:12px;">
<div class="form-field">
<div class="kv-label"> *</div>
<input v-model="form.coil_no" class="kv-input" />
<div class="kv-label">任务编 *</div>
<input v-model="taskForm.task_code" class="kv-input" :disabled="!!editTask" />
</div>
<div class="form-field">
<div class="kv-label">实测厚度 (mm)</div>
<input v-model.number="form.thickness_actual" type="number" class="kv-input" step="0.01" />
<div class="kv-label">卷号</div>
<input v-model="taskForm.coil_no" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">实测宽度 (mm)</div>
<input v-model.number="form.width_actual" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">平直度 (IU)</div>
<input v-model.number="form.flatness" type="number" class="kv-input" step="0.1" />
</div>
<div class="form-field">
<div class="kv-label">凸度 (μm)</div>
<input v-model.number="form.crown" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">表面缺陷类型</div>
<select v-model="form.surface_defect_type" class="kv-input">
<option value="">无缺陷</option>
<option value="划伤">划伤</option>
<option value="压印">压印</option>
<option value="氧化色">氧化色</option>
<option value="过酸洗">过酸洗</option>
<option value="欠酸洗">欠酸洗</option>
<option value="锈迹">锈迹</option>
<div class="kv-label">任务类型</div>
<select v-model="taskForm.task_type" class="kv-input">
<option value="">请选择</option>
<option value="incoming">来料检验</option>
<option value="process">过程检验</option>
<option value="final">成品检验</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">缺陷长度 (m)</div>
<input v-model.number="form.defect_length_m" type="number" class="kv-input" step="0.1" />
<div class="kv-label">方案名称</div>
<input v-model="taskForm.scheme_name" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">缺陷位置</div>
<input v-model="form.defect_position" class="kv-input" />
<div class="kv-label">检验人员</div>
<input v-model="taskForm.inspect_user" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">PI评分</div>
<input v-model.number="form.pi_score" type="number" class="kv-input" step="0.1" min="0" max="100" />
</div>
<div class="form-field">
<div class="kv-label">表面评分</div>
<input v-model.number="form.surface_score" type="number" class="kv-input" step="0.1" min="0" max="100" />
</div>
<div class="form-field">
<div class="kv-label">综合等级</div>
<select v-model="form.overall_grade" class="kv-input">
<option v-for="g in ['A1','A2','B1','B2','C']" :key="g" :value="g">{{ g }}</option>
<div v-if="editTask" class="form-field">
<div class="kv-label">状态</div>
<select v-model="taskForm.status" class="kv-input">
<option :value="0">待检验</option>
<option :value="1">检验中</option>
<option :value="2">待审核</option>
<option :value="3">完成</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">残酸量 (g/)</div>
<input v-model.number="form.acid_residual" type="number" class="kv-input" step="0.01" />
</div>
<div class="form-field">
<div class="kv-label">粗糙度 Ra (μm)</div>
<input v-model.number="form.roughness_ra" type="number" class="kv-input" step="0.01" />
</div>
<div class="form-field">
<div class="kv-label">检验员</div>
<input v-model="form.inspector" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">是否合格</div>
<select v-model="form.is_passed" class="kv-input">
<option :value="true">合格</option>
<option :value="false">不合格</option>
<div v-if="editTask" class="form-field">
<div class="kv-label">检验结果</div>
<select v-model="taskForm.result" class="kv-input">
<option value="">待定</option>
<option value="qualified">合格</option>
<option value="unqualified">不合格</option>
</select>
</div>
<div class="form-field" style="grid-column:1/-1;">
<div class="kv-label">备注</div>
<textarea v-model="taskForm.remark" class="kv-input" rows="2"></textarea>
</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>
<button class="btn btn-outline" @click="taskDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveTask">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
<!-- 添加检验项弹窗 -->
<div v-if="itemDialogVisible" class="modal-mask" @click.self="itemDialogVisible = false">
<div class="modal-box" style="width:500px;">
<div class="modal-header">
添加检验项
<span class="modal-close" @click="itemDialogVisible = false"></span>
</div>
<div class="modal-body">
<div class="grid-2" style="gap:12px;">
<div class="form-field" style="grid-column:1/-1;">
<div class="kv-label">检验项名称 *</div>
<input v-model="itemForm.item_name" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">类型</div>
<select v-model="itemForm.item_type" class="kv-input">
<option value="">请选择</option>
<option value="quantitative">定量</option>
<option value="qualitative">定性</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">单位</div>
<input v-model="itemForm.unit" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">标准值</div>
<input v-model.number="itemForm.standard_value" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">上限</div>
<input v-model.number="itemForm.upper_limit" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">下限</div>
<input v-model.number="itemForm.lower_limit" type="number" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">实测值</div>
<input v-model="itemForm.inspect_value" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">是否合格</div>
<select v-model="itemForm.is_qualified" class="kv-input">
<option :value="null">待判定</option>
<option :value="1">合格</option>
<option :value="0">不合格</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">检验人</div>
<input v-model="itemForm.inspect_user" class="kv-input" />
</div>
<div class="form-field" style="grid-column:1/-1;">
<div class="kv-label">判定结果说明</div>
<input v-model="itemForm.judge_result" class="kv-input" />
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="itemDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveItem">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
<!-- 新增/编辑缺陷弹窗 -->
<div v-if="defectDialogVisible" class="modal-mask" @click.self="defectDialogVisible = false">
<div class="modal-box" style="width:580px;">
<div class="modal-header">
{{ editDefect ? '编辑缺陷记录 #' + editDefect.id : '新增缺陷记录' }}
<span class="modal-close" @click="defectDialogVisible = false"></span>
</div>
<div class="modal-body">
<div class="grid-2" style="gap:12px;">
<div class="form-field">
<div class="kv-label">卷号</div>
<input v-model="defectForm.coil_no" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">产线</div>
<input v-model="defectForm.production_line" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">位置</div>
<input v-model="defectForm.position" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">板面</div>
<select v-model="defectForm.plate_surface" 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">缺陷代码</div>
<input v-model="defectForm.defect_code" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">缺陷类型</div>
<input v-model="defectForm.defect_type" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">缺陷率(%)</div>
<input v-model.number="defectForm.defect_rate" type="number" step="0.01" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">缺陷重量(kg)</div>
<input v-model.number="defectForm.defect_weight" type="number" step="0.1" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">严重程度</div>
<select v-model="defectForm.degree" class="kv-input">
<option value="">请选择</option>
<option value="light">轻微</option>
<option value="normal">一般</option>
<option value="serious">严重</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">判定等级</div>
<input v-model="defectForm.judge_level" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">判定人</div>
<input v-model="defectForm.judge_by" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">判定时间</div>
<input v-model="defectForm.judge_time" type="datetime-local" class="kv-input" />
</div>
<div class="form-field" style="grid-column:1/-1;">
<div class="kv-label">备注</div>
<textarea v-model="defectForm.remark" class="kv-input" rows="2"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="defectDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveDefect">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
@@ -280,127 +486,202 @@
</template>
<script>
import { getQualityList, createQuality, getQualitySummary, predictQuality } from '@/api'
import {
getQcTasks, createQcTask, updateQcTask, deleteQcTask,
getQcTaskItems, createQcTaskItem,
getQcDefects, createQcDefect, updateQcDefect, deleteQcDefect,
} from '@/api'
const TASK_STATUS = {
0: { label: '待检验', badge: 'badge-gray' },
1: { label: '检验中', badge: 'badge-yellow' },
2: { label: '待审核', badge: 'badge' },
3: { label: '完成', badge: 'badge-green' },
}
const DEGREE_MAP = {
light: { label: '轻微', badge: 'badge-blue' },
normal: { label: '一般', badge: 'badge-yellow' },
serious: { label: '严重', badge: 'badge-red' },
}
export default {
name: 'Quality',
data() {
return {
loading: false, saving: false, predLoading: false,
tableData: [], total: 0,
summary: { pass_rate: 0, avg_pi_score: 0, avg_surface_score: 0, total: 0, grade_distribution: {} },
query: { page: 1, page_size: 20, coil_no: '', overall_grade: '', start_date: '', end_date: '' },
dialogVisible: false, editRow: null, form: {},
pred: { thickness: 3.0, avg_speed: 100, acid_conc_avg: 160, acid_temp_avg: 75, scale_weight: 8.5 },
predResult: null,
activeTab: 'tasks',
saving: false,
// 任务
taskData: [], taskTotal: 0,
taskQuery: { page: 1, page_size: 20, task_code: '', coil_no: '', status: '', result: '', start_date: '', end_date: '' },
selectedTask: null,
taskItems: [],
taskDialogVisible: false, editTask: null, taskForm: {},
itemDialogVisible: false, itemForm: {},
// 缺陷
defectData: [], defectTotal: 0,
defectQuery: { page: 1, page_size: 20, coil_no: '', defect_type: '', degree: '', start_date: '', end_date: '' },
defectDialogVisible: false, editDefect: null, defectForm: {},
}
},
created() {
this.fetchData()
this.fetchSummary()
this.fetchTasks()
},
methods: {
async fetchData() {
this.loading = true
const params = { ...this.query }
if (!params.coil_no) delete params.coil_no
if (!params.overall_grade) delete params.overall_grade
if (params.start_date) params.start_date = params.start_date + 'T00:00:00'
else delete params.start_date
if (params.end_date) params.end_date = params.end_date + 'T23:59:59'
else delete params.end_date
// ── 任务 ──────────────────────────────────────
async fetchTasks() {
const params = {}
if (this.taskQuery.task_code) params.task_code = this.taskQuery.task_code
if (this.taskQuery.coil_no) params.coil_no = this.taskQuery.coil_no
if (this.taskQuery.status !== '') params.status = this.taskQuery.status
if (this.taskQuery.result) params.result = this.taskQuery.result
if (this.taskQuery.start_date) params.start_date = this.taskQuery.start_date
if (this.taskQuery.end_date) params.end_date = this.taskQuery.end_date
params.page = this.taskQuery.page
params.page_size = this.taskQuery.page_size
try {
const res = await getQualityList(params)
this.tableData = res.data.items
this.total = res.data.total
} finally { this.loading = false }
},
async fetchSummary() {
try {
const res = await getQualitySummary()
this.summary = res.data
this.$nextTick(() => this.drawChart())
const res = await getQcTasks(params)
this.taskData = res.data.items
this.taskTotal = res.data.total
} catch (e) { /* ignore */ }
},
drawChart() {
const canvas = this.$refs.gradeChart
if (!canvas) return
const ctx = canvas.getContext('2d')
const grades = ['A1','A2','B1','B2','C']
const dist = this.summary.grade_distribution || {}
const counts = grades.map(g => dist[g] || 0)
const maxCount = Math.max(...counts, 1)
const colors = ['#28a745','#00c8ff','#0078d4','#f0a500','#da3633']
const W = canvas.offsetWidth || 300
const H = 140
canvas.width = W
canvas.height = H
ctx.clearRect(0, 0, W, H)
const barW = Math.floor(W / grades.length) - 8
const pad = 4
grades.forEach((g, i) => {
const barH = Math.floor((counts[i] / maxCount) * (H - 36))
const x = i * (barW + 8) + pad
const y = H - barH - 20
ctx.fillStyle = colors[i]
ctx.globalAlpha = 0.85
ctx.fillRect(x, y, barW, barH)
ctx.globalAlpha = 1
ctx.fillStyle = '#e6edf3'
ctx.font = '11px Consolas'
ctx.textAlign = 'center'
ctx.fillText(counts[i], x + barW / 2, y - 4)
ctx.fillStyle = '#8b949e'
ctx.font = '11px "Microsoft YaHei"'
ctx.fillText(g, x + barW / 2, H - 4)
})
async selectTask(row) {
this.selectedTask = row
try {
const res = await getQcTaskItems(row.id)
this.taskItems = res.data || []
} catch (e) { this.taskItems = [] }
},
fmtTime(t) { return t ? t.replace('T', ' ').slice(0, 16) : '—' },
gradeBadge(g) {
if (g === 'A1') return 'badge-green'
if (g === 'A2') return 'badge-blue'
if (g === 'B1') return 'badge-blue'
if (g === 'B2') return 'badge-yellow'
return 'badge-red'
openTaskDialog(row = null) {
this.editTask = row
this.taskForm = row ? { ...row } : { task_code: '', status: 0 }
this.taskDialogVisible = true
},
openDialog(row = null) {
this.editRow = row
this.form = row ? { ...row } : { is_passed: true }
this.dialogVisible = true
},
async save() {
if (!this.form.coil_no) { this.$message.error('卷号不能为空'); return }
async saveTask() {
if (!this.taskForm.task_code) { this.$message.error('任务编号不能为空'); return }
this.saving = true
try {
if (this.editRow) await createQuality({ ...this.form }) // use PUT when available
else await createQuality(this.form)
if (this.editTask) {
await updateQcTask(this.editTask.id, this.taskForm)
} else {
await createQcTask(this.taskForm)
}
this.$message.success('保存成功')
this.dialogVisible = false
this.fetchData()
this.fetchSummary()
this.taskDialogVisible = false
this.fetchTasks()
} finally { this.saving = false }
},
async doPred() {
this.predLoading = true
async deleteTask(row) {
if (!confirm(`确认删除任务 ${row.task_code}`)) return
try {
const res = await predictQuality(this.pred)
this.predResult = res.data
} catch (e) {
this.$message.error('预测失败:' + (e.response?.data?.detail || e.message))
} finally { this.predLoading = false }
await deleteQcTask(row.id)
this.$message.success('已删除')
if (this.selectedTask && this.selectedTask.id === row.id) {
this.selectedTask = null
this.taskItems = []
}
this.fetchTasks()
} catch (e) { this.$message.error('删除失败') }
},
}
// ── 检验项 ────────────────────────────────────
openItemDialog() {
this.itemForm = { task_id: this.selectedTask.id, item_name: '', is_qualified: null }
this.itemDialogVisible = true
},
async saveItem() {
if (!this.itemForm.item_name) { this.$message.error('检验项名称不能为空'); return }
this.saving = true
try {
await createQcTaskItem({ ...this.itemForm, task_id: this.selectedTask.id })
this.$message.success('添加成功')
this.itemDialogVisible = false
await this.selectTask(this.selectedTask)
} finally { this.saving = false }
},
// ── 缺陷 ──────────────────────────────────────
async fetchDefects() {
const params = {}
if (this.defectQuery.coil_no) params.coil_no = this.defectQuery.coil_no
if (this.defectQuery.defect_type) params.defect_type = this.defectQuery.defect_type
if (this.defectQuery.degree) params.degree = this.defectQuery.degree
if (this.defectQuery.start_date) params.start_date = this.defectQuery.start_date
if (this.defectQuery.end_date) params.end_date = this.defectQuery.end_date
params.page = this.defectQuery.page
params.page_size = this.defectQuery.page_size
try {
const res = await getQcDefects(params)
this.defectData = res.data.items
this.defectTotal = res.data.total
} catch (e) { /* ignore */ }
},
openDefectDialog(row = null) {
this.editDefect = row
this.defectForm = row ? { ...row } : {}
this.defectDialogVisible = true
},
async saveDefect() {
this.saving = true
try {
if (this.editDefect) {
await updateQcDefect(this.editDefect.id, this.defectForm)
} else {
await createQcDefect(this.defectForm)
}
this.$message.success('保存成功')
this.defectDialogVisible = false
this.fetchDefects()
} finally { this.saving = false }
},
async deleteDefect(row) {
if (!confirm('确认删除该缺陷记录?')) return
try {
await deleteQcDefect(row.id)
this.$message.success('已删除')
this.fetchDefects()
} catch (e) { this.$message.error('删除失败') }
},
// ── 工具方法 ──────────────────────────────────
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
taskStatusLabel(s) { return TASK_STATUS[s]?.label || s },
taskStatusBadge(s) { return TASK_STATUS[s]?.badge || 'badge-gray' },
degreeLabel(d) { return DEGREE_MAP[d]?.label || d },
degreeBadge(d) { return DEGREE_MAP[d]?.badge || 'badge-gray' },
},
}
</script>
<style lang="scss" scoped>
@import '@/assets/styles/variables';
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 10px; &:hover { text-decoration: underline; } }
.tab-bar {
display: flex;
gap: 0;
margin-bottom: 14px;
border-bottom: 2px solid $border;
}
.tab-item {
padding: 8px 20px;
font-size: 13px;
cursor: pointer;
color: $text-muted;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all .15s;
&:hover { color: $text-primary; }
&.active { color: $sms-highlight; border-bottom-color: $sms-highlight; font-weight: 600; }
}
.section-row { display: flex; gap: 14px; align-items: flex-start; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; }
.row-selected { background: rgba(0,200,255,.08) !important; }
.form-field { display: flex; flex-direction: column; gap: 5px; }
.rec-item { padding: 5px 8px; margin-top: 4px; background: rgba(0,200,255,.05); border-left: 2px solid $sms-highlight; font-size: 12px; color: $text-secondary; border-radius: 0 3px 3px 0; }
.mt8 { margin-top: 8px; }
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 10px; &:hover { text-decoration: underline; } }
.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; width: 680px; max-width: 96vw; max-height: 92vh; display: flex; flex-direction: column; }
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 96vw; max-height: 92vh; 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; }