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

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,13 +1,16 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc from sqlalchemy import select, func, desc
from typing import Optional from typing import Optional
from datetime import datetime
from app.database import get_db from app.database import get_db
from app.models.inspection import InspectionLocation, InspectionRecord from app.models.inspection import EqpChecklist, EqpChecklistItem, EqpInspectionRecord, EqpInspectionDetail
from app.schemas.inspection import ( from app.schemas.inspection import (
InspectionLocationCreate, InspectionLocationOut, EqpChecklistCreate, EqpChecklistUpdate, EqpChecklistOut,
InspectionRecordCreate, InspectionRecordOut, EqpChecklistItemCreate, EqpChecklistItemOut,
EqpInspectionRecordCreate, EqpInspectionRecordOut,
EqpInspectionDetailOut,
) )
from app.schemas.common import Response, PageResponse from app.schemas.common import Response, PageResponse
from app.services.auth_service import get_current_user from app.services.auth_service import get_current_user
@@ -15,61 +18,117 @@ from app.services.auth_service import get_current_user
router = APIRouter() router = APIRouter()
@router.get("/locations", response_model=Response[list[InspectionLocationOut]]) # ─── 巡检模板 ────────────────────────────────────────────────────────────────
async def list_locations(
@router.get("/checklists", response_model=Response[list[EqpChecklistOut]])
async def list_checklists(
equipment_code: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_ = Depends(get_current_user), _=Depends(get_current_user),
): ):
result = await db.execute( query = select(EqpChecklist).order_by(EqpChecklist.id)
select(InspectionLocation).order_by(InspectionLocation.sort_order, InspectionLocation.id) if equipment_code:
) query = query.where(EqpChecklist.equipment_code == equipment_code)
items = [InspectionLocationOut.model_validate(r) for r in result.scalars()] r = await db.execute(query)
return Response.ok(items) return Response.ok([EqpChecklistOut.model_validate(c) for c in r.scalars()])
@router.post("/locations", response_model=Response[InspectionLocationOut]) @router.post("/checklists", response_model=Response[EqpChecklistOut])
async def create_location( async def create_checklist(body: EqpChecklistCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
body: InspectionLocationCreate, items_data = body.items or []
db: AsyncSession = Depends(get_db), data = body.model_dump(exclude={"items"})
_ = Depends(get_current_user), checklist = EqpChecklist(**data)
): db.add(checklist)
loc = InspectionLocation(**body.model_dump())
db.add(loc)
await db.flush() await db.flush()
return Response.ok(InspectionLocationOut.model_validate(loc)) for item in items_data:
ci = EqpChecklistItem(checklist_id=checklist.id, **item.model_dump())
db.add(ci)
await db.flush()
return Response.ok(EqpChecklistOut.model_validate(checklist))
@router.get("/records", response_model=Response[PageResponse[InspectionRecordOut]]) @router.put("/checklists/{checklist_id}", response_model=Response[EqpChecklistOut])
async def update_checklist(checklist_id: int, body: EqpChecklistUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(select(EqpChecklist).where(EqpChecklist.id == checklist_id))
checklist = r.scalar_one_or_none()
if not checklist:
raise HTTPException(status_code=404, detail="模板不存在")
for k, v in body.model_dump(exclude_none=True).items():
setattr(checklist, k, v)
await db.flush()
return Response.ok(EqpChecklistOut.model_validate(checklist))
@router.get("/checklists/{checklist_id}/items", response_model=Response[list[EqpChecklistItemOut]])
async def list_checklist_items(checklist_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(
select(EqpChecklistItem).where(EqpChecklistItem.checklist_id == checklist_id)
.order_by(EqpChecklistItem.sort_order, EqpChecklistItem.id)
)
return Response.ok([EqpChecklistItemOut.model_validate(i) for i in r.scalars()])
@router.post("/checklist-items", response_model=Response[EqpChecklistItemOut])
async def create_checklist_item(
body: EqpChecklistItemCreate,
checklist_id: int,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
):
item = EqpChecklistItem(checklist_id=checklist_id, **body.model_dump())
db.add(item)
await db.flush()
return Response.ok(EqpChecklistItemOut.model_validate(item))
# ─── 巡检记录 ────────────────────────────────────────────────────────────────
@router.get("/records", response_model=Response[PageResponse[EqpInspectionRecordOut]])
async def list_records( async def list_records(
page: int = 1, page: int = 1,
page_size: int = 30, page_size: int = 20,
location_id: Optional[int] = None, checklist_id: Optional[int] = None,
status: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_ = Depends(get_current_user), _=Depends(get_current_user),
): ):
query = select(InspectionRecord).order_by(desc(InspectionRecord.created_at)) query = select(EqpInspectionRecord).order_by(desc(EqpInspectionRecord.created_at))
if location_id: if checklist_id:
query = query.where(InspectionRecord.location_id == location_id) query = query.where(EqpInspectionRecord.checklist_id == checklist_id)
if status:
query = query.where(EqpInspectionRecord.status == status)
if start_date:
query = query.where(EqpInspectionRecord.inspect_time >= datetime.fromisoformat(start_date))
if end_date:
query = query.where(EqpInspectionRecord.inspect_time <= datetime.fromisoformat(end_date + "T23:59:59"))
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar()
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) rows = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = [InspectionRecordOut.model_validate(r) for r in result.scalars()] items = [EqpInspectionRecordOut.model_validate(r) for r in rows.scalars()]
return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items)) return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
@router.post("/records", response_model=Response[InspectionRecordOut]) @router.post("/records", response_model=Response[EqpInspectionRecordOut])
async def create_record( async def create_record(body: EqpInspectionRecordCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
body: InspectionRecordCreate, details_data = body.details or []
db: AsyncSession = Depends(get_db), # get checklist name
_ = Depends(get_current_user), cl_r = await db.execute(select(EqpChecklist).where(EqpChecklist.id == body.checklist_id))
): cl = cl_r.scalar_one_or_none()
loc_result = await db.execute( data = body.model_dump(exclude={"details"})
select(InspectionLocation).where(InspectionLocation.id == body.location_id) record = EqpInspectionRecord(**data, checklist_name=cl.name if cl else None)
)
loc = loc_result.scalar_one_or_none()
record = InspectionRecord(
**body.model_dump(),
location_name=loc.name if loc else None,
)
db.add(record) db.add(record)
await db.flush() await db.flush()
return Response.ok(InspectionRecordOut.model_validate(record)) for d in details_data:
detail = EqpInspectionDetail(record_id=record.id, **d.model_dump())
db.add(detail)
await db.flush()
return Response.ok(EqpInspectionRecordOut.model_validate(record))
@router.get("/records/{record_id}/details", response_model=Response[list[EqpInspectionDetailOut]])
async def get_record_details(record_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(
select(EqpInspectionDetail).where(EqpInspectionDetail.record_id == record_id).order_by(EqpInspectionDetail.id)
)
return Response.ok([EqpInspectionDetailOut.model_validate(d) for d in r.scalars()])

View File

@@ -5,128 +5,177 @@ from typing import Optional
from datetime import datetime from datetime import datetime
from app.database import get_db from app.database import get_db
from app.models.quality import QualityRecord from app.models.quality import QcTask, QcTaskItem, QcDefect
from app.schemas.quality import QualityRecordCreate, QualityRecordUpdate, QualityRecordOut from app.schemas.quality import (
QcTaskCreate, QcTaskUpdate, QcTaskOut,
QcTaskItemCreate, QcTaskItemUpdate, QcTaskItemOut,
QcDefectCreate, QcDefectUpdate, QcDefectOut,
)
from app.schemas.common import Response, PageResponse from app.schemas.common import Response, PageResponse
from app.services.auth_service import get_current_user from app.services.auth_service import get_current_user
from app.services.prediction import QualityPredictionModel
router = APIRouter() router = APIRouter()
@router.get("/summary", response_model=Response[dict]) # ─── 检验任务 ───────────────────────────────────────────────────────────────
async def quality_summary(
@router.get("/tasks", response_model=Response[PageResponse[QcTaskOut]])
async def list_tasks(
page: int = 1,
page_size: int = 20,
task_code: Optional[str] = None,
coil_no: Optional[str] = None,
status: Optional[int] = None,
result: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_=Depends(get_current_user), _=Depends(get_current_user),
): ):
"""合格率、平均评分、班次分组统计""" query = select(QcTask).where(QcTask.del_flag == 0).order_by(desc(QcTask.created_at))
total_q = await db.execute(select(func.count()).select_from(QualityRecord)) if task_code:
passed_q = await db.execute( query = query.where(QcTask.task_code.ilike(f"%{task_code}%"))
select(func.count()).select_from(QualityRecord).where(QualityRecord.is_passed == True)
)
avg_pi_q = await db.execute(select(func.avg(QualityRecord.pi_score)).select_from(QualityRecord))
avg_suf_q = await db.execute(select(func.avg(QualityRecord.surface_score)).select_from(QualityRecord))
# Grade distribution
grade_counts: dict = {}
for grade in ["A1", "A2", "B1", "B2", "C"]:
cnt_q = await db.execute(
select(func.count()).select_from(QualityRecord).where(QualityRecord.overall_grade == grade)
)
grade_counts[grade] = cnt_q.scalar() or 0
total = total_q.scalar() or 0
passed = passed_q.scalar() or 0
pass_rate = round(passed / total * 100, 1) if total > 0 else 0.0
return Response.ok({
"total": total,
"passed": passed,
"pass_rate": pass_rate,
"avg_pi_score": round(avg_pi_q.scalar() or 0, 1),
"avg_surface_score": round(avg_suf_q.scalar() or 0, 1),
"grade_distribution": grade_counts,
})
@router.get("/", response_model=Response[PageResponse[QualityRecordOut]])
async def list_quality(
page: int = 1,
page_size: int = 20,
coil_no: Optional[str] = None,
overall_grade: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
):
query = select(QualityRecord).order_by(desc(QualityRecord.created_at))
if coil_no: if coil_no:
query = query.where(QualityRecord.coil_no.ilike(f"%{coil_no}%")) query = query.where(QcTask.coil_no.ilike(f"%{coil_no}%"))
if overall_grade: if status is not None:
query = query.where(QualityRecord.overall_grade == overall_grade) query = query.where(QcTask.status == status)
if result:
query = query.where(QcTask.result == result)
if start_date: if start_date:
query = query.where(QualityRecord.created_at >= start_date) query = query.where(QcTask.created_at >= datetime.fromisoformat(start_date))
if end_date: if end_date:
query = query.where(QualityRecord.created_at <= end_date) query = query.where(QcTask.created_at <= datetime.fromisoformat(end_date + "T23:59:59"))
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar()
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() result_rows = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) items = [QcTaskOut.model_validate(r) for r in result_rows.scalars()]
items = [QualityRecordOut.model_validate(r) for r in result.scalars()]
return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items)) return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
@router.post("/", response_model=Response[QualityRecordOut]) @router.post("/tasks", response_model=Response[QcTaskOut])
async def create_quality( async def create_task(body: QcTaskCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
body: QualityRecordCreate, task = QcTask(**body.model_dump())
db: AsyncSession = Depends(get_db), db.add(task)
_=Depends(get_current_user),
):
"""
创建质量记录。若未提供 pi_score / surface_score / overall_grade
则尝试用 QualityPredictionModel 自动填充需要thickness_actual和相关参数
"""
data = body.model_dump()
if (
data.get("pi_score") is None
and data.get("thickness_actual") is not None
):
# Use default avg values for auto-prediction when detailed params are absent
avg_speed = 100.0 # m/min default
acid_conc_avg = 160.0 # g/L default
acid_temp_avg = 75.0 # °C default
try:
pred = QualityPredictionModel(
thickness=data["thickness_actual"],
avg_speed=avg_speed,
acid_conc_avg=acid_conc_avg,
acid_temp_avg=acid_temp_avg,
).calculate()
data["pi_score"] = pred["pi_score"]
data["surface_score"] = pred["surface_score"]
data["overall_grade"] = pred["overall_grade"]
except Exception:
pass # best-effort, do not block creation
record = QualityRecord(**data)
db.add(record)
await db.flush() await db.flush()
return Response.ok(QualityRecordOut.model_validate(record)) return Response.ok(QcTaskOut.model_validate(task))
@router.put("/{quality_id}", response_model=Response[QualityRecordOut]) @router.get("/tasks/{task_id}", response_model=Response[QcTaskOut])
async def update_quality( async def get_task(task_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
quality_id: int, r = await db.execute(select(QcTask).where(QcTask.id == task_id, QcTask.del_flag == 0))
body: QualityRecordUpdate, task = r.scalar_one_or_none()
db: AsyncSession = Depends(get_db), if not task:
_=Depends(get_current_user), raise HTTPException(status_code=404, detail="任务不存在")
): return Response.ok(QcTaskOut.model_validate(task))
result = await db.execute(select(QualityRecord).where(QualityRecord.id == quality_id))
record = result.scalar_one_or_none()
if not record: @router.put("/tasks/{task_id}", response_model=Response[QcTaskOut])
raise HTTPException(status_code=404, detail="质量记录不存在") async def update_task(task_id: int, body: QcTaskUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(select(QcTask).where(QcTask.id == task_id, QcTask.del_flag == 0))
task = r.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
for k, v in body.model_dump(exclude_none=True).items(): for k, v in body.model_dump(exclude_none=True).items():
setattr(record, k, v) setattr(task, k, v)
await db.flush() await db.flush()
return Response.ok(QualityRecordOut.model_validate(record)) return Response.ok(QcTaskOut.model_validate(task))
@router.delete("/tasks/{task_id}", response_model=Response[dict])
async def delete_task(task_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(select(QcTask).where(QcTask.id == task_id))
task = r.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task.del_flag = 1
await db.flush()
return Response.ok({})
# ─── 检验项目 ────────────────────────────────────────────────────────────────
@router.get("/tasks/{task_id}/items", response_model=Response[list[QcTaskItemOut]])
async def list_task_items(task_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(select(QcTaskItem).where(QcTaskItem.task_id == task_id).order_by(QcTaskItem.id))
return Response.ok([QcTaskItemOut.model_validate(i) for i in r.scalars()])
@router.post("/task-items", response_model=Response[QcTaskItemOut])
async def create_task_item(body: QcTaskItemCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
item = QcTaskItem(**body.model_dump())
db.add(item)
await db.flush()
return Response.ok(QcTaskItemOut.model_validate(item))
@router.put("/task-items/{item_id}", response_model=Response[QcTaskItemOut])
async def update_task_item(item_id: int, body: QcTaskItemUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(select(QcTaskItem).where(QcTaskItem.id == item_id))
item = r.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="检验项不存在")
for k, v in body.model_dump(exclude_none=True).items():
setattr(item, k, v)
await db.flush()
return Response.ok(QcTaskItemOut.model_validate(item))
# ─── 缺陷记录 ────────────────────────────────────────────────────────────────
@router.get("/defects", response_model=Response[PageResponse[QcDefectOut]])
async def list_defects(
page: int = 1,
page_size: int = 20,
coil_no: Optional[str] = None,
defect_type: Optional[str] = None,
degree: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
):
query = select(QcDefect).where(QcDefect.del_flag == 0).order_by(desc(QcDefect.created_at))
if coil_no:
query = query.where(QcDefect.coil_no.ilike(f"%{coil_no}%"))
if defect_type:
query = query.where(QcDefect.defect_type.ilike(f"%{defect_type}%"))
if degree:
query = query.where(QcDefect.degree == degree)
if start_date:
query = query.where(QcDefect.created_at >= datetime.fromisoformat(start_date))
if end_date:
query = query.where(QcDefect.created_at <= datetime.fromisoformat(end_date + "T23:59:59"))
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar()
rows = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = [QcDefectOut.model_validate(r) for r in rows.scalars()]
return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
@router.post("/defects", response_model=Response[QcDefectOut])
async def create_defect(body: QcDefectCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
defect = QcDefect(**body.model_dump())
db.add(defect)
await db.flush()
return Response.ok(QcDefectOut.model_validate(defect))
@router.put("/defects/{defect_id}", response_model=Response[QcDefectOut])
async def update_defect(defect_id: int, body: QcDefectUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(select(QcDefect).where(QcDefect.id == defect_id, QcDefect.del_flag == 0))
defect = r.scalar_one_or_none()
if not defect:
raise HTTPException(status_code=404, detail="缺陷记录不存在")
for k, v in body.model_dump(exclude_none=True).items():
setattr(defect, k, v)
await db.flush()
return Response.ok(QcDefectOut.model_validate(defect))
@router.delete("/defects/{defect_id}", response_model=Response[dict])
async def delete_defect(defect_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
r = await db.execute(select(QcDefect).where(QcDefect.id == defect_id))
defect = r.scalar_one_or_none()
if not defect:
raise HTTPException(status_code=404, detail="缺陷记录不存在")
defect.del_flag = 1
await db.flush()
return Response.ok({})

View File

@@ -6,9 +6,9 @@ from app.models.downtime import DowntimeRecord, DowntimeCategory
from app.models.equipment import Equipment, EquipmentMaintenance from app.models.equipment import Equipment, EquipmentMaintenance
from app.models.message import MessageLog from app.models.message import MessageLog
from app.models.pdi import PDIRecord from app.models.pdi import PDIRecord
from app.models.quality import QualityRecord from app.models.quality import QcTask, QcTaskItem, QcDefect
from app.models.energy import EnergyRecord from app.models.energy import EnergyRecord
from app.models.inspection import InspectionLocation, InspectionRecord from app.models.inspection import EqpChecklist, EqpChecklistItem, EqpInspectionRecord, EqpInspectionDetail
__all__ = [ __all__ = [
"User", "User",
@@ -19,7 +19,7 @@ __all__ = [
"Equipment", "EquipmentMaintenance", "Equipment", "EquipmentMaintenance",
"MessageLog", "MessageLog",
"PDIRecord", "PDIRecord",
"QualityRecord", "QcTask", "QcTaskItem", "QcDefect",
"EnergyRecord", "EnergyRecord",
"InspectionLocation", "InspectionRecord", "EqpChecklist", "EqpChecklistItem", "EqpInspectionRecord", "EqpInspectionDetail",
] ]

View File

@@ -1,28 +1,54 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey, func
from app.database import Base from app.database import Base
class InspectionLocation(Base): class EqpChecklist(Base):
__tablename__ = "inspection_locations" """设备巡检模板"""
__tablename__ = "eqp_checklist"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
code = Column(String(30), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
description = Column(Text) description = Column(Text, nullable=True)
equipment_code = Column(String(30), nullable=True, index=True)
equipment_name = Column(String(100), nullable=True)
period = Column(String(20), default="daily") # daily/weekly/monthly
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class EqpChecklistItem(Base):
"""巡检模板项目"""
__tablename__ = "eqp_checklist_item"
id = Column(Integer, primary_key=True, index=True)
checklist_id = Column(Integer, ForeignKey("eqp_checklist.id"), nullable=False, index=True)
item_name = Column(String(100), nullable=False)
item_standard = Column(String(200), nullable=True)
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
class InspectionRecord(Base): class EqpInspectionRecord(Base):
__tablename__ = "inspection_records" """巡检记录"""
__tablename__ = "eqp_inspection_record"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
location_id = Column(Integer, ForeignKey("inspection_locations.id"), nullable=False) checklist_id = Column(Integer, ForeignKey("eqp_checklist.id"), nullable=False, index=True)
location_name = Column(String(100)) checklist_name = Column(String(100), nullable=True)
equipment_code = Column(String(30), index=True)
equipment_name = Column(String(100))
scan_code = Column(String(200))
inspector = Column(String(50), nullable=False) inspector = Column(String(50), nullable=False)
result = Column(String(20), default="normal") inspect_time = Column(DateTime, nullable=False)
notes = Column(Text) status = Column(String(20), default="ok") # ok/issue/urgent
overall_result = Column(String(20), nullable=True) # pass/fail
remark = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
class EqpInspectionDetail(Base):
"""巡检记录明细"""
__tablename__ = "eqp_inspection_detail"
id = Column(Integer, primary_key=True, index=True)
record_id = Column(Integer, ForeignKey("eqp_inspection_record.id"), nullable=False, index=True)
checklist_item_id = Column(Integer, nullable=True)
item_name = Column(String(100), nullable=False)
actual_value = Column(String(100), nullable=True)
is_ok = Column(Boolean, default=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())

View File

@@ -2,37 +2,64 @@ from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Boolean,
from app.database import Base from app.database import Base
class QualityRecord(Base): class QcTask(Base):
"""质量检验记录""" """检验任务"""
__tablename__ = "quality_records" __tablename__ = "qc_task"
id = Column(Integer, primary_key=True, index=True)
task_code = Column(String(50), unique=True, nullable=False, index=True)
coil_no = Column(String(30), nullable=True, index=True)
task_type = Column(String(30), nullable=True) # e.g. incoming/process/final
scheme_name = Column(String(100), nullable=True)
status = Column(Integer, default=0) # 0=待检验 1=检验中 2=待审核 3=完成
inspect_user = Column(String(50), nullable=True)
inspect_time = Column(DateTime, nullable=True)
audit_user = Column(String(50), nullable=True)
audit_time = Column(DateTime, nullable=True)
result = Column(String(20), nullable=True) # qualified/unqualified
remark = Column(Text, nullable=True)
del_flag = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
id = Column(Integer, primary_key=True, index=True)
coil_no = Column(String(30), nullable=False, index=True, comment="卷号")
production_record_id = Column(Integer, ForeignKey("production_records.id"), nullable=True, comment="关联生产实绩")
# 实测规格 class QcTaskItem(Base):
thickness_actual = Column(Float, nullable=True, comment="实测厚度 mm") """检验项目"""
width_actual = Column(Float, nullable=True, comment="实测宽度 mm") __tablename__ = "qc_task_item"
flatness = Column(Float, nullable=True, comment="平直度 IU") id = Column(Integer, primary_key=True, index=True)
crown = Column(Float, nullable=True, comment="凸度 μm") task_id = Column(Integer, ForeignKey("qc_task.id"), nullable=False, index=True)
item_name = Column(String(100), nullable=False)
item_type = Column(String(20), nullable=True) # quantitative/qualitative
standard_value = Column(Float, nullable=True)
upper_limit = Column(Float, nullable=True)
lower_limit = Column(Float, nullable=True)
unit = Column(String(20), nullable=True)
inspect_value = Column(String(50), nullable=True)
is_qualified = Column(Integer, nullable=True) # 1=qualified 0=unqualified
judge_result = Column(String(100), nullable=True)
inspect_user = Column(String(50), nullable=True)
inspect_time = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
# 表面缺陷
surface_defect_type = Column(String(50), nullable=True, comment="表面缺陷类型")
defect_length_m = Column(Float, nullable=True, comment="缺陷长度 m")
defect_position = Column(String(50), nullable=True, comment="缺陷位置")
# 质量模型评分 class QcDefect(Base):
pi_score = Column(Float, nullable=True, comment="酸洗指数评分 0-100") """钢卷缺陷记录"""
surface_score = Column(Float, nullable=True, comment="表面质量评分 0-100") __tablename__ = "qc_defect"
overall_grade = Column(String(5), nullable=True, comment="综合等级 A1/A2/B1/B2/C") id = Column(Integer, primary_key=True, index=True)
coil_no = Column(String(30), nullable=True, index=True)
# 残酸 / 粗糙度 production_line = Column(String(50), nullable=True)
acid_residual = Column(Float, nullable=True, comment="残酸量 g/m²") position = Column(String(50), nullable=True)
roughness_ra = Column(Float, nullable=True, comment="粗糙度 Ra μm") plate_surface = Column(String(20), nullable=True)
defect_code = Column(String(30), nullable=True)
# 检验信息 defect_type = Column(String(50), nullable=True, index=True)
inspector = Column(String(50), nullable=True, comment="检验员") defect_rate = Column(Float, nullable=True)
inspect_time = Column(DateTime, nullable=True, comment="检验时间") defect_weight = Column(Float, nullable=True)
is_passed = Column(Boolean, default=True, comment="是否合格") degree = Column(String(20), nullable=True) # light/normal/serious
judge_level = Column(String(20), nullable=True)
created_at = Column(DateTime, server_default=func.now()) judge_by = Column(String(50), nullable=True)
judge_time = Column(DateTime, nullable=True)
main_mark = Column(Integer, nullable=True)
whole_coil_mark = Column(Integer, nullable=True)
remark = Column(Text, nullable=True)
del_flag = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())

View File

@@ -1,37 +1,88 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
from datetime import datetime from datetime import datetime
class InspectionLocationCreate(BaseModel): class EqpChecklistItemCreate(BaseModel):
code: str item_name: str
name: str item_standard: Optional[str] = None
description: Optional[str] = None
sort_order: int = 0 sort_order: int = 0
class InspectionLocationOut(InspectionLocationCreate): class EqpChecklistItemOut(EqpChecklistItemCreate):
id: int id: int
checklist_id: int
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
class InspectionRecordCreate(BaseModel): class EqpChecklistCreate(BaseModel):
location_id: int name: str
description: Optional[str] = None
equipment_code: Optional[str] = None equipment_code: Optional[str] = None
equipment_name: Optional[str] = None equipment_name: Optional[str] = None
scan_code: Optional[str] = None period: str = "daily"
inspector: str items: Optional[List[EqpChecklistItemCreate]] = None
result: str = "normal"
class EqpChecklistUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
equipment_code: Optional[str] = None
equipment_name: Optional[str] = None
period: Optional[str] = None
is_active: Optional[bool] = None
class EqpChecklistOut(BaseModel):
id: int
name: str
description: Optional[str]
equipment_code: Optional[str]
equipment_name: Optional[str]
period: str
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class EqpInspectionDetailCreate(BaseModel):
checklist_item_id: Optional[int] = None
item_name: str
actual_value: Optional[str] = None
is_ok: bool = True
notes: Optional[str] = None notes: Optional[str] = None
class InspectionRecordOut(InspectionRecordCreate): class EqpInspectionDetailOut(EqpInspectionDetailCreate):
id: int id: int
location_name: Optional[str] = None record_id: int
created_at: datetime
class Config:
from_attributes = True
class EqpInspectionRecordCreate(BaseModel):
checklist_id: int
inspector: str
inspect_time: datetime
status: str = "ok"
overall_result: Optional[str] = None
remark: Optional[str] = None
details: Optional[List[EqpInspectionDetailCreate]] = None
class EqpInspectionRecordOut(BaseModel):
id: int
checklist_id: int
checklist_name: Optional[str]
inspector: str
inspect_time: datetime
status: str
overall_result: Optional[str]
remark: Optional[str]
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -1,66 +1,136 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
from datetime import datetime from datetime import datetime
class QualityRecordCreate(BaseModel): class QcTaskCreate(BaseModel):
coil_no: str task_code: str
production_record_id: Optional[int] = None coil_no: Optional[str] = None
thickness_actual: Optional[float] = None task_type: Optional[str] = None
width_actual: Optional[float] = None scheme_name: Optional[str] = None
flatness: Optional[float] = None inspect_user: Optional[str] = None
crown: Optional[float] = None remark: Optional[str] = None
surface_defect_type: Optional[str] = None
defect_length_m: Optional[float] = None
defect_position: Optional[str] = None
pi_score: Optional[float] = None
surface_score: Optional[float] = None
overall_grade: Optional[str] = None
acid_residual: Optional[float] = None
roughness_ra: Optional[float] = None
inspector: Optional[str] = None
inspect_time: Optional[datetime] = None
is_passed: Optional[bool] = True
class QualityRecordUpdate(BaseModel): class QcTaskUpdate(BaseModel):
thickness_actual: Optional[float] = None coil_no: Optional[str] = None
width_actual: Optional[float] = None task_type: Optional[str] = None
flatness: Optional[float] = None scheme_name: Optional[str] = None
crown: Optional[float] = None status: Optional[int] = None
surface_defect_type: Optional[str] = None inspect_user: Optional[str] = None
defect_length_m: Optional[float] = None inspect_time: Optional[datetime] = None
defect_position: Optional[str] = None audit_user: Optional[str] = None
pi_score: Optional[float] = None audit_time: Optional[datetime] = None
surface_score: Optional[float] = None result: Optional[str] = None
overall_grade: Optional[str] = None remark: Optional[str] = None
acid_residual: Optional[float] = None
roughness_ra: Optional[float] = None
inspector: Optional[str] = None
inspect_time: Optional[datetime] = None
is_passed: Optional[bool] = None
class QualityRecordOut(BaseModel): class QcTaskItemOut(BaseModel):
id: int id: int
coil_no: str task_id: int
production_record_id: Optional[int] item_name: str
thickness_actual: Optional[float] item_type: Optional[str]
width_actual: Optional[float] standard_value: Optional[float]
flatness: Optional[float] upper_limit: Optional[float]
crown: Optional[float] lower_limit: Optional[float]
surface_defect_type: Optional[str] unit: Optional[str]
defect_length_m: Optional[float] inspect_value: Optional[str]
defect_position: Optional[str] is_qualified: Optional[int]
pi_score: Optional[float] judge_result: Optional[str]
surface_score: Optional[float] inspect_user: Optional[str]
overall_grade: Optional[str] inspect_time: Optional[datetime]
acid_residual: Optional[float] created_at: datetime
roughness_ra: Optional[float] class Config:
inspector: Optional[str] from_attributes = True
inspect_time: Optional[datetime]
is_passed: Optional[bool]
created_at: datetime class QcTaskItemCreate(BaseModel):
task_id: int
item_name: str
item_type: Optional[str] = None
standard_value: Optional[float] = None
upper_limit: Optional[float] = None
lower_limit: Optional[float] = None
unit: Optional[str] = None
inspect_value: Optional[str] = None
is_qualified: Optional[int] = None
judge_result: Optional[str] = None
inspect_user: Optional[str] = None
inspect_time: Optional[datetime] = None
class QcTaskItemUpdate(BaseModel):
inspect_value: Optional[str] = None
is_qualified: Optional[int] = None
judge_result: Optional[str] = None
inspect_user: Optional[str] = None
inspect_time: Optional[datetime] = None
class QcTaskOut(BaseModel):
id: int
task_code: str
coil_no: Optional[str]
task_type: Optional[str]
scheme_name: Optional[str]
status: int
inspect_user: Optional[str]
inspect_time: Optional[datetime]
audit_user: Optional[str]
audit_time: Optional[datetime]
result: Optional[str]
remark: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class QcDefectCreate(BaseModel):
coil_no: Optional[str] = None
production_line: Optional[str] = None
position: Optional[str] = None
plate_surface: Optional[str] = None
defect_code: Optional[str] = None
defect_type: Optional[str] = None
defect_rate: Optional[float] = None
defect_weight: Optional[float] = None
degree: Optional[str] = None
judge_level: Optional[str] = None
judge_by: Optional[str] = None
judge_time: Optional[datetime] = None
main_mark: Optional[int] = None
whole_coil_mark: Optional[int] = None
remark: Optional[str] = None
class QcDefectUpdate(BaseModel):
defect_type: Optional[str] = None
defect_rate: Optional[float] = None
defect_weight: Optional[float] = None
degree: Optional[str] = None
judge_level: Optional[str] = None
judge_by: Optional[str] = None
judge_time: Optional[datetime] = None
remark: Optional[str] = None
class QcDefectOut(BaseModel):
id: int
coil_no: Optional[str]
production_line: Optional[str]
position: Optional[str]
plate_surface: Optional[str]
defect_code: Optional[str]
defect_type: Optional[str]
defect_rate: Optional[float]
defect_weight: Optional[float]
degree: Optional[str]
judge_level: Optional[str]
judge_by: Optional[str]
judge_time: Optional[datetime]
main_mark: Optional[int]
whole_coil_mark: Optional[int]
remark: Optional[str]
created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True

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 resetCalibration = model => request.post(`/prediction/calibration/reset/${model}`)
// 设备巡检 // 设备巡检
export const getInspectionLocations = () => request.get('/inspection/locations') export const getChecklists = params => request.get('/inspection/checklists', { params })
export const createInspectionLocation = data => request.post('/inspection/locations', data) 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 getInspectionRecords = params => request.get('/inspection/records', { params })
export const createInspectionRecord = data => request.post('/inspection/records', data) 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 getQcTasks = params => request.get('/quality/tasks', { params })
export const createQuality = data => request.post('/quality/', data) export const createQcTask = data => request.post('/quality/tasks', data)
export const updateQuality = (id, data) => request.put(`/quality/${id}`, data) export const updateQcTask = (id, data) => request.put(`/quality/tasks/${id}`, data)
export const getQualitySummary = () => request.get('/quality/summary') 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> <template>
<div class="insp-layout"> <div class="insp-layout">
<!-- 左侧巡检模板列表 -->
<div class="insp-sidebar"> <div class="insp-sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
巡检点位 巡检模板
<span class="add-btn" @click="openLocDialog()"></span> <span class="add-btn" @click="openChecklistDialog()"></span>
</div> </div>
<div class="loc-list"> <div class="cl-list">
<div <div
v-for="loc in locations" v-for="cl in checklists"
:key="loc.id" :key="cl.id"
:class="['loc-item', { active: selectedLoc && selectedLoc.id === loc.id }]" :class="['cl-item', { active: selectedCl && selectedCl.id === cl.id }]"
@click="selectLocation(loc)" @click="selectChecklist(cl)"
> >
<div class="loc-code">{{ loc.code }}</div> <div class="cl-name">{{ cl.name }}</div>
<div class="loc-name">{{ loc.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>
<div v-if="!locations.length" class="loc-empty">暂无点位</div> <div v-if="!checklists.length" class="cl-empty">暂无模板</div>
</div> </div>
</div> </div>
<!-- 右侧主区域 -->
<div class="insp-main"> <div class="insp-main">
<div v-if="!selectedLoc" class="empty-tip">请选择点位</div> <div v-if="!selectedCl" class="empty-tip">请选择巡检模板</div>
<template v-else> <template v-else>
<!-- 模板详情卡 -->
<div class="card"> <div class="card">
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;"> <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> <span>{{ selectedCl.name }}</span>
<button class="btn btn-primary" @click="openRecordDialog(null)">录入巡检</button> <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> </div>
<!-- 模板项目 -->
<div class="card"> <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"> <div class="table-scroll">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>设备编号</th><th>设备名称</th><th>类别</th><th>状态</th><th>操作</th> <th style="width:50px;">序号</th>
<th>项目名称</th>
<th>检验标准</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="eq in locationEquipments" :key="eq.id"> <tr v-for="item in clItems" :key="item.id">
<td class="td-num">{{ eq.code }}</td> <td class="td-num">{{ item.sort_order }}</td>
<td>{{ eq.name }}</td> <td>{{ item.item_name }}</td>
<td>{{ eq.category || '—' }}</td> <td class="td-muted">{{ item.item_standard || '—' }}</td>
<td><span :class="['badge', eqStatusBadge(eq.status)]">{{ eqStatusLabel(eq.status) }}</span></td>
<td><span class="action-link" @click="openRecordDialog(eq)">录入</span></td>
</tr> </tr>
<tr v-if="!locationEquipments.length"> <tr v-if="!clItems.length">
<td colspan="5" class="td-muted" style="text-align:center;padding:16px;">该点位暂无关联设备</td> <td colspan="3" class="td-muted" style="text-align:center;padding:16px;">暂无巡检项目</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- 巡检记录 -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
巡检历史 巡检记录
<span class="ch-badge">{{ records.length }} </span> <span class="ch-badge"> {{ recordTotal }} </span>
</div> </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"> <table class="data-table">
<thead> <thead>
<tr> <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> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="r in records" :key="r.id"> <tr v-for="r in records" :key="r.id">
<td class="td-muted">{{ fmtTime(r.created_at) }}</td> <td class="td-muted">{{ fmtTime(r.inspect_time) }}</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>{{ r.inspector }}</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>
<tr v-if="!records.length && !loadingRecords"> <tr v-if="!records.length">
<td colspan="6" class="td-muted" style="text-align:center;padding:16px;">暂无巡检记录</td> <td colspan="6" class="td-muted" style="text-align:center;padding:16px;">暂无巡检记录</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </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> </div>
</template> </template>
</div> </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"> <div class="modal-header">
录入巡检 新增巡检模板
<span class="modal-close" @click="recordDialogVisible=false"></span> <span class="modal-close" @click="clDialogVisible = false"></span>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div style="display:flex;flex-direction:column;gap:12px;"> <div style="display:flex;flex-direction:column;gap:12px;">
<div class="form-field"> <div class="form-field">
<div class="kv-label">点位</div> <div class="kv-label">模板名称 *</div>
<input class="kv-input" :value="selectedLoc.name" disabled /> <input v-model="clForm.name" class="kv-input" />
</div> </div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">设备编号</div> <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>
<div class="form-field"> <div class="form-field">
<div class="kv-label">设备名称</div> <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>
<div class="form-field"> <div class="form-field">
<div class="kv-label">二维码</div> <div class="kv-label">巡检周期</div>
<input v-model="recordForm.scan_code" class="kv-input" ref="scanInput" /> <select v-model="clForm.period" class="kv-input">
</div> <option value="daily">每日</option>
<div class="form-field"> <option value="weekly">每周</option>
<div class="kv-label">巡检结果 *</div> <option value="monthly">每月</option>
<select v-model="recordForm.result" class="kv-input">
<option value="normal">正常</option>
<option value="abnormal">异常</option>
<option value="pending">待处理</option>
</select> </select>
</div> </div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">巡检人 *</div> <div class="kv-label">描述</div>
<input v-model="recordForm.inspector" class="kv-input" /> <textarea v-model="clForm.description" class="kv-input" rows="2"></textarea>
</div>
<div class="form-field">
<div class="kv-label">备注</div>
<textarea v-model="recordForm.notes" class="kv-input" rows="3"></textarea>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-outline" @click="recordDialogVisible=false">取消</button> <button class="btn btn-outline" @click="clDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveRecord">{{ saving ? '保存中...' : '保存' }}</button> <button class="btn btn-primary" :disabled="saving" @click="saveChecklist">{{ saving ? '保存中...' : '保存' }}</button>
</div> </div>
</div> </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"> <div class="modal-header">
新增点位 编辑模板
<span class="modal-close" @click="locDialogVisible=false"></span> <span class="modal-close" @click="editClDialogVisible = false"></span>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div style="display:flex;flex-direction:column;gap:12px;"> <div style="display:flex;flex-direction:column;gap:12px;">
<div class="form-field"> <div class="form-field">
<div class="kv-label">点位编号 *</div> <div class="kv-label">模板名称 *</div>
<input v-model="locForm.code" class="kv-input" /> <input v-model="editClForm.name" class="kv-input" />
</div> </div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">点位名称 *</div> <div class="kv-label">设备编号</div>
<input v-model="locForm.name" class="kv-input" /> <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>
<div class="form-field"> <div class="form-field">
<div class="kv-label">描述</div> <div class="kv-label">描述</div>
<input v-model="locForm.description" class="kv-input" /> <textarea v-model="editClForm.description" class="kv-input" rows="2"></textarea>
</div>
<div class="form-field">
<div class="kv-label">排序</div>
<input v-model.number="locForm.sort_order" type="number" class="kv-input" />
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-outline" @click="locDialogVisible=false">取消</button> <button class="btn btn-outline" @click="editClDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="saveLocation">{{ saving ? '保存中...' : '保存' }}</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> </div>
</div> </div>
@@ -171,118 +377,204 @@
<script> <script>
import { import {
getInspectionLocations, createInspectionLocation, getChecklists, createChecklist, updateChecklist,
getInspectionRecords, createInspectionRecord, getChecklistItems, createChecklistItem,
getEquipments, getInspectionRecords, createInspectionRecord, getInspectionRecordDetails,
} from '@/api' } from '@/api'
const EQ_STATUS = { const PERIOD_MAP = {
normal: { label: '正常', badge: 'badge-green' }, daily: { label: '每日', badge: 'badge-blue' },
fault: { label: '故障', badge: 'badge-red' }, weekly: { label: '每周', badge: 'badge-yellow' },
maintenance: { label: '检修', badge: 'badge-yellow' }, monthly: { label: '每月', badge: 'badge' },
standby: { label: '备用', badge: 'badge-gray' },
} }
const RESULT_MAP = { const RECORD_STATUS = {
normal: { label: '正常', badge: 'badge-green' }, ok: { label: '正常', badge: 'badge-green' },
abnormal: { label: '异常', badge: 'badge-red' }, issue: { label: '有问题', badge: 'badge-yellow' },
pending: { label: '待处理', badge: 'badge-yellow' }, urgent: { label: '紧急', badge: 'badge-red' },
} }
export default { export default {
name: 'Inspection', name: 'Inspection',
data() { data() {
return { return {
locations: [], checklists: [],
selectedLoc: null, selectedCl: null,
allEquipments: [], clItems: [],
records: [], records: [], recordTotal: 0,
loadingRecords: false, recordQuery: { page: 1, page_size: 20, status: '', start_date: '', end_date: '' },
saving: false, saving: false,
recordDialogVisible: false,
locDialogVisible: false, // 新增模板
recordForm: { result: 'normal', inspector: '', equipment_code: '', equipment_name: '', scan_code: '', notes: '' }, clDialogVisible: false,
locForm: { code: '', name: '', description: '', sort_order: 0 }, 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: { computed: {
locationEquipments() { cl_is_active_badge() {
if (!this.selectedLoc) return [] return this.selectedCl?.is_active ? 'badge-green' : 'badge-gray'
return this.allEquipments.filter(e => e.location === this.selectedLoc.name)
}, },
}, },
created() { created() {
this.fetchLocations() this.fetchChecklists()
this.fetchAllEquipments()
}, },
methods: { methods: {
async fetchLocations() { async fetchChecklists() {
const res = await getInspectionLocations() try {
this.locations = res.data || [] const res = await getChecklists()
this.checklists = res.data || []
} catch (e) { /* ignore */ }
}, },
async fetchAllEquipments() { async selectChecklist(cl) {
const res = await getEquipments({ page: 1, page_size: 200 }) this.selectedCl = cl
this.allEquipments = res.data?.items || [] await Promise.all([this.fetchClItems(), this.fetchRecords()])
}, },
async selectLocation(loc) { async fetchClItems() {
this.selectedLoc = loc if (!this.selectedCl) return
await this.fetchRecords() try {
const res = await getChecklistItems(this.selectedCl.id)
this.clItems = res.data || []
} catch (e) { this.clItems = [] }
}, },
async fetchRecords() { async fetchRecords() {
if (!this.selectedLoc) return if (!this.selectedCl) return
this.loadingRecords = true 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 { try {
const res = await getInspectionRecords({ location_id: this.selectedLoc.id, page_size: 50 }) const res = await getInspectionRecords(params)
this.records = res.data?.items || [] this.records = res.data.items || []
} finally { this.recordTotal = res.data.total || 0
this.loadingRecords = false } catch (e) { this.records = []; this.recordTotal = 0 }
}
}, },
openRecordDialog(eq) {
this.recordForm = { // ── 模板 ──────────────────────────────────────
result: 'normal', openChecklistDialog() {
inspector: '', this.clForm = { name: '', equipment_code: '', equipment_name: '', period: 'daily', description: '' }
equipment_code: eq ? eq.code : '', this.clDialogVisible = true
equipment_name: eq ? eq.name : '', },
scan_code: '', 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: '', notes: '',
} }))
this.recordDialogVisible = true this.inspectDialogVisible = true
this.$nextTick(() => { if (this.$refs.scanInput) this.$refs.scanInput.focus() })
}, },
async saveRecord() { async saveInspect() {
if (!this.recordForm.inspector) { this.$message.error('巡检人不能为空'); return } if (!this.inspectForm.inspector) { this.$message.error('巡检人不能为空'); return }
if (!this.inspectForm.inspect_time) { this.$message.error('巡检时间不能为空'); return }
this.saving = true this.saving = true
try { try {
await createInspectionRecord({ ...this.recordForm, location_id: this.selectedLoc.id }) const details = this.inspectDetails.map(d => ({
this.$message.success('保存成功') checklist_item_id: d.checklist_item_id,
this.recordDialogVisible = false 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() await this.fetchRecords()
} finally { } finally { this.saving = false }
this.saving = false
}
}, },
openLocDialog() {
this.locForm = { code: '', name: '', description: '', sort_order: 0 } // ── 查看明细 ──────────────────────────────────
this.locDialogVisible = true async viewDetails(record) {
},
async saveLocation() {
if (!this.locForm.code || !this.locForm.name) { this.$message.error('编号和名称不能为空'); return }
this.saving = true
try { try {
await createInspectionLocation(this.locForm) const res = await getInspectionRecordDetails(record.id)
this.$message.success('保存成功') this.recordDetails = res.data || []
this.locDialogVisible = false this.detailDialogVisible = true
await this.fetchLocations() } catch (e) { this.$message.error('获取明细失败') }
} finally {
this.saving = false
}
}, },
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', ' ') : '—' }, 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> </script>
@@ -298,7 +590,7 @@ export default {
} }
.insp-sidebar { .insp-sidebar {
width: 220px; width: 230px;
flex-shrink: 0; flex-shrink: 0;
background: $bg-card; background: $bg-card;
border: 1px solid $border; border: 1px solid $border;
@@ -328,14 +620,14 @@ export default {
&:hover { opacity: .7; } &:hover { opacity: .7; }
} }
.loc-list { .cl-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: $border transparent; scrollbar-color: $border transparent;
} }
.loc-item { .cl-item {
padding: 10px 12px; padding: 10px 12px;
cursor: pointer; cursor: pointer;
border-bottom: 1px solid rgba($border, .5); 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; } &.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; } .cl-name { font-size: 12px; color: $text-primary; margin-bottom: 4px; }
.loc-name { font-size: 12px; color: $text-primary; margin-top: 2px; } .cl-meta { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
.loc-empty { padding: 20px; text-align: center; font-size: 12px; color: $text-muted; } .cl-empty { padding: 20px; text-align: center; font-size: 12px; color: $text-muted; }
.insp-main { .insp-main {
flex: 1; flex: 1;
@@ -366,10 +658,40 @@ export default {
font-size: 13px; 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; } .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-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-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-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; } .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> <template>
<div> <div>
<!-- 过滤栏 --> <!-- 标签页 -->
<div class="card"> <div class="tab-bar">
<div class="card-body" style="padding:10px 14px;"> <span :class="['tab-item', { active: activeTab === 'tasks' }]" @click="activeTab = 'tasks'">检验任务</span>
<div class="flex-row" style="flex-wrap:wrap;gap:12px;"> <span :class="['tab-item', { active: activeTab === 'defects' }]" @click="activeTab = 'defects'; fetchDefects()">缺陷记录</span>
<div class="flex-row"> </div>
<span class="kv-label">卷号</span>
<input v-model="query.coil_no" class="kv-input" style="width:140px;" @keyup.enter="fetchData" /> <!-- 检验任务 Tab -->
</div> <template v-if="activeTab === 'tasks'">
<div class="flex-row"> <!-- 过滤栏 -->
<span class="kv-label">质量等级</span> <div class="card">
<select v-model="query.overall_grade" class="kv-input" style="width:100px;"> <div class="card-body" style="padding:10px 14px;">
<option value="">全部</option> <div class="flex-row" style="flex-wrap:wrap;gap:12px;">
<option v-for="g in ['A1','A2','B1','B2','C']" :key="g" :value="g">{{ g }}</option> <div class="flex-row">
</select> <span class="kv-label">任务编号</span>
</div> <input v-model="taskQuery.task_code" class="kv-input" style="width:130px;" @keyup.enter="fetchTasks" />
<div class="flex-row"> </div>
<span class="kv-label">日期</span> <div class="flex-row">
<input v-model="query.start_date" type="date" class="kv-input" style="width:140px;" /> <span class="kv-label">卷号</span>
<span class="kv-label">~</span> <input v-model="taskQuery.coil_no" class="kv-input" style="width:120px;" @keyup.enter="fetchTasks" />
<input v-model="query.end_date" type="date" class="kv-input" style="width:140px;" /> </div>
</div> <div class="flex-row">
<div class="flex-row"> <span class="kv-label">状态</span>
<button class="btn btn-primary" @click="fetchData">查询</button> <select v-model="taskQuery.status" class="kv-input" style="width:100px;">
<button class="btn btn-outline" @click="openDialog()"> 新增检验</button> <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> </div>
</div>
<!-- 统计卡片 --> <!-- 主体区域任务列表 + 检验项面板 -->
<div class="grid-4"> <div class="section-row">
<div class="metric-box"> <!-- 任务列表 -->
<div class="mb-label">合格率</div> <div class="card" style="flex:3;min-width:0;">
<div class="mb-value" :style="{ color: summary.pass_rate >= 95 ? '#28a745' : '#f0a500' }"> <div class="card-header">
{{ summary.pass_rate }} 检验任务列表
<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>
<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;min-width:0;">
<!-- 质量记录表 --> <div class="card-header">
<div class="card" style="flex:2;"> <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"> <div class="card-header">
质量检验记录 缺陷记录列表
<span class="ch-badge"> {{ total }} </span> <span class="ch-badge"> {{ defectTotal }} </span>
</div> </div>
<div class="table-scroll" v-loading="loading"> <div class="table-scroll">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>卷号</th> <th>卷号</th>
<th>实测厚度</th> <th>产线</th>
<th>实测宽度</th> <th>位置</th>
<th>PI评分</th> <th>板面</th>
<th>表面评分</th> <th>缺陷代码</th>
<th>质量等级</th> <th>缺陷类型</th>
<th>残酸(g/)</th> <th>缺陷率(%)</th>
<th>粗糙度Ra</th> <th>缺陷重量</th>
<th>是否合格</th> <th>严重程度</th>
<th>检验员</th> <th>判定等级</th>
<th>检验时间</th> <th>判定人</th>
<th>判定时间</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in tableData" :key="row.id"> <tr v-for="row in defectData" :key="row.id">
<td class="td-num">{{ row.coil_no }}</td> <td class="td-num">{{ row.coil_no || '—' }}</td>
<td class="td-num">{{ row.thickness_actual || '—' }}</td> <td class="td-muted">{{ row.production_line || '—' }}</td>
<td class="td-num">{{ row.width_actual || '—' }}</td> <td class="td-muted">{{ row.position || '—' }}</td>
<td class="td-num">{{ row.pi_score != null ? row.pi_score.toFixed(1) : '—' }}</td> <td class="td-muted">{{ row.plate_surface || '—' }}</td>
<td class="td-num">{{ row.surface_score != null ? row.surface_score.toFixed(1) : '—' }}</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> <td>
<span v-if="row.overall_grade" :class="['badge', gradeBadge(row.overall_grade)]"> <span v-if="row.degree" :class="['badge', degreeBadge(row.degree)]">{{ degreeLabel(row.degree) }}</span>
{{ row.overall_grade }}
</span>
<span v-else class="td-muted"></span> <span v-else class="td-muted"></span>
</td> </td>
<td class="td-num">{{ row.acid_residual || '—' }}</td> <td class="td-muted">{{ row.judge_level || '—' }}</td>
<td class="td-num">{{ row.roughness_ra || '—' }}</td> <td class="td-muted">{{ row.judge_by || '—' }}</td>
<td class="td-muted">{{ fmtTime(row.judge_time) }}</td>
<td> <td>
<span :class="['badge', row.is_passed ? 'badge-green' : 'badge-red']"> <span class="action-link" @click="openDefectDialog(row)">编辑</span>
{{ row.is_passed ? '合格' : '不合格' }} <span class="action-link" style="color:#da3633;" @click="deleteDefect(row)">删除</span>
</span>
</td> </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>
<tr v-if="!tableData.length && !loading"> <tr v-if="!defectData.length">
<td colspan="12" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td> <td colspan="13" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </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"> <div class="flex-row">
<button class="btn btn-outline" :disabled="query.page<=1" @click="query.page--;fetchData()">上一页</button> <button class="btn btn-outline" :disabled="defectQuery.page <= 1" @click="defectQuery.page--; fetchDefects()">上一页</button>
<span class="kv-label"> {{ query.page }} / {{ Math.ceil(total/query.page_size) }} </span> <span class="kv-label"> {{ defectQuery.page }} / {{ Math.ceil(defectTotal / defectQuery.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 >= Math.ceil(defectTotal / defectQuery.page_size)" @click="defectQuery.page++; fetchDefects()">下一页</button>
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- 右侧面板 --> <!-- 新增/编辑任务弹窗 -->
<div style="flex:1;display:flex;flex-direction:column;gap:14px;min-width:0;"> <div v-if="taskDialogVisible" class="modal-mask" @click.self="taskDialogVisible = false">
<!-- 快速预测面板 --> <div class="modal-box" style="width:560px;">
<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 class="modal-header"> <div class="modal-header">
{{ editRow ? '编辑质量记录 #' + editRow.id : '新增质量记录' }} {{ editTask ? '编辑任务 #' + editTask.id : '新增检验任务' }}
<span class="modal-close" @click="dialogVisible=false"></span> <span class="modal-close" @click="taskDialogVisible = false"></span>
</div> </div>
<div class="modal-body"> <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="form-field">
<div class="kv-label"> *</div> <div class="kv-label">任务编 *</div>
<input v-model="form.coil_no" class="kv-input" /> <input v-model="taskForm.task_code" class="kv-input" :disabled="!!editTask" />
</div> </div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">实测厚度 (mm)</div> <div class="kv-label">卷号</div>
<input v-model.number="form.thickness_actual" type="number" class="kv-input" step="0.01" /> <input v-model="taskForm.coil_no" class="kv-input" />
</div> </div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">实测宽度 (mm)</div> <div class="kv-label">任务类型</div>
<input v-model.number="form.width_actual" type="number" class="kv-input" /> <select v-model="taskForm.task_type" class="kv-input">
</div> <option value="">请选择</option>
<div class="form-field"> <option value="incoming">来料检验</option>
<div class="kv-label">平直度 (IU)</div> <option value="process">过程检验</option>
<input v-model.number="form.flatness" type="number" class="kv-input" step="0.1" /> <option value="final">成品检验</option>
</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>
</select> </select>
</div> </div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">缺陷长度 (m)</div> <div class="kv-label">方案名称</div>
<input v-model.number="form.defect_length_m" type="number" class="kv-input" step="0.1" /> <input v-model="taskForm.scheme_name" class="kv-input" />
</div> </div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">缺陷位置</div> <div class="kv-label">检验人员</div>
<input v-model="form.defect_position" class="kv-input" /> <input v-model="taskForm.inspect_user" class="kv-input" />
</div> </div>
<div class="form-field"> <div v-if="editTask" class="form-field">
<div class="kv-label">PI评分</div> <div class="kv-label">状态</div>
<input v-model.number="form.pi_score" type="number" class="kv-input" step="0.1" min="0" max="100" /> <select v-model="taskForm.status" class="kv-input">
</div> <option :value="0">待检验</option>
<div class="form-field"> <option :value="1">检验中</option>
<div class="kv-label">表面评分</div> <option :value="2">待审核</option>
<input v-model.number="form.surface_score" type="number" class="kv-input" step="0.1" min="0" max="100" /> <option :value="3">完成</option>
</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>
</select> </select>
</div> </div>
<div class="form-field"> <div v-if="editTask" class="form-field">
<div class="kv-label">残酸量 (g/)</div> <div class="kv-label">检验结果</div>
<input v-model.number="form.acid_residual" type="number" class="kv-input" step="0.01" /> <select v-model="taskForm.result" class="kv-input">
</div> <option value="">待定</option>
<div class="form-field"> <option value="qualified">合格</option>
<div class="kv-label">粗糙度 Ra (μm)</div> <option value="unqualified">不合格</option>
<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>
</select> </select>
</div> </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> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-outline" @click="dialogVisible=false">取消</button> <button class="btn btn-outline" @click="taskDialogVisible = false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="save"> <button class="btn btn-primary" :disabled="saving" @click="saveTask">{{ saving ? '保存中...' : '保存' }}</button>
{{ saving ? '保存中...' : '保存' }} </div>
</button> </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> </div>
</div> </div>
@@ -280,127 +486,202 @@
</template> </template>
<script> <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 { export default {
name: 'Quality', name: 'Quality',
data() { data() {
return { return {
loading: false, saving: false, predLoading: false, activeTab: 'tasks',
tableData: [], total: 0, saving: false,
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: {}, taskData: [], taskTotal: 0,
pred: { thickness: 3.0, avg_speed: 100, acid_conc_avg: 160, acid_temp_avg: 75, scale_weight: 8.5 }, taskQuery: { page: 1, page_size: 20, task_code: '', coil_no: '', status: '', result: '', start_date: '', end_date: '' },
predResult: null, 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() { created() {
this.fetchData() this.fetchTasks()
this.fetchSummary()
}, },
methods: { methods: {
async fetchData() { // ── 任务 ──────────────────────────────────────
this.loading = true async fetchTasks() {
const params = { ...this.query } const params = {}
if (!params.coil_no) delete params.coil_no if (this.taskQuery.task_code) params.task_code = this.taskQuery.task_code
if (!params.overall_grade) delete params.overall_grade if (this.taskQuery.coil_no) params.coil_no = this.taskQuery.coil_no
if (params.start_date) params.start_date = params.start_date + 'T00:00:00' if (this.taskQuery.status !== '') params.status = this.taskQuery.status
else delete params.start_date if (this.taskQuery.result) params.result = this.taskQuery.result
if (params.end_date) params.end_date = params.end_date + 'T23:59:59' if (this.taskQuery.start_date) params.start_date = this.taskQuery.start_date
else delete params.end_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 { try {
const res = await getQualityList(params) const res = await getQcTasks(params)
this.tableData = res.data.items this.taskData = res.data.items
this.total = res.data.total this.taskTotal = res.data.total
} finally { this.loading = false }
},
async fetchSummary() {
try {
const res = await getQualitySummary()
this.summary = res.data
this.$nextTick(() => this.drawChart())
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}, },
drawChart() { async selectTask(row) {
const canvas = this.$refs.gradeChart this.selectedTask = row
if (!canvas) return try {
const ctx = canvas.getContext('2d') const res = await getQcTaskItems(row.id)
const grades = ['A1','A2','B1','B2','C'] this.taskItems = res.data || []
const dist = this.summary.grade_distribution || {} } catch (e) { this.taskItems = [] }
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)
})
}, },
fmtTime(t) { return t ? t.replace('T', ' ').slice(0, 16) : '—' }, openTaskDialog(row = null) {
gradeBadge(g) { this.editTask = row
if (g === 'A1') return 'badge-green' this.taskForm = row ? { ...row } : { task_code: '', status: 0 }
if (g === 'A2') return 'badge-blue' this.taskDialogVisible = true
if (g === 'B1') return 'badge-blue'
if (g === 'B2') return 'badge-yellow'
return 'badge-red'
}, },
openDialog(row = null) { async saveTask() {
this.editRow = row if (!this.taskForm.task_code) { this.$message.error('任务编号不能为空'); return }
this.form = row ? { ...row } : { is_passed: true }
this.dialogVisible = true
},
async save() {
if (!this.form.coil_no) { this.$message.error('卷号不能为空'); return }
this.saving = true this.saving = true
try { try {
if (this.editRow) await createQuality({ ...this.form }) // use PUT when available if (this.editTask) {
else await createQuality(this.form) await updateQcTask(this.editTask.id, this.taskForm)
} else {
await createQcTask(this.taskForm)
}
this.$message.success('保存成功') this.$message.success('保存成功')
this.dialogVisible = false this.taskDialogVisible = false
this.fetchData() this.fetchTasks()
this.fetchSummary()
} finally { this.saving = false } } finally { this.saving = false }
}, },
async doPred() { async deleteTask(row) {
this.predLoading = true if (!confirm(`确认删除任务 ${row.task_code}`)) return
try { try {
const res = await predictQuality(this.pred) await deleteQcTask(row.id)
this.predResult = res.data this.$message.success('已删除')
} catch (e) { if (this.selectedTask && this.selectedTask.id === row.id) {
this.$message.error('预测失败:' + (e.response?.data?.detail || e.message)) this.selectedTask = null
} finally { this.predLoading = false } 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/assets/styles/variables'; @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; } .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; } .action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 10px; &:hover { text-decoration: underline; } }
.mt8 { margin-top: 8px; }
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; } .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-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-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; } .modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }