11 Коммиты b3e320f283 ... cdc8a09029

Автор SHA1 Сообщение Дата
  yangjingjing cdc8a09029 feat(大屏 3.3): 新增大屏主页面 + API + 路由注册 (暗色主题/6卡片/5图表/预警走马灯) 1 неделя назад
  yangjingjing 75356f9214 feat(risk): 监督抽检/平台客诉前端页面(3.1.4) 1 неделя назад
  yangjingjing 627e8e70b8 feat(食品安全宣传): 前端 types/API/主页(4看板+列表+3弹窗) 1 неделя назад
  yangjingjing b44d7fe6a1 feat(risk-accident): 事故上报新增处理弹窗(归档+附件上传) 1 неделя назад
  yangjingjing 10a66c2da2 style(ui): 事故上报&巡检任务新增按钮统一调整到右侧 1 неделя назад
  yangjingjing af0cf1a993 feat(special-equipment): 新增特种设备铭牌照片上传(必填) 1 неделя назад
  yangjingjing 06159693fa feat(risk-warning): 发布弹窗加附件上传 + 详情支持下载(MinIO) 1 неделя назад
  yangjingjing 7b428f4c7b feat(risk-workReport): 作业上报新增工期字段及联动计算 1 неделя назад
  yangjingjing 7422fb44b8 feat(risk-hiddenDanger): 列表与查看详情展示提报人/提报部门 1 неделя назад
  yangjingjing c18176dac8 feat(risk-factoryVisit): 品控访厂(3.1.2)PC 端前端完整实现 1 неделя назад
  klxj001 0ac6393905 feat(risk-quality): 品控巡检(3.1.1)PC 端前端完整实现 1 неделя назад

+ 9 - 0
src/api/risk/accident.ts

@@ -37,6 +37,15 @@ export const updateAccident = (data: RiskAccidentBo): AxiosPromise => {
   });
 };
 
+// 处理事故上报(状态强制变为'归档',不依赖前端传入)
+export const handleAccident = (id: string | number, data: RiskAccidentBo): AxiosPromise => {
+  return request({
+    url: '/risk/accident/handle/' + id,
+    method: 'put',
+    data: data
+  });
+};
+
 // 删除事故上报
 export const delAccident = (ids: string | number | Array<string | number>): AxiosPromise => {
   return request({

+ 96 - 0
src/api/risk/bigScreen.ts

@@ -0,0 +1,96 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+
+/**
+ * 3.3 大屏 - API
+ *
+ * 6 张业务卡片 + 2 饼图 + 1 折线图 + 1 柱状图 + 1 走马灯
+ */
+
+// ============ 类型定义 ============
+
+/** 6 张业务数据卡片 */
+export interface BigScreenCardVo {
+  // 1. 安全隐患
+  dangerTotal: number;
+  dangerHandled: number;
+  dangerPending: number;
+  // 2. 危险源
+  dangerSourceTotal: number;
+  dangerSourceMonthNew: number;
+  // 3. 作业上报
+  workReportTotal: number;
+  workReportOngoing: number;
+  // 4. 审计工作
+  personnelChangeTotal: number;
+  personnelLeave: number;
+  personnelTransfer: number;
+  // 5. 特种设备
+  specialEquipmentTotal: number;
+  specialEquipmentRunning: number;
+  specialEquipmentMaintaining: number;
+  // 6. 访厂审核
+  factoryVisitTotal: number;
+  factoryVisitPassed: number;
+  factoryVisitRejected: number;
+}
+
+/** 饼图名值对 */
+export interface NameValue {
+  name: string;
+  value: number;
+}
+
+/** 5 个图表数据 */
+export interface BigScreenChartVo {
+  // 饼图 1: 人员岗位占比
+  personnelDistribution: NameValue[];
+  // 饼图 2: 事故类型分布
+  accidentDistribution: NameValue[];
+  // 折线图: 巡检/抽检近 30 天
+  trendDates: string[];
+  qualityInspectionCount: number[];
+  riskInspectionCount: number[];
+  // 柱状图: 客诉月度
+  complaintMonths: string[];
+  complaintCounts: number[];
+}
+
+/** 预警走马灯单项 */
+export interface BigScreenWarningStreamVo {
+  id: number;
+  warningCode: string;
+  title: string;
+  warningType: string;
+  warningLevel: string;
+  levelColor: string; // red | yellow | blue
+  releaseTime: string;
+  storeName: string;
+}
+
+// ============ API ============
+
+/** ① 6 张业务数据卡片统计 */
+export const getBigScreenCards = (): AxiosPromise<BigScreenCardVo> => {
+  return request({
+    url: '/risk/bigScreen/cards',
+    method: 'get'
+  });
+};
+
+/** ② ③ ④ 图表数据 */
+export const getBigScreenCharts = (): AxiosPromise<BigScreenChartVo> => {
+  return request({
+    url: '/risk/bigScreen/charts',
+    method: 'get'
+  });
+};
+
+/** ⑤ 最新预警动态走马灯 */
+export const getBigScreenWarnings = (limit = 10): AxiosPromise<BigScreenWarningStreamVo[]> => {
+  return request({
+    url: '/risk/bigScreen/warnings',
+    method: 'get',
+    params: { limit }
+  });
+};

+ 73 - 0
src/api/risk/factoryVisit.ts

@@ -0,0 +1,73 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import { RiskFactoryVisitBo, RiskFactoryVisitVo } from '@/api/risk/types';
+
+// 查询访厂任务列表
+export const listFactoryVisit = (query: RiskFactoryVisitBo): AxiosPromise<RiskFactoryVisitVo[]> => {
+  return request({
+    url: '/risk/factoryVisit/list',
+    method: 'get',
+    params: query
+  });
+};
+
+// 查询访厂任务详情(含报告子表)
+export const getFactoryVisit = (id: string | number): AxiosPromise<RiskFactoryVisitVo> => {
+  return request({
+    url: '/risk/factoryVisit/' + id,
+    method: 'get'
+  });
+};
+
+// 新增访厂任务
+export const addFactoryVisit = (data: RiskFactoryVisitBo): AxiosPromise => {
+  return request({
+    url: '/risk/factoryVisit',
+    method: 'post',
+    data: data
+  });
+};
+
+// 修改访厂任务
+export const updateFactoryVisit = (data: RiskFactoryVisitBo): AxiosPromise => {
+  return request({
+    url: '/risk/factoryVisit',
+    method: 'put',
+    data: data
+  });
+};
+
+// 删除访厂任务
+export const delFactoryVisit = (ids: string | number | Array<string | number>): AxiosPromise => {
+  return request({
+    url: '/risk/factoryVisit/' + ids,
+    method: 'delete'
+  });
+};
+
+// 导出访厂任务
+export const exportFactoryVisit = (query: RiskFactoryVisitBo): AxiosPromise => {
+  return request({
+    url: '/risk/factoryVisit/export',
+    method: 'post',
+    params: query
+  });
+};
+
+// 提交访厂报告(7 个模块评分)
+export const submitFactoryVisitReport = (data: any): AxiosPromise<{ reportId: number }> => {
+  return request({
+    url: '/risk/factoryVisit/submitReport',
+    method: 'post',
+    data: data
+  });
+};
+
+// 审核访厂任务
+export const auditFactoryVisit = (data: RiskFactoryVisitBo): AxiosPromise => {
+  return request({
+    url: '/risk/factoryVisit/audit',
+    method: 'put',
+    data: data
+  });
+};

+ 97 - 0
src/api/risk/foodSafetyCampaign.ts

@@ -0,0 +1,97 @@
+import { AxiosPromise } from 'axios';
+import request from '@/utils/request';
+import {
+  RiskFoodSafetyCampaignBo,
+  RiskFoodSafetyCampaignVo,
+  FoodSafetyCampaignStatsVo
+} from '@/api/risk/types';
+
+/**
+ * 食品安全宣传 - 活动管理 API
+ */
+
+// 查询宣传活动列表
+export const listFoodSafetyCampaign = (query: RiskFoodSafetyCampaignBo): AxiosPromise<RiskFoodSafetyCampaignVo[]> => {
+  return request({
+    url: '/risk/foodSafety/campaign/list',
+    method: 'get',
+    params: query
+  });
+};
+
+// 查询活动详情
+export const getFoodSafetyCampaign = (id: string | number): AxiosPromise<RiskFoodSafetyCampaignVo> => {
+  return request({
+    url: '/risk/foodSafety/campaign/' + id,
+    method: 'get'
+  });
+};
+
+// 新增活动
+export const addFoodSafetyCampaign = (data: RiskFoodSafetyCampaignBo): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/campaign',
+    method: 'post',
+    data: data
+  });
+};
+
+// 修改活动
+export const updateFoodSafetyCampaign = (data: RiskFoodSafetyCampaignBo): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/campaign',
+    method: 'put',
+    data: data
+  });
+};
+
+// 删除活动
+export const delFoodSafetyCampaign = (ids: string | number | Array<string | number>): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/campaign/' + ids,
+    method: 'delete'
+  });
+};
+
+// 导出活动列表
+export const exportFoodSafetyCampaign = (query: RiskFoodSafetyCampaignBo): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/campaign/export',
+    method: 'post',
+    data: query
+  });
+};
+
+/**
+ * 活动开始(状态:未开始 → 进行中)
+ */
+export const startFoodSafetyCampaign = (id: string | number): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/campaign/start/' + id,
+    method: 'put'
+  });
+};
+
+/**
+ * 活动结束(状态:进行中 → 已结束),body 中传 summary
+ */
+export const finishFoodSafetyCampaign = (
+  id: string | number,
+  data: { summary: string }
+): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/campaign/finish/' + id,
+    method: 'put',
+    data: data
+  });
+};
+
+/**
+ * 查询看板统计数据(总活动/参与人次/资料/即将进行)
+ */
+export const getFoodSafetyCampaignStats = (): AxiosPromise<FoodSafetyCampaignStatsVo> => {
+  return request({
+    url: '/risk/foodSafety/campaign/stats',
+    method: 'get'
+  });
+};

+ 59 - 0
src/api/risk/foodSafetyMaterial.ts

@@ -0,0 +1,59 @@
+import { AxiosPromise } from 'axios';
+import request from '@/utils/request';
+import { RiskFoodSafetyMaterialBo, RiskFoodSafetyMaterialVo } from '@/api/risk/types';
+
+/**
+ * 食品安全宣传 - 资料管理 API
+ */
+
+// 查询宣传资料列表
+export const listFoodSafetyMaterial = (query: RiskFoodSafetyMaterialBo): AxiosPromise<RiskFoodSafetyMaterialVo[]> => {
+  return request({
+    url: '/risk/foodSafety/material/list',
+    method: 'get',
+    params: query
+  });
+};
+
+// 查询资料详情
+export const getFoodSafetyMaterial = (id: string | number): AxiosPromise<RiskFoodSafetyMaterialVo> => {
+  return request({
+    url: '/risk/foodSafety/material/' + id,
+    method: 'get'
+  });
+};
+
+// 新增资料(上传资料)
+export const addFoodSafetyMaterial = (data: RiskFoodSafetyMaterialBo): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/material',
+    method: 'post',
+    data: data
+  });
+};
+
+// 修改资料
+export const updateFoodSafetyMaterial = (data: RiskFoodSafetyMaterialBo): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/material',
+    method: 'put',
+    data: data
+  });
+};
+
+// 删除资料
+export const delFoodSafetyMaterial = (ids: string | number | Array<string | number>): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/material/' + ids,
+    method: 'delete'
+  });
+};
+
+// 导出资料列表
+export const exportFoodSafetyMaterial = (query: RiskFoodSafetyMaterialBo): AxiosPromise => {
+  return request({
+    url: '/risk/foodSafety/material/export',
+    method: 'post',
+    data: query
+  });
+};

+ 62 - 0
src/api/risk/qualityInspectionTask.ts

@@ -0,0 +1,62 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import { RiskInspectionTaskBo, RiskInspectionTaskVo, RiskInspectionRecordVo, InspectionStatsVo } from '@/api/risk/types';
+
+// 查询品控巡检任务列表
+export const listQualityInspectionTask = (query: RiskInspectionTaskBo): AxiosPromise<RiskInspectionTaskVo[]> => {
+  return request({
+    url: '/quality/inspectionTask/list',
+    method: 'get',
+    params: query
+  });
+};
+
+// 查询品控巡检任务详情
+export const getQualityInspectionTask = (id: string | number): AxiosPromise<RiskInspectionTaskVo> => {
+  return request({
+    url: '/quality/inspectionTask/' + id,
+    method: 'get'
+  });
+};
+
+// 新增品控巡检任务
+export const addQualityInspectionTask = (data: RiskInspectionTaskBo): AxiosPromise => {
+  return request({
+    url: '/quality/inspectionTask',
+    method: 'post',
+    data: data
+  });
+};
+
+// 修改品控巡检任务
+export const updateQualityInspectionTask = (data: RiskInspectionTaskBo): AxiosPromise => {
+  return request({
+    url: '/quality/inspectionTask',
+    method: 'put',
+    data: data
+  });
+};
+
+// 删除品控巡检任务
+export const delQualityInspectionTask = (ids: string | number | Array<string | number>): AxiosPromise => {
+  return request({
+    url: '/quality/inspectionTask/' + ids,
+    method: 'delete'
+  });
+};
+
+// 查询品控巡检任务的打卡记录列表
+export const listQualityInspectionRecordByTask = (taskId: string | number): AxiosPromise<RiskInspectionRecordVo[]> => {
+  return request({
+    url: '/quality/inspectionTask/records/' + taskId,
+    method: 'get'
+  });
+};
+
+// 查询品控巡检统计数据(顶部4张卡片)
+export const getQualityInspectionStats = (): AxiosPromise<InspectionStatsVo> => {
+  return request({
+    url: '/quality/inspectionTask/stats',
+    method: 'get'
+  });
+};

+ 10 - 0
src/api/risk/supervision.ts

@@ -0,0 +1,10 @@
+import request from '@/utils/request';
+import type { SupervisionStatsVo } from './types';
+
+/** 监督抽检 - 顶部 4 张数据看板聚合接口 */
+export function getSupervisionStats() {
+  return request({
+    url: '/risk/supervision/case/stats',
+    method: 'get'
+  });
+}

+ 65 - 0
src/api/risk/supervisionCase.ts

@@ -0,0 +1,65 @@
+import request from '@/utils/request';
+import type {
+  RiskSupervisionCaseBo,
+  RiskSupervisionCaseVo
+} from './types';
+
+/** 监督抽检 - 不合格案件 API */
+export function listCase(query: Partial<RiskSupervisionCaseBo> & { pageNum?: number; pageSize?: number }) {
+  return request({
+    url: '/risk/supervision/case/list',
+    method: 'get',
+    params: query
+  });
+}
+
+export function getCase(id: string | number) {
+  return request({
+    url: `/risk/supervision/case/${id}`,
+    method: 'get'
+  });
+}
+
+/** 新增案件(从不合格抽检单"案件处理"按钮触发) */
+export function addCase(data: RiskSupervisionCaseBo) {
+  return request({
+    url: '/risk/supervision/case',
+    method: 'post',
+    data
+  });
+}
+
+export function updateCase(data: RiskSupervisionCaseBo) {
+  return request({
+    url: '/risk/supervision/case',
+    method: 'put',
+    data
+  });
+}
+
+export function delCase(ids: Array<string | number>) {
+  return request({
+    url: `/risk/supervision/case/${ids.join(',')}`,
+    method: 'delete'
+  });
+}
+
+export function exportCase(query: Partial<RiskSupervisionCaseBo>) {
+  return request({
+    url: '/risk/supervision/case/export',
+    method: 'post',
+    params: query,
+    responseType: 'blob'
+  });
+}
+
+/** 处理案件节点(写节点表 + current_node + 1) */
+export function processCase(data: RiskSupervisionCaseBo) {
+  return request({
+    url: '/risk/supervision/case/process',
+    method: 'put',
+    data
+  });
+}
+
+export type { RiskSupervisionCaseBo, RiskSupervisionCaseVo };

+ 65 - 0
src/api/risk/supervisionComplaint.ts

@@ -0,0 +1,65 @@
+import request from '@/utils/request';
+import type {
+  RiskSupervisionComplaintBo,
+  RiskSupervisionComplaintVo
+} from './types';
+
+/** 监督抽检 - 平台客诉 API */
+export function listComplaint(query: Partial<RiskSupervisionComplaintBo> & { pageNum?: number; pageSize?: number }) {
+  return request({
+    url: '/risk/supervision/complaint/list',
+    method: 'get',
+    params: query
+  });
+}
+
+export function getComplaint(id: string | number) {
+  return request({
+    url: `/risk/supervision/complaint/${id}`,
+    method: 'get'
+  });
+}
+
+/** 新增客诉(从"新增客诉"按钮触发) */
+export function addComplaint(data: RiskSupervisionComplaintBo) {
+  return request({
+    url: '/risk/supervision/complaint',
+    method: 'post',
+    data
+  });
+}
+
+export function updateComplaint(data: RiskSupervisionComplaintBo) {
+  return request({
+    url: '/risk/supervision/complaint',
+    method: 'put',
+    data
+  });
+}
+
+export function delComplaint(ids: Array<string | number>) {
+  return request({
+    url: `/risk/supervision/complaint/${ids.join(',')}`,
+    method: 'delete'
+  });
+}
+
+export function exportComplaint(query: Partial<RiskSupervisionComplaintBo>) {
+  return request({
+    url: '/risk/supervision/complaint/export',
+    method: 'post',
+    params: query,
+    responseType: 'blob'
+  });
+}
+
+/** 处理客诉节点(写节点表 + current_node + 1) */
+export function processComplaint(data: RiskSupervisionComplaintBo) {
+  return request({
+    url: '/risk/supervision/complaint/process',
+    method: 'put',
+    data
+  });
+}
+
+export type { RiskSupervisionComplaintBo, RiskSupervisionComplaintVo };

+ 63 - 0
src/api/risk/supervisionInspection.ts

@@ -0,0 +1,63 @@
+import request from '@/utils/request';
+import type {
+  RiskSupervisionInspectionBo,
+  RiskSupervisionInspectionVo
+} from './types';
+
+/** 监督抽检 - 抽检单 API */
+export function listInspection(query: Partial<RiskSupervisionInspectionBo> & { pageNum?: number; pageSize?: number }) {
+  return request({
+    url: '/risk/supervision/inspection/list',
+    method: 'get',
+    params: query
+  });
+}
+
+export function getInspection(id: string | number) {
+  return request({
+    url: `/risk/supervision/inspection/${id}`,
+    method: 'get'
+  });
+}
+
+export function addInspection(data: RiskSupervisionInspectionBo) {
+  return request({
+    url: '/risk/supervision/inspection',
+    method: 'post',
+    data
+  });
+}
+
+export function updateInspection(data: RiskSupervisionInspectionBo) {
+  return request({
+    url: '/risk/supervision/inspection',
+    method: 'put',
+    data
+  });
+}
+
+export function delInspection(ids: Array<string | number>) {
+  return request({
+    url: `/risk/supervision/inspection/${ids.join(',')}`,
+    method: 'delete'
+  });
+}
+
+export function exportInspection(query: Partial<RiskSupervisionInspectionBo>) {
+  return request({
+    url: '/risk/supervision/inspection/export',
+    method: 'post',
+    params: query,
+    responseType: 'blob'
+  });
+}
+
+/** 审核抽检单(待审核 → 已审核) */
+export function auditInspection(id: string | number) {
+  return request({
+    url: `/risk/supervision/inspection/audit/${id}`,
+    method: 'put'
+  });
+}
+
+export type { RiskSupervisionInspectionBo, RiskSupervisionInspectionVo };

+ 502 - 0
src/api/risk/types.ts

@@ -51,6 +51,9 @@ export interface RiskHiddenDangerVo {
   photoList: string | string[];
   remark: string;
   createBy: string;
+  createByName: string;
+  createDept: string;
+  createDeptName: string;
   createTime: string;
   updateBy: string;
   updateTime: string;
@@ -110,6 +113,8 @@ export interface RiskWorkReportBo {
   workLocation?: string;
   workStartTime?: string;
   workEndTime?: string;
+  /** 工期(单位:天,与开始/结束时间联动计算) */
+  durationDays?: number;
   reporterId?: string | number;
   reporterName?: string;
   reportTime?: string;
@@ -140,6 +145,8 @@ export interface RiskWorkReportVo {
   workLocation: string;
   workStartTime: string;
   workEndTime: string;
+  /** 工期(单位:天) */
+  durationDays: number;
   reporterId: string | number;
   reporterName: string;
   reportTime: string;
@@ -375,6 +382,10 @@ export interface RiskWarningBo {
   status?: string;
   readCount?: number;
   remark?: string;
+  /** 附件访问URL(MinIO对象存储路径) */
+  attachmentUrl?: string;
+  /** 附件原始文件名 */
+  attachmentName?: string;
   params?: any;
 }
 
@@ -396,6 +407,10 @@ export interface RiskWarningVo {
   status: string;
   readCount: number;
   remark: string;
+  /** 附件访问URL(MinIO对象存储路径) */
+  attachmentUrl: string;
+  /** 附件原始文件名 */
+  attachmentName: string;
   createBy: string;
   createTime: string;
   updateBy: string;
@@ -417,6 +432,8 @@ export interface RiskSpecialEquipmentBo {
   storeName?: string;
   location?: string;
   photoList?: string | string[];
+  nameplateUrl?: string;
+  nameplateName?: string;
   installDate?: string;
   useStatus?: string;
   inspectionStatus?: string;
@@ -441,6 +458,8 @@ export interface RiskSpecialEquipmentVo {
   storeName: string;
   location: string;
   photoList: string | string[];
+  nameplateUrl: string;
+  nameplateName: string;
   installDate: string;
   useStatus: string;
   inspectionStatus: string;
@@ -600,6 +619,16 @@ export interface RiskAccidentBo {
   handlerId?: string | number;
   handlerName?: string;
   handleMeasures?: string;
+  /** 处理方式(字典:risk_accident_handle_way) */
+  handleResult?: string;
+  /** 处理期限 */
+  handleDeadline?: string;
+  /** 处理附件URL(MinIO对象存储路径) */
+  handleAttachmentUrl?: string;
+  /** 处理附件原始文件名 */
+  handleAttachmentName?: string;
+  /** 处理时间 */
+  handleTime?: string;
   investigationResult?: string;
   improvementMeasures?: string;
   remark?: string;
@@ -628,6 +657,16 @@ export interface RiskAccidentVo {
   handlerId: string | number;
   handlerName: string;
   handleMeasures: string;
+  /** 处理方式(字典:risk_accident_handle_way) */
+  handleResult: string;
+  /** 处理期限 */
+  handleDeadline: string;
+  /** 处理附件URL(MinIO对象存储路径) */
+  handleAttachmentUrl: string;
+  /** 处理附件原始文件名 */
+  handleAttachmentName: string;
+  /** 处理时间 */
+  handleTime: string;
   investigationResult: string;
   improvementMeasures: string;
   remark: string;
@@ -830,3 +869,466 @@ export interface RiskInspectionRecordVo {
   updateTime: string;
 }
 
+// ==================== 访厂管理 ====================
+export interface RiskFactoryVisitBo {
+  id?: string | number;
+  visitCode?: string;
+  companyName?: string;
+  companyAddress?: string;
+  visitDate?: string;
+  visitorId?: string | number;
+  visitorName?: string;
+  auditDept?: string;
+  productScope?: string;
+  reviewerId?: string | number;
+  reviewerName?: string;
+  totalScore?: number;
+  status?: string;
+  remark?: string;
+  keyword?: string;
+  /** 审核结论(通过/不通过/待定) - 仅审核接口使用 */
+  auditResult?: string;
+  /** 审核意见 */
+  auditRemark?: string;
+  /** 审核人ID */
+  auditorId?: string | number;
+  /** 审核人姓名 */
+  auditorName?: string;
+  params?: any;
+}
+
+export interface RiskFactoryVisitVo {
+  id: string | number;
+  visitCode: string;
+  companyName: string;
+  companyAddress: string;
+  visitDate: string;
+  visitorId: string | number;
+  visitorName: string;
+  auditDept: string;
+  productScope: string;
+  reviewerId: string | number;
+  reviewerName: string;
+  totalScore: number;
+  auditResult: string;
+  auditRemark: string;
+  auditorId: string | number;
+  auditorName: string;
+  auditTime: string;
+  status: string;
+  remark: string;
+  createBy: string;
+  createTime: string;
+  updateBy: string;
+  updateTime: string;
+  report?: RiskFactoryVisitReportVo;
+}
+
+export interface RiskFactoryVisitReportVo {
+  id: string | number;
+  visitId: string | number;
+  basicEvaluate: any;
+  esgScore: any;
+  gmpScore: any;
+  iso14001Score: any;
+  iso45001Score: any;
+  iso9001Score: any;
+  allergenEvaluate: any;
+  summary: string;
+}
+
+export interface RiskFactoryVisitReportBo {
+  id?: string | number;
+  visitId: string | number;
+  basicEvaluate?: any;
+  esgScore?: any;
+  gmpScore?: any;
+  iso14001Score?: any;
+  iso45001Score?: any;
+  iso9001Score?: any;
+  allergenEvaluate?: any;
+  summary?: string;
+  /** 总分(可由前端传入,否则后端按各模块分数自动求和) */
+  totalScore?: number;
+  /** 访厂日期(写入主表) */
+  visitDate?: string;
+}
+
+// ==================== 食品安全宣传(活动) ====================
+export interface RiskFoodSafetyCampaignBo {
+  id?: string | number;
+  /** 活动编号(后端自动生成 CF+yyyyMMdd+0001) */
+  campaignCode?: string;
+  /** 活动名称 */
+  campaignName?: string;
+  /** 活动类型(字典:risk_food_safety_campaign_type) */
+  campaignType?: string;
+  /** 开始时间 yyyy-MM-dd HH:mm:ss */
+  startTime?: string;
+  /** 结束时间 yyyy-MM-dd HH:mm:ss */
+  endTime?: string;
+  /** 预计参与人数 */
+  expectedParticipants?: number;
+  /** 实际参与人数(活动结束录入或统计) */
+  actualParticipants?: number;
+  /** 活动地点 */
+  location?: string;
+  /** 活动描述 */
+  description?: string;
+  /** 负责人ID */
+  principalId?: string | number;
+  /** 负责人姓名 */
+  principalName?: string;
+  /** 状态(未开始/进行中/已结束) */
+  status?: string;
+  /** 实际开始时间 */
+  startRealTime?: string;
+  /** 实际结束时间 */
+  endRealTime?: string;
+  /** 活动总结(活动结束必填) */
+  summary?: string;
+  /** 备注 */
+  remark?: string;
+  /** 关键词(搜索活动名称/编号/负责人) */
+  keyword?: string;
+  params?: any;
+}
+
+export interface RiskFoodSafetyCampaignVo {
+  id: string | number;
+  campaignCode: string;
+  campaignName: string;
+  campaignType: string;
+  startTime: string;
+  endTime: string;
+  expectedParticipants: number;
+  actualParticipants: number;
+  location: string;
+  description: string;
+  principalId: string | number;
+  principalName: string;
+  /** 状态:未开始/进行中/已结束 */
+  status: string;
+  startRealTime: string;
+  endRealTime: string;
+  summary: string;
+  remark: string;
+  createBy: string;
+  createTime: string;
+  updateBy: string;
+  updateTime: string;
+}
+
+/** 食品安全宣传看板统计 VO */
+export interface FoodSafetyCampaignStatsVo {
+  /** 总宣传活动数 */
+  totalCount: number;
+  /** 总宣传活动 较上月变化率(%) */
+  totalRate: number;
+  /** 参与人次(累计) */
+  participantsTotal: number;
+  /** 参与人次 较上月变化率(%) */
+  participantsRate: number;
+  /** 宣传资料数量 */
+  materialTotal: number;
+  /** 宣传资料 较上月变化率(%) */
+  materialRate: number;
+  /** 即将进行(未开始数) */
+  notStartedCount: number;
+  /** 即将进行 较上月变化率(%) */
+  notStartedRate: number;
+  /** 上月总活动数 */
+  lastMonthTotal: number;
+  /** 上月参与人次 */
+  lastMonthParticipants: number;
+  /** 上月资料总数 */
+  lastMonthMaterial: number;
+  /** 上月未开始数 */
+  lastMonthNotStarted: number;
+}
+
+// ==================== 食品安全宣传(资料) ====================
+export interface RiskFoodSafetyMaterialBo {
+  id?: string | number;
+  /** 资料编号(后端自动生成 MT+yyyyMMdd+0001) */
+  materialCode?: string;
+  /** 资料名称 */
+  materialName?: string;
+  /** 资料类型(字典:risk_food_safety_material_type:图文海报类/培训手册/视频音频类) */
+  materialType?: string;
+  /** 资料概述 */
+  materialSummary?: string;
+  /** 资料封面URL(MinIO) */
+  coverUrl?: string;
+  /** 资料封面原始文件名 */
+  coverName?: string;
+  /** 资料附件URL(MinIO) */
+  attachmentUrl?: string;
+  /** 资料附件原始文件名 */
+  attachmentName?: string;
+  /** 关联活动ID(可空) */
+  campaignId?: string | number;
+  /** 上传人ID */
+  uploaderId?: string | number;
+  /** 上传人姓名 */
+  uploaderName?: string;
+  /** 关键词搜索 */
+  keyword?: string;
+  params?: any;
+}
+
+export interface RiskFoodSafetyMaterialVo {
+  id: string | number;
+  materialCode: string;
+  materialName: string;
+  materialType: string;
+  materialSummary: string;
+  coverUrl: string;
+  coverName: string;
+  attachmentUrl: string;
+  attachmentName: string;
+  campaignId: string | number;
+  uploaderId: string | number;
+  uploaderName: string;
+  createTime: string;
+}
+
+// ==================== 监督抽检 / 平台客诉(3.1.4) ====================
+
+/** 监督抽检 - 抽检节点记录视图对象 */
+export interface SupervisionCaseNodeVo {
+  id: string | number;
+  caseId: string | number;
+  nodeCode: number;
+  nodeName: string;
+  opinion: string;
+  attachmentUrl: string;
+  attachmentName: string;
+  handlerId: string | number;
+  handlerName: string;
+  handleTime: string;
+  createTime: string;
+}
+
+/** 监督抽检 - 客诉节点记录视图对象 */
+export interface SupervisionComplaintNodeVo {
+  id: string | number;
+  complaintId: string | number;
+  nodeCode: number;
+  nodeName: string;
+  opinion: string;
+  attachmentUrl: string;
+  attachmentName: string;
+  handlerId: string | number;
+  handlerName: string;
+  handleTime: string;
+  createTime: string;
+}
+
+/** 监督抽检 - 抽检单业务对象 */
+export interface RiskSupervisionInspectionBo {
+  id?: string | number;
+  /** 抽检单号(JC+yyyyMMdd+4位序号) */
+  inspectionCode?: string;
+  /** 门店ID */
+  storeId?: string | number;
+  /** 门店名称 */
+  storeName?: string;
+  /** 抽检时间 */
+  inspectionTime?: string;
+  /** 抽检商品 */
+  productName?: string;
+  /** 抽检结果(字典:抽检合格/抽检不合格) */
+  inspectionResult?: string;
+  /** 不合格原因 */
+  failReason?: string;
+  /** 状态(字典:待审核/已审核) */
+  status?: string;
+  /** 抽检人ID */
+  inspectorId?: string | number;
+  /** 抽检人姓名 */
+  inspectorName?: string;
+  /** 备注 */
+  remark?: string;
+  /** 关键词 */
+  keyword?: string;
+  params?: any;
+}
+
+/** 监督抽检 - 抽检单视图对象 */
+export interface RiskSupervisionInspectionVo {
+  id: string | number;
+  inspectionCode: string;
+  storeId: string | number;
+  storeName: string;
+  inspectionTime: string;
+  productName: string;
+  inspectionResult: string;
+  failReason: string;
+  status: string;
+  inspectorId: string | number;
+  inspectorName: string;
+  remark: string;
+  createTime: string;
+  updateBy: string | number;
+  updateTime: string;
+}
+
+/** 监督抽检 - 不合格案件业务对象 */
+export interface RiskSupervisionCaseBo {
+  id?: string | number;
+  /** 案件编号(AJ+yyyyMMdd+4位序号) */
+  caseCode?: string;
+  /** 关联抽检单ID(新增时必填) */
+  inspectionId?: string | number;
+  /** 冗余抽检单号 */
+  inspectionCode?: string;
+  /** 涉事门店ID */
+  storeId?: string | number;
+  /** 涉事门店 */
+  storeName?: string;
+  /** 不合格产品 */
+  productName?: string;
+  /** 当前节点(1-5) */
+  currentNode?: number;
+  /** 当前节点名称 */
+  nodeName?: string;
+  /** 风险等级(字典:高风险/中风险/低风险) */
+  riskLevel?: string;
+  /** 风险描述 */
+  riskDesc?: string;
+  /** 控制措施 */
+  controlMeasures?: string;
+  /** 处理人ID */
+  handlerId?: string | number;
+  /** 处理人 */
+  handlerName?: string;
+  /** 状态(字典:处置中/已闭环) */
+  status?: string;
+  /** 备注 */
+  remark?: string;
+  /** 关键词搜索 */
+  keyword?: string;
+  /** 当前节点处理意见(节点处理时必填) */
+  opinion?: string;
+  /** 凭证附件URL(MinIO) */
+  attachmentUrl?: string;
+  /** 凭证附件原始文件名 */
+  attachmentName?: string;
+  params?: any;
+}
+
+/** 监督抽检 - 不合格案件视图对象(含历史节点) */
+export interface RiskSupervisionCaseVo {
+  id: string | number;
+  caseCode: string;
+  inspectionId: string | number;
+  inspectionCode: string;
+  storeId: string | number;
+  storeName: string;
+  productName: string;
+  currentNode: number;
+  nodeName: string;
+  riskLevel: string;
+  riskDesc: string;
+  controlMeasures: string;
+  handlerId: string | number;
+  handlerName: string;
+  status: string;
+  remark: string;
+  createTime: string;
+  updateBy: string | number;
+  updateTime: string;
+  /** 历史节点处理记录(仅详情接口填充) */
+  nodeRecords: SupervisionCaseNodeVo[];
+}
+
+/** 监督抽检 - 平台客诉业务对象 */
+export interface RiskSupervisionComplaintBo {
+  id?: string | number;
+  /** 客诉编号(TS+yyyyMMdd+4位序号) */
+  complaintCode?: string;
+  /** 客诉来源(字典) */
+  source?: string;
+  /** 投诉人姓名 */
+  complainantName?: string;
+  /** 投诉人电话 */
+  complainantPhone?: string;
+  /** 涉事门店ID */
+  storeId?: string | number;
+  /** 涉事门店 */
+  storeName?: string;
+  /** 移交时间 */
+  transferTime?: string;
+  /** 客诉内容 */
+  complaintContent?: string;
+  /** 客诉附件URL */
+  attachmentUrl?: string;
+  /** 客诉附件原始文件名 */
+  attachmentName?: string;
+  /** 当前节点(1-7) */
+  currentNode?: number;
+  /** 当前节点名称 */
+  nodeName?: string;
+  /** 状态(字典:处置中/已闭环) */
+  status?: string;
+  /** 备注 */
+  remark?: string;
+  /** 关键词搜索 */
+  keyword?: string;
+  /** 当前节点处理意见(节点处理时必填) */
+  opinion?: string;
+  /** 处理环节附件URL */
+  processAttachmentUrl?: string;
+  /** 处理环节附件原始文件名 */
+  processAttachmentName?: string;
+  params?: any;
+}
+
+/** 监督抽检 - 平台客诉视图对象(含历史节点) */
+export interface RiskSupervisionComplaintVo {
+  id: string | number;
+  complaintCode: string;
+  source: string;
+  complainantName: string;
+  complainantPhone: string;
+  storeId: string | number;
+  storeName: string;
+  transferTime: string;
+  complaintContent: string;
+  attachmentUrl: string;
+  attachmentName: string;
+  currentNode: number;
+  nodeName: string;
+  status: string;
+  remark: string;
+  createTime: string;
+  updateBy: string | number;
+  updateTime: string;
+  /** 历史节点处理记录(仅详情接口填充) */
+  nodeRecords: SupervisionComplaintNodeVo[];
+}
+
+/** 监督抽检 - 顶部 4 张数据看板 VO */
+export interface SupervisionStatsVo {
+  /** 今日待审核抽检单据总量 */
+  todayPending: number;
+  /** 不合格待处理案件数(状态=处置中) */
+  pendingCases: number;
+  /** 待处理客诉移交数(状态=处置中) */
+  pendingComplaints: number;
+  /** 本月抽检合格率 */
+  monthPassRate: SupervisionMonthPassRate;
+}
+
+export interface SupervisionMonthPassRate {
+  /** 统计月份 yyyy-MM */
+  month: string;
+  /** 本月合格单数 */
+  passCount: number;
+  /** 本月总单数 */
+  totalCount: number;
+  /** 合格率(0-100, 保留 2 位小数) */
+  passRate: number;
+}
+

+ 18 - 0
src/router/index.ts

@@ -319,6 +319,24 @@ export const dynamicRoutes: RouteRecordRaw[] = [
         component: () => import('@/views/risk/inspectionTask/index.vue'),
         name: 'RiskInspectionTask',
         meta: { title: '巡检任务', icon: 'list', permissions: ['risk:inspectionTask:list'] }
+      },
+      {
+        path: 'qualityInspectionTask',
+        component: () => import('@/views/risk/qualityInspectionTask/index.vue'),
+        name: 'RiskQualityInspectionTask',
+        meta: { title: '品控巡检', icon: 'list', permissions: ['risk:qualityInspectionTask:list'] }
+      },
+      {
+        path: 'factoryVisit',
+        component: () => import('@/views/risk/factoryVisit/index.vue'),
+        name: 'RiskFactoryVisit',
+        meta: { title: '品控访厂', icon: 'guide', permissions: ['risk:factoryVisit:list'] }
+      },
+      {
+        path: 'bigScreen',
+        component: () => import('@/views/risk/bigScreen/index.vue'),
+        name: 'RiskBigScreen',
+        meta: { title: '风控大屏', icon: 'data-board', permissions: ['risk:bigScreen:view'] }
       }
     ]
   }

+ 251 - 18
src/views/risk/accident/index.vue

@@ -24,7 +24,7 @@
         <el-card shadow="hover" class="stat-card">
           <div class="stat-card-content">
             <div class="stat-info">
-              <div class="stat-title">调查中</div>
+              <div class="stat-title">待处理</div>
               <div class="stat-value">{{ stats.investigating || 0 }}</div>
               <div class="stat-extra" :class="(stats.investigatingRate || 0) >= 0 ? 'stat-extra-up' : 'stat-extra-down'">
                 <el-icon v-if="(stats.investigatingRate || 0) >= 0"><CaretTop /></el-icon>
@@ -42,7 +42,7 @@
         <el-card shadow="hover" class="stat-card">
           <div class="stat-card-content">
             <div class="stat-info">
-              <div class="stat-title">已结束</div>
+              <div class="stat-title">归档</div>
               <div class="stat-value">{{ stats.finished || 0 }}</div>
               <div class="stat-extra" :class="(stats.finishedRate || 0) >= 0 ? 'stat-extra-up' : 'stat-extra-down'">
                 <el-icon v-if="(stats.finishedRate || 0) >= 0"><CaretTop /></el-icon>
@@ -109,15 +109,14 @@
     </transition>
 
     <!-- 数据表格区域 -->
-    <el-card shadow="hover">
-      <template #header>
-        <el-row :gutter="10" class="mb-2">
-          <el-col :span="1.5">
-            <el-button type="primary" plain icon="Plus" @click="handleAdd">新增上报</el-button>
-          </el-col>
-          <right-toolbar v-model:show-search="showSearch" @query-table="getList" />
-        </el-row>
-      </template>
+    <el-card shadow="hover" class="list-card">
+      <div class="list-toolbar">
+        <span class="list-total">共 {{ total }} 条记录</span>
+        <div class="toolbar-actions">
+          <el-button type="primary" plain icon="Plus" @click="handleAdd">新增上报</el-button>
+          <right-toolbar v-model:show-search="showSearch" @query-table="getList" style="margin-left: 8px;" />
+        </div>
+      </div>
 
       <el-table v-loading="loading" :data="accidentList" border>
         <el-table-column label="事故编号" align="center" prop="accidentCode" width="220" :show-overflow-tooltip="true" />
@@ -135,9 +134,10 @@
             <el-tag :type="getStatusTag(scope.row.handleStatus)">{{ scope.row.handleStatus }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="操作" fixed="right" align="center" width="240" class-name="small-padding fixed-width">
+        <el-table-column label="操作" fixed="right" align="center" width="280" class-name="small-padding fixed-width">
           <template #default="scope">
             <el-button v-hasPermi="['risk:accident:query']" link type="primary" icon="View" @click="handleView(scope.row)">详情</el-button>
+            <el-button v-if="scope.row.handleStatus === '待处理'" v-hasPermi="['risk:accident:handle']" link type="primary" icon="CircleCheck" @click="openHandleDialog(scope.row)">处理</el-button>
             <el-button v-hasPermi="['risk:accident:edit']" link type="primary" icon="Edit" @click="handleUpdate(scope.row)">编辑</el-button>
             <el-button v-hasPermi="['risk:accident:remove']" link type="danger" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
           </template>
@@ -263,6 +263,78 @@
         <el-button @click="viewDialog.visible = false">关 闭</el-button>
       </template>
     </el-dialog>
+
+    <!-- 事故处理弹窗(独立,字段:处理方式/处理意见/责任人/处理期限/附件) -->
+    <el-dialog
+      v-model="handleDialog.visible"
+      :title="handleDialog.title"
+      width="640px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="cancelHandle"
+    >
+      <el-form ref="handleFormRef" :model="handleForm" :rules="handleRules" label-width="100px">
+        <el-form-item label="事故编号">
+          <span class="readonly-text">{{ handleForm.accidentCode }}</span>
+        </el-form-item>
+        <el-form-item label="处理方式" prop="handleResult">
+          <el-select v-model="handleForm.handleResult" placeholder="请选择处理方式" style="width: 100%" clearable>
+            <el-option v-for="dict in accident_handle_way_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="处理意见" prop="handleMeasures">
+          <el-input
+            v-model="handleForm.handleMeasures"
+            type="textarea"
+            :rows="5"
+            placeholder="请详细填写本次事故处理过程、措施与结论..."
+            maxlength="2000"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="责任人" prop="handlerName">
+          <el-input v-model="handleForm.handlerName" placeholder="请输入责任人姓名(默认当前登录用户)" maxlength="50" />
+        </el-form-item>
+        <el-form-item label="处理期限" prop="handleDeadline">
+          <el-date-picker
+            v-model="handleForm.handleDeadline"
+            type="date"
+            placeholder="请选择处理期限"
+            value-format="YYYY-MM-DD"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="附件上传">
+          <el-upload
+            v-model:file-list="handleAttachmentFileList"
+            :http-request="customHandleAttachmentUpload"
+            list-type="text"
+            :limit="1"
+            :on-success="onHandleAttachmentSuccess"
+            :on-error="onHandleAttachmentError"
+            :on-exceed="onHandleAttachmentExceed"
+            :on-remove="onHandleAttachmentRemove"
+            :before-upload="beforeHandleAttachmentUpload"
+          >
+            <el-button type="primary" plain icon="Upload">点击上传</el-button>
+            <template #tip>
+              <div class="el-upload__tip">支持任意格式文件,大小不超过 20MB</div>
+            </template>
+          </el-upload>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="cancelHandle">取 消</el-button>
+          <el-button
+            v-hasPermi="['risk:accident:handle']"
+            type="primary"
+            :loading="handleSubmitting"
+            @click="submitHandle"
+          >提交处理</el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -276,7 +348,8 @@ import {
   updateAccident,
   delAccident,
   getAccidentStats,
-  getAccidentTrend
+  getAccidentTrend,
+  handleAccident
 } from '@/api/risk/accident';
 import type { RiskAccidentBo, RiskAccidentVo, AccidentStatsVo, AccidentTrendVo } from '@/api/risk/types';
 import { FormInstance, UploadFile, UploadRawFile, UploadUserFile } from 'element-plus';
@@ -304,9 +377,15 @@ const viewPhotoList = ref<string[]>([]);
 const {
   risk_accident_level: accident_level_dict,
   risk_accident_type: accident_type_dict,
-  risk_accident_status: accident_status_dict
+  risk_accident_status: accident_status_dict,
+  risk_accident_handle_way: accident_handle_way_dict
 } = toRefs<any>(
-  proxy?.useDict('risk_accident_level', 'risk_accident_type', 'risk_accident_status')
+  proxy?.useDict(
+    'risk_accident_level',
+    'risk_accident_type',
+    'risk_accident_status',
+    'risk_accident_handle_way'
+  )
 );
 
 // 统计
@@ -378,6 +457,28 @@ const dialog = reactive<DialogOption>({ visible: false, title: '' });
 const viewDialog = reactive({ visible: false });
 const viewForm = ref<any>({});
 
+// 事故处理弹窗(独立,不与新增/编辑表单混用)
+const handleDialog = reactive({ visible: false, title: '事故处理' });
+const handleSubmitting = ref(false);
+const handleForm = ref<any>({
+  id: undefined,
+  accidentCode: '',
+  handleResult: '',
+  handleMeasures: '',
+  handlerName: '',
+  handleDeadline: '',
+  handleAttachmentUrl: '',
+  handleAttachmentName: ''
+});
+const handleFormRef = ref<FormInstance>();
+const handleAttachmentFileList = ref<UploadUserFile[]>([]);
+const handleRules = {
+  handleResult: [{ required: true, message: '请选择处理方式', trigger: 'change' }],
+  handleMeasures: [{ required: true, message: '请输入处理意见文案', trigger: 'blur' }],
+  handlerName: [{ required: true, message: '请输入责任人', trigger: 'blur' }],
+  handleDeadline: [{ required: true, message: '请选择处理期限', trigger: 'change' }]
+};
+
 const getList = async () => {
   loading.value = true;
   try {
@@ -505,6 +606,103 @@ const handleView = async (row: RiskAccidentVo) => {
   viewDialog.visible = true;
 };
 
+// ============ 事故处理(独立弹窗) ============
+// 打开处理弹窗:重置表单 → 加载事故详情到只读字段 → 显示弹窗
+const openHandleDialog = async (row: RiskAccidentVo) => {
+  if (!row || row.handleStatus !== '待处理') {
+    proxy?.$modal.msgWarning('只有"待处理"状态的事故才能处理');
+    return;
+  }
+  resetHandleForm();
+  const res = await getAccident(row.id);
+  const data: any = res.data || {};
+  handleForm.value.id = data.id;
+  handleForm.value.accidentCode = data.accidentCode || '';
+  // 责任人默认当前登录用户名(由后端 LoginHelper.getUsername() 兜底,前端可空)
+  handleForm.value.handlerName = data.handlerName || '';
+  handleDialog.visible = true;
+};
+
+// 提交处理(后端强制将状态置为"归档")
+const submitHandle = () => {
+  handleFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    handleSubmitting.value = true;
+    try {
+      await handleAccident(handleForm.value.id, handleForm.value as RiskAccidentBo);
+      proxy?.$modal.msgSuccess('处理成功,事故已归档');
+      handleDialog.visible = false;
+      await loadAll();
+    } finally {
+      handleSubmitting.value = false;
+    }
+  });
+};
+
+const cancelHandle = () => {
+  handleDialog.visible = false;
+  resetHandleForm();
+};
+
+const resetHandleForm = () => {
+  handleForm.value = {
+    id: undefined,
+    accidentCode: '',
+    handleResult: '',
+    handleMeasures: '',
+    handlerName: '',
+    handleDeadline: '',
+    handleAttachmentUrl: '',
+    handleAttachmentName: ''
+  };
+  handleAttachmentFileList.value = [];
+  handleFormRef.value?.resetFields();
+};
+
+// ============ 处理弹窗附件上传(走 axios baseURL,避免依赖 :action) ============
+const customHandleAttachmentUpload = async (options: any) => {
+  try {
+    const res: any = await uploadFile(options.file);
+    if (res && res.code === 200 && res.data) {
+      options.onSuccess(res, options.file);
+    } else {
+      options.onError(new Error(res?.msg || '上传失败'));
+    }
+  } catch (err: any) {
+    options.onError(err);
+  }
+};
+
+const beforeHandleAttachmentUpload = (file: UploadRawFile) => {
+  const isLt20M = file.size / 1024 / 1024 < 20;
+  if (!isLt20M) {
+    proxy?.$modal.msgError('附件大小不能超过 20MB!');
+    return false;
+  }
+  return true;
+};
+
+const onHandleAttachmentSuccess = (response: any, uploadFile: UploadFile) => {
+  const url = response?.fileName || response?.data?.fileName || response?.url || response?.data?.url || '';
+  uploadFile.url = getMinioFileUrl(url);
+  handleForm.value.handleAttachmentUrl = uploadFile.url;
+  handleForm.value.handleAttachmentName = uploadFile.name;
+  proxy?.$modal.msgSuccess('上传成功');
+};
+
+const onHandleAttachmentError = () => {
+  proxy?.$modal.msgError('附件上传失败,请重试');
+};
+
+const onHandleAttachmentExceed = () => {
+  proxy?.$modal.msgWarning('最多只能上传 1 个附件');
+};
+
+const onHandleAttachmentRemove = () => {
+  handleForm.value.handleAttachmentUrl = '';
+  handleForm.value.handleAttachmentName = '';
+};
+
 const parsePhotoList = (val: any): string[] => {
   if (!val) return [];
   if (Array.isArray(val)) return val as string[];
@@ -639,9 +837,7 @@ const getLevelTag = (level: string): 'primary' | 'success' | 'warning' | 'info'
 const getStatusTag = (status: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
   const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
     待处理: 'danger',
-    调查中: 'warning',
-    已结束: 'success',
-    待考核: 'info'
+    归档: 'success'
   };
   return (map[status] || 'info') as any;
 };
@@ -716,4 +912,41 @@ onBeforeUnmount(() => {
 .mt-2 { margin-top: 8px; }
 .trend-chart { width: 100%; height: 360px; }
 .el-upload__tip { font-size: 12px; color: #909399; line-height: 1.5; margin-top: 4px; }
+.list-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.list-total {
+  font-size: 13px;
+  color: #606266;
+}
+.toolbar-actions {
+  display: flex;
+  align-items: center;
+}
+/* ============ 事故处理弹窗专用样式 ============ */
+.readonly-text {
+  display: inline-block;
+  width: 100%;
+  padding: 0 12px;
+  line-height: 32px;
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  color: #606266;
+  font-size: 14px;
+}
+.upload-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 4px;
+}
+.upload-tip {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+}
 </style>

+ 805 - 0
src/views/risk/bigScreen/index.vue

@@ -0,0 +1,805 @@
+<!--
+  3.3 风控品控平台大屏
+  - ① 6 张业务数据卡片
+  - ② 2 个饼图(人员岗位 + 事故类型)
+  - ③ 1 个双折线面积图(巡检/抽检近 30 天)
+  - ④ 1 个渐变柱状图(客诉月度)
+  - ⑤ 1 个滚动走马灯(最新预警)
+-->
+<template>
+  <div class="big-screen">
+    <!-- 顶部标题 -->
+    <div class="bs-header">
+      <div class="bs-header-left">
+        <div class="bs-time">{{ currentTime }}</div>
+        <div class="bs-date">{{ currentDate }}</div>
+      </div>
+      <div class="bs-header-title">
+        <div class="bs-title-zh">风控品控管理平台</div>
+        <div class="bs-title-en">RISK & QUALITY CONTROL PLATFORM</div>
+      </div>
+      <div class="bs-header-right">
+        <el-button class="bs-refresh" type="primary" @click="loadAll">
+          <el-icon><Refresh /></el-icon>
+          <span style="margin-left: 4px">刷新数据</span>
+        </el-button>
+      </div>
+    </div>
+
+    <!-- 主体三列布局 -->
+    <div class="bs-body">
+      <!-- 左列:人员岗位 + 巡检/抽检 -->
+      <div class="bs-col bs-col-left">
+        <div class="bs-panel">
+          <div class="bs-panel-title">
+            <span class="bs-title-bar"></span>人员岗位占比
+          </div>
+          <div ref="personnelPieRef" class="bs-chart bs-pie-chart"></div>
+        </div>
+        <div class="bs-panel">
+          <div class="bs-panel-title">
+            <span class="bs-title-bar"></span>巡检/抽检 · 近30天趋势
+          </div>
+          <div ref="trendLineRef" class="bs-chart bs-line-chart"></div>
+        </div>
+      </div>
+
+      <!-- 中列:6 张卡片 -->
+      <div class="bs-col bs-col-center">
+        <div class="bs-cards">
+          <div class="bs-card bs-card-danger">
+            <div class="bs-card-title">安全隐患</div>
+            <div class="bs-card-value">{{ fmtNum(card.dangerTotal) }}</div>
+            <div class="bs-card-meta">
+              <span class="meta-success">已闭环 {{ fmtNum(card.dangerHandled) }}</span>
+              <span class="meta-danger">待处理 {{ fmtNum(card.dangerPending) }}</span>
+            </div>
+          </div>
+
+          <div class="bs-card bs-card-source">
+            <div class="bs-card-title">危险源</div>
+            <div class="bs-card-value">{{ fmtNum(card.dangerSourceTotal) }}</div>
+            <div class="bs-card-meta">
+              <span class="meta-default">登记在册</span>
+              <span class="meta-danger">本月新增 {{ fmtNum(card.dangerSourceMonthNew) }}</span>
+            </div>
+          </div>
+
+          <div class="bs-card bs-card-work">
+            <div class="bs-card-title">作业上报</div>
+            <div class="bs-card-value">{{ fmtNum(card.workReportTotal) }}</div>
+            <div class="bs-card-meta">
+              <span class="meta-default">历史累计</span>
+              <span class="meta-primary">进行中 {{ fmtNum(card.workReportOngoing) }}</span>
+            </div>
+          </div>
+
+          <div class="bs-card bs-card-audit">
+            <div class="bs-card-title">审计工作 · 人员异动</div>
+            <div class="bs-card-value">{{ fmtNum(card.personnelChangeTotal) }}</div>
+            <div class="bs-card-meta">
+              <span class="meta-danger">离职 {{ fmtNum(card.personnelLeave) }}</span>
+              <span class="meta-primary">调任 {{ fmtNum(card.personnelTransfer) }}</span>
+            </div>
+          </div>
+
+          <div class="bs-card bs-card-equip">
+            <div class="bs-card-title">特种设备</div>
+            <div class="bs-card-value">{{ fmtNum(card.specialEquipmentTotal) }}</div>
+            <div class="bs-card-meta">
+              <span class="meta-success">运行中 {{ fmtNum(card.specialEquipmentRunning) }}</span>
+              <span class="meta-warning">维护中 {{ fmtNum(card.specialEquipmentMaintaining) }}</span>
+            </div>
+          </div>
+
+          <div class="bs-card bs-card-visit">
+            <div class="bs-card-title">访厂审核</div>
+            <div class="bs-card-value">{{ fmtNum(card.factoryVisitTotal) }}</div>
+            <div class="bs-card-meta">
+              <span class="meta-success">通过 {{ fmtNum(card.factoryVisitPassed) }}</span>
+              <span class="meta-danger">不通过 {{ fmtNum(card.factoryVisitRejected) }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 右列:事故 + 客诉 -->
+      <div class="bs-col bs-col-right">
+        <div class="bs-panel">
+          <div class="bs-panel-title">
+            <span class="bs-title-bar"></span>事故类型分布
+          </div>
+          <div ref="accidentPieRef" class="bs-chart bs-pie-chart"></div>
+        </div>
+        <div class="bs-panel">
+          <div class="bs-panel-title">
+            <span class="bs-title-bar"></span>客诉统计 · 近12个月
+          </div>
+          <div ref="complaintBarRef" class="bs-chart bs-bar-chart"></div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 底部:预警走马灯 -->
+    <div class="bs-footer">
+      <div class="bs-footer-title">
+        <span class="bs-title-bar"></span>最新预警事件
+        <span class="bs-footer-tag">实时刷新</span>
+      </div>
+      <div class="bs-stream">
+        <el-scrollbar height="100%">
+          <div class="bs-stream-list">
+            <div v-for="w in warningStream" :key="w.id" class="bs-stream-item">
+              <span class="bs-dot" :style="{ background: dotColor(w.levelColor) }"></span>
+              <span class="bs-stream-time">{{ w.releaseTime }}</span>
+              <span class="bs-stream-level" :style="{ color: dotColor(w.levelColor) }">
+                [{{ w.warningLevel || '一般' }}]
+              </span>
+              <span class="bs-stream-title">{{ w.title }}</span>
+              <span class="bs-stream-store">{{ w.storeName || '—' }}</span>
+            </div>
+            <div v-if="!warningStream.length" class="bs-stream-empty">暂无预警</div>
+          </div>
+        </el-scrollbar>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+import * as echarts from 'echarts';
+import { Refresh } from '@element-plus/icons-vue';
+import {
+  getBigScreenCards,
+  getBigScreenCharts,
+  getBigScreenWarnings,
+  type BigScreenCardVo,
+  type BigScreenChartVo,
+  type BigScreenWarningStreamVo
+} from '@/api/risk/bigScreen';
+
+// ===== 响应式数据 =====
+const card = ref<BigScreenCardVo>({
+  dangerTotal: 0,
+  dangerHandled: 0,
+  dangerPending: 0,
+  dangerSourceTotal: 0,
+  dangerSourceMonthNew: 0,
+  workReportTotal: 0,
+  workReportOngoing: 0,
+  personnelChangeTotal: 0,
+  personnelLeave: 0,
+  personnelTransfer: 0,
+  specialEquipmentTotal: 0,
+  specialEquipmentRunning: 0,
+  specialEquipmentMaintaining: 0,
+  factoryVisitTotal: 0,
+  factoryVisitPassed: 0,
+  factoryVisitRejected: 0
+});
+const warningStream = ref<BigScreenWarningStreamVo[]>([]);
+const currentTime = ref('');
+const currentDate = ref('');
+
+// 图表实例
+const personnelPieRef = ref<HTMLElement>();
+const accidentPieRef = ref<HTMLElement>();
+const trendLineRef = ref<HTMLElement>();
+const complaintBarRef = ref<HTMLElement>();
+
+let personnelPieChart: echarts.ECharts | null = null;
+let accidentPieChart: echarts.ECharts | null = null;
+let trendLineChart: echarts.ECharts | null = null;
+let complaintBarChart: echarts.ECharts | null = null;
+
+let timer: number | undefined;
+let streamTimer: number | undefined;
+let resizeHandler: (() => void) | undefined;
+
+// ===== 工具函数 =====
+const fmtNum = (n: number | undefined | null): string => {
+  if (n === undefined || n === null) return '0';
+  return n.toLocaleString('zh-CN');
+};
+
+const dotColor = (c?: string): string => {
+  if (c === 'red') return '#ff4d4f';
+  if (c === 'yellow') return '#faad14';
+  return '#1890ff';
+};
+
+// ===== 加载数据 =====
+async function loadCards() {
+  try {
+    const res = await getBigScreenCards();
+    card.value = (res as any).data ?? res;
+  } catch (e) {
+    console.warn('[BigScreen] 加载卡片数据失败', e);
+  }
+}
+
+async function loadCharts() {
+  try {
+    const res = await getBigScreenCharts();
+    const data: BigScreenChartVo = (res as any).data ?? res;
+    renderPersonnelPie(data.personnelDistribution);
+    renderAccidentPie(data.accidentDistribution);
+    renderTrendLine(data.trendDates, data.qualityInspectionCount, data.riskInspectionCount);
+    renderComplaintBar(data.complaintMonths, data.complaintCounts);
+  } catch (e) {
+    console.warn('[BigScreen] 加载图表数据失败', e);
+  }
+}
+
+async function loadStream() {
+  try {
+    const res = await getBigScreenWarnings(15);
+    const list: BigScreenWarningStreamVo[] = (res as any).data ?? res;
+    warningStream.value = Array.isArray(list) ? list : [];
+  } catch (e) {
+    console.warn('[BigScreen] 加载预警走马灯失败', e);
+  }
+}
+
+function loadAll() {
+  loadCards();
+  loadCharts();
+  loadStream();
+}
+
+// ===== 时钟 =====
+function tickClock() {
+  const now = new Date();
+  const pad = (n: number) => n.toString().padStart(2, '0');
+  currentTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
+  const weeks = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
+  currentDate.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${weeks[now.getDay()]}`;
+}
+
+// ===== ECharts =====
+const PIE_COLORS = ['#5b8ff9', '#5ad8a6', '#5d7092', '#f6bd16', '#e86452', '#6dc8ec', '#945fb9', '#ff9845'];
+
+function renderPersonnelPie(data: { name: string; value: number }[]) {
+  if (!personnelPieRef.value) return;
+  if (!personnelPieChart) {
+    personnelPieChart = echarts.init(personnelPieRef.value);
+  }
+  personnelPieChart.setOption({
+    tooltip: {
+      trigger: 'item',
+      formatter: (params: any) => `${params.marker} ${params.name}<br/>${params.value} 人 (${params.percent}%)`,
+      backgroundColor: 'rgba(20,40,80,0.95)',
+      borderColor: '#3a8dff',
+      textStyle: { color: '#fff' }
+    },
+    legend: {
+      type: 'scroll',
+      bottom: 0,
+      left: 'center',
+      textStyle: { color: '#aac8ff', fontSize: 12 },
+      itemWidth: 10,
+      itemHeight: 10
+    },
+    color: PIE_COLORS,
+    series: [
+      {
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '42%'],
+        avoidLabelOverlap: true,
+        itemStyle: {
+          borderColor: '#0a1a3a',
+          borderWidth: 2
+        },
+        label: {
+          show: true,
+          color: '#aac8ff',
+          formatter: '{b}\n{d}%',
+          fontSize: 11
+        },
+        labelLine: { lineStyle: { color: '#3a8dff' } },
+        data: data.length ? data : [{ name: '暂无数据', value: 0 }]
+      }
+    ]
+  });
+}
+
+function renderAccidentPie(data: { name: string; value: number }[]) {
+  if (!accidentPieRef.value) return;
+  if (!accidentPieChart) {
+    accidentPieChart = echarts.init(accidentPieRef.value);
+  }
+  accidentPieChart.setOption({
+    tooltip: {
+      trigger: 'item',
+      backgroundColor: 'rgba(20,40,80,0.95)',
+      borderColor: '#3a8dff',
+      textStyle: { color: '#fff' }
+    },
+    legend: {
+      type: 'scroll',
+      bottom: 0,
+      left: 'center',
+      textStyle: { color: '#aac8ff', fontSize: 12 }
+    },
+    color: ['#e86452', '#f6bd16', '#5b8ff9', '#5ad8a6', '#ff9845'],
+    series: [
+      {
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '42%'],
+        avoidLabelOverlap: true,
+        itemStyle: { borderColor: '#0a1a3a', borderWidth: 2 },
+        label: { color: '#aac8ff', formatter: '{b}\n{d}%', fontSize: 11 },
+        labelLine: { lineStyle: { color: '#3a8dff' } },
+        data: data.length ? data : [{ name: '暂无数据', value: 0 }]
+      }
+    ]
+  });
+}
+
+function renderTrendLine(dates: string[], quality: number[], risk: number[]) {
+  if (!trendLineRef.value) return;
+  if (!trendLineChart) {
+    trendLineChart = echarts.init(trendLineRef.value);
+  }
+  trendLineChart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(20,40,80,0.95)',
+      borderColor: '#3a8dff',
+      textStyle: { color: '#fff' }
+    },
+    legend: {
+      data: ['品控巡检', '风控抽检'],
+      top: 8,
+      textStyle: { color: '#aac8ff' }
+    },
+    grid: { left: 40, right: 30, top: 40, bottom: 40 },
+    xAxis: {
+      type: 'category',
+      data: dates,
+      axisLine: { lineStyle: { color: '#3a8dff' } },
+      axisLabel: { color: '#aac8ff', fontSize: 10 }
+    },
+    yAxis: {
+      type: 'value',
+      axisLine: { lineStyle: { color: '#3a8dff' } },
+      axisLabel: { color: '#aac8ff' },
+      splitLine: { lineStyle: { color: 'rgba(58,141,255,0.15)' } }
+    },
+    series: [
+      {
+        name: '品控巡检',
+        type: 'line',
+        smooth: true,
+        symbol: 'circle',
+        symbolSize: 6,
+        lineStyle: { color: '#5ad8a6', width: 2 },
+        itemStyle: { color: '#5ad8a6' },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(90,216,166,0.5)' },
+            { offset: 1, color: 'rgba(90,216,166,0.05)' }
+          ])
+        },
+        data: quality
+      },
+      {
+        name: '风控抽检',
+        type: 'line',
+        smooth: true,
+        symbol: 'circle',
+        symbolSize: 6,
+        lineStyle: { color: '#5b8ff9', width: 2 },
+        itemStyle: { color: '#5b8ff9' },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(91,143,249,0.5)' },
+            { offset: 1, color: 'rgba(91,143,249,0.05)' }
+          ])
+        },
+        data: risk
+      }
+    ]
+  });
+}
+
+function renderComplaintBar(months: string[], counts: number[]) {
+  if (!complaintBarRef.value) return;
+  if (!complaintBarChart) {
+    complaintBarChart = echarts.init(complaintBarRef.value);
+  }
+  complaintBarChart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+      backgroundColor: 'rgba(20,40,80,0.95)',
+      borderColor: '#3a8dff',
+      textStyle: { color: '#fff' }
+    },
+    grid: { left: 40, right: 20, top: 30, bottom: 40 },
+    xAxis: {
+      type: 'category',
+      data: months,
+      axisLine: { lineStyle: { color: '#3a8dff' } },
+      axisLabel: { color: '#aac8ff', fontSize: 10, interval: 0 }
+    },
+    yAxis: {
+      type: 'value',
+      axisLine: { lineStyle: { color: '#3a8dff' } },
+      axisLabel: { color: '#aac8ff' },
+      splitLine: { lineStyle: { color: 'rgba(58,141,255,0.15)' } }
+    },
+    series: [
+      {
+        type: 'bar',
+        barWidth: '50%',
+        data: counts,
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: '#5b8ff9' },
+            { offset: 1, color: 'rgba(91,143,249,0.2)' }
+          ]),
+          borderRadius: [4, 4, 0, 0]
+        },
+        label: {
+          show: true,
+          position: 'top',
+          color: '#aac8ff',
+          fontSize: 11
+        }
+      }
+    ]
+  });
+}
+
+// ===== 生命周期 =====
+onMounted(() => {
+  loadAll();
+  tickClock();
+  timer = window.setInterval(tickClock, 1000);
+  streamTimer = window.setInterval(loadStream, 30000);
+
+  resizeHandler = () => {
+    personnelPieChart?.resize();
+    accidentPieChart?.resize();
+    trendLineChart?.resize();
+    complaintBarChart?.resize();
+  };
+  window.addEventListener('resize', resizeHandler);
+
+  // 给图表初始化一点时间,以便容器有正确尺寸
+  setTimeout(() => {
+    personnelPieChart?.resize();
+    accidentPieChart?.resize();
+    trendLineChart?.resize();
+    complaintBarChart?.resize();
+  }, 200);
+});
+
+onBeforeUnmount(() => {
+  if (timer) {
+    clearInterval(timer);
+    timer = undefined;
+  }
+  if (streamTimer) {
+    clearInterval(streamTimer);
+    streamTimer = undefined;
+  }
+  if (resizeHandler) {
+    window.removeEventListener('resize', resizeHandler);
+  }
+  personnelPieChart?.dispose();
+  accidentPieChart?.dispose();
+  trendLineChart?.dispose();
+  complaintBarChart?.dispose();
+});
+</script>
+
+<style scoped lang="scss">
+.big-screen {
+  width: 100%;
+  height: 100%;
+  min-height: 100vh;
+  background: radial-gradient(ellipse at center, #0a1a3a 0%, #050b1f 100%);
+  color: #e6f0ff;
+  display: flex;
+  flex-direction: column;
+  padding: 12px;
+  box-sizing: border-box;
+  font-family: 'Microsoft YaHei', 'PingFang SC', Arial, sans-serif;
+}
+
+// ===== 顶部 =====
+.bs-header {
+  height: 70px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid rgba(58, 141, 255, 0.3);
+  margin-bottom: 12px;
+
+  &-left,
+  &-right {
+    width: 320px;
+    display: flex;
+    align-items: center;
+    color: #aac8ff;
+  }
+
+  &-right {
+    justify-content: flex-end;
+  }
+
+  &-title {
+    flex: 1;
+    text-align: center;
+  }
+}
+
+.bs-time {
+  font-size: 28px;
+  font-weight: 600;
+  color: #5ad8ff;
+  text-shadow: 0 0 10px rgba(90, 216, 255, 0.6);
+}
+
+.bs-date {
+  margin-left: 12px;
+  font-size: 14px;
+  color: #aac8ff;
+}
+
+.bs-title-zh {
+  font-size: 30px;
+  font-weight: 700;
+  background: linear-gradient(180deg, #ffffff, #5ad8ff);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  letter-spacing: 4px;
+}
+
+.bs-title-en {
+  font-size: 12px;
+  color: #5ad8ff;
+  letter-spacing: 2px;
+  margin-top: 4px;
+}
+
+.bs-refresh {
+  background: linear-gradient(180deg, #1a4a8c, #0a2855) !important;
+  border: 1px solid #3a8dff !important;
+}
+
+// ===== 三列布局 =====
+.bs-body {
+  flex: 1;
+  display: grid;
+  grid-template-columns: 1fr 1.2fr 1fr;
+  gap: 12px;
+  min-height: 0;
+}
+
+.bs-col {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  min-height: 0;
+}
+
+.bs-panel {
+  flex: 1;
+  background: linear-gradient(180deg, rgba(20, 40, 80, 0.5), rgba(10, 26, 58, 0.5));
+  border: 1px solid rgba(58, 141, 255, 0.25);
+  border-radius: 6px;
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+
+.bs-panel-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #aac8ff;
+  padding-bottom: 8px;
+  border-bottom: 1px solid rgba(58, 141, 255, 0.2);
+  display: flex;
+  align-items: center;
+}
+
+.bs-title-bar {
+  display: inline-block;
+  width: 4px;
+  height: 14px;
+  background: linear-gradient(180deg, #5ad8ff, #3a8dff);
+  margin-right: 8px;
+  border-radius: 2px;
+}
+
+.bs-chart {
+  flex: 1;
+  width: 100%;
+  min-height: 0;
+}
+
+// ===== 卡片区 =====
+.bs-cards {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-template-rows: repeat(3, 1fr);
+  gap: 12px;
+  height: 100%;
+  min-height: 0;
+}
+
+.bs-card {
+  border-radius: 6px;
+  padding: 16px;
+  position: relative;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  border: 1px solid rgba(58, 141, 255, 0.3);
+  background: linear-gradient(135deg, rgba(20, 40, 80, 0.7), rgba(10, 26, 58, 0.5));
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: 3px;
+    background: linear-gradient(90deg, transparent, #5ad8ff, transparent);
+    opacity: 0.6;
+  }
+}
+
+.bs-card-title {
+  font-size: 14px;
+  color: #aac8ff;
+  margin-bottom: 8px;
+}
+
+.bs-card-value {
+  font-size: 32px;
+  font-weight: 700;
+  color: #ffffff;
+  text-shadow: 0 0 12px rgba(90, 216, 255, 0.4);
+  font-family: 'DIN Alternate', 'Microsoft YaHei', monospace;
+}
+
+.bs-card-meta {
+  display: flex;
+  justify-content: space-between;
+  font-size: 12px;
+  margin-top: 8px;
+}
+
+.meta-success {
+  color: #5ad8a6;
+  font-weight: 600;
+}
+
+.meta-danger {
+  color: #ff4d4f;
+  font-weight: 600;
+}
+
+.meta-warning {
+  color: #faad14;
+  font-weight: 600;
+}
+
+.meta-primary {
+  color: #5ad8ff;
+  font-weight: 600;
+}
+
+.meta-default {
+  color: #aac8ff;
+}
+
+// ===== 底部走马灯 =====
+.bs-footer {
+  height: 140px;
+  background: linear-gradient(180deg, rgba(20, 40, 80, 0.5), rgba(10, 26, 58, 0.5));
+  border: 1px solid rgba(58, 141, 255, 0.25);
+  border-radius: 6px;
+  padding: 8px 16px;
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+}
+
+.bs-footer-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #aac8ff;
+  padding-bottom: 6px;
+  border-bottom: 1px solid rgba(58, 141, 255, 0.2);
+  display: flex;
+  align-items: center;
+}
+
+.bs-footer-tag {
+  margin-left: auto;
+  font-size: 12px;
+  color: #5ad8a6;
+  font-weight: 400;
+  border: 1px solid #5ad8a6;
+  padding: 1px 8px;
+  border-radius: 10px;
+}
+
+.bs-stream {
+  flex: 1;
+  min-height: 0;
+  margin-top: 4px;
+}
+
+.bs-stream-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.bs-stream-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 6px 8px;
+  font-size: 13px;
+  color: #e6f0ff;
+  border-radius: 4px;
+  background: rgba(10, 26, 58, 0.4);
+  transition: background 0.3s;
+
+  &:hover {
+    background: rgba(58, 141, 255, 0.15);
+  }
+}
+
+.bs-dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  box-shadow: 0 0 8px currentColor;
+}
+
+.bs-stream-time {
+  color: #aac8ff;
+  font-family: 'DIN Alternate', monospace;
+  font-size: 12px;
+  min-width: 140px;
+}
+
+.bs-stream-level {
+  font-weight: 600;
+  min-width: 60px;
+}
+
+.bs-stream-title {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.bs-stream-store {
+  color: #5ad8a6;
+  font-size: 12px;
+  min-width: 100px;
+}
+
+.bs-stream-empty {
+  text-align: center;
+  color: #aac8ff;
+  padding: 20px;
+  font-size: 13px;
+}
+</style>

+ 601 - 0
src/views/risk/factoryVisit/components/ReportDialog.vue

@@ -0,0 +1,601 @@
+<template>
+  <el-dialog
+    :model-value="visible"
+    title="填写访厂报告"
+    width="1000px"
+    append-to-body
+    :close-on-click-modal="false"
+    :before-close="handleClose"
+    top="3vh"
+    class="factory-report-dialog"
+  >
+    <template v-if="visit">
+      <!-- 顶部基础信息 -->
+      <el-descriptions :column="3" border size="small" class="mb-3">
+        <el-descriptions-item label="访厂ID">{{ visit.visitCode }}</el-descriptions-item>
+        <el-descriptions-item label="公司名称">{{ visit.companyName }}</el-descriptions-item>
+        <el-descriptions-item label="审核部门">{{ visit.auditDept || '-' }}</el-descriptions-item>
+      </el-descriptions>
+
+      <el-tabs v-model="activeTab" type="border-card">
+        <!-- 模块 1:基础信息核查 -->
+        <el-tab-pane label="1.基础信息核查" name="basic">
+          <el-form label-width="280px">
+            <h4 class="section-title">1.1 资质合法</h4>
+            <el-form-item label="营业执照齐全有效">
+              <el-select v-model="form.basicEvaluate.businessLicense" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="食品经营许可证/生产许可证齐全">
+              <el-select v-model="form.basicEvaluate.foodLicense" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="检验检疫证明齐全(生鲜肉/畜/禽)">
+              <el-select v-model="form.basicEvaluate.inspectionCert" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="进口食品许可及检验检疫证明">
+              <el-select v-model="form.basicEvaluate.importCert" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+            <h4 class="section-title">1.2 经营合法</h4>
+            <el-form-item label="生产经营活动及员工合法合规">
+              <el-select v-model="form.basicEvaluate.operationLegal" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="产品无虚假标注(批号/日期)">
+              <el-select v-model="form.basicEvaluate.productLabel" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="存储/运输/分包活动合法合规">
+              <el-select v-model="form.basicEvaluate.logisticsLegal" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+            <h4 class="section-title">1.5 GMP 标准</h4>
+            <el-form-item label="GMP 认证齐全有效,覆盖生产范围">
+              <el-select v-model="form.basicEvaluate.gmpCert" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+            <h4 class="section-title">1.6 三大 ISO 标准</h4>
+            <el-form-item label="ISO9001/14001/45001 证书齐全有效">
+              <el-select v-model="form.basicEvaluate.isoCert" placeholder="请选择" style="width: 160px">
+                <el-option label="是" value="是" />
+                <el-option label="否" value="否" />
+              </el-select>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 模块 2:ESG 审查(总分 30:E12+S12+G6) -->
+        <el-tab-pane label="2.ESG 审查" name="esg">
+          <el-form label-width="380px">
+            <h4 class="section-title">环境 E(12 分)</h4>
+            <el-form-item :label="`碳排放管理(0~${SCORE.esg.carbon})`">
+              <el-input-number v-model="form.esgScore.carbon" :min="0" :max="SCORE.esg.carbon" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`废弃物管理(0~${SCORE.esg.waste})`">
+              <el-input-number v-model="form.esgScore.waste" :min="0" :max="SCORE.esg.waste" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`三废排放(0~${SCORE.esg.emission})`">
+              <el-input-number v-model="form.esgScore.emission" :min="0" :max="SCORE.esg.emission" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`节能降耗(0~${SCORE.esg.energy})`">
+              <el-input-number v-model="form.esgScore.energy" :min="0" :max="SCORE.esg.energy" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <h4 class="section-title">社会 S(12 分)</h4>
+            <el-form-item :label="`员工权益(0~${SCORE.esg.labor})`">
+              <el-input-number v-model="form.esgScore.labor" :min="0" :max="SCORE.esg.labor" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`职业健康(0~${SCORE.esg.health})`">
+              <el-input-number v-model="form.esgScore.health" :min="0" :max="SCORE.esg.health" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`供应链责任(0~${SCORE.esg.supply})`">
+              <el-input-number v-model="form.esgScore.supply" :min="0" :max="SCORE.esg.supply" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`社区责任(0~${SCORE.esg.community})`">
+              <el-input-number v-model="form.esgScore.community" :min="0" :max="SCORE.esg.community" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <h4 class="section-title">治理 G(6 分)</h4>
+            <el-form-item :label="`ESG管理制度(0~${SCORE.esg.management})`">
+              <el-input-number v-model="form.esgScore.management" :min="0" :max="SCORE.esg.management" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`反腐败/反商业贿赂(0~${SCORE.esg.antibribery})`">
+              <el-input-number v-model="form.esgScore.antibribery" :min="0" :max="SCORE.esg.antibribery" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <div class="score-summary">当前 ESG 小计: {{ esgTotal }} / 30</div>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 模块 3:GMP 审查(总分 20) -->
+        <el-tab-pane label="3.GMP 审查" name="gmp">
+          <el-form label-width="380px">
+            <el-form-item :label="`车间布局/洁净区(0~${SCORE.gmp.layout})`">
+              <el-input-number v-model="form.gmpScore.layout" :min="0" :max="SCORE.gmp.layout" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`生产工艺/批记录(0~${SCORE.gmp.process})`">
+              <el-input-number v-model="form.gmpScore.process" :min="0" :max="SCORE.gmp.process" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`成品检验/召回机制(0~${SCORE.gmp.release})`">
+              <el-input-number v-model="form.gmpScore.release" :min="0" :max="SCORE.gmp.release" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`原材料供应商管控(0~${SCORE.gmp.material})`">
+              <el-input-number v-model="form.gmpScore.material" :min="0" :max="SCORE.gmp.material" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`设备清洁/校准/验证(0~${SCORE.gmp.equipment})`">
+              <el-input-number v-model="form.gmpScore.equipment" :min="0" :max="SCORE.gmp.equipment" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <div class="score-summary">当前 GMP 小计: {{ gmpTotal }} / 20</div>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 模块 4:ISO14001(总分 15) -->
+        <el-tab-pane label="4.ISO14001" name="iso14001">
+          <el-form label-width="380px">
+            <el-form-item :label="`环境管理手册/程序文件(0~${SCORE.iso14001.manual})`">
+              <el-input-number v-model="form.iso14001Score.manual" :min="0" :max="SCORE.iso14001.manual" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`环保设施/监测记录(0~${SCORE.iso14001.facility})`">
+              <el-input-number v-model="form.iso14001Score.facility" :min="0" :max="SCORE.iso14001.facility" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`环境因素识别/风险管控(0~${SCORE.iso14001.factor})`">
+              <el-input-number v-model="form.iso14001Score.factor" :min="0" :max="SCORE.iso14001.factor" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`内审/管理评审/持续改进(0~${SCORE.iso14001.review})`">
+              <el-input-number v-model="form.iso14001Score.review" :min="0" :max="SCORE.iso14001.review" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <div class="score-summary">当前 ISO14001 小计: {{ iso14001Total }} / 15</div>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 模块 5:ISO45001(总分 15) -->
+        <el-tab-pane label="5.ISO45001" name="iso45001">
+          <el-form label-width="380px">
+            <el-form-item :label="`职业健康手册/程序文件(0~${SCORE.iso45001.manual})`">
+              <el-input-number v-model="form.iso45001Score.manual" :min="0" :max="SCORE.iso45001.manual" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`安全培训/应急演练(0~${SCORE.iso45001.training})`">
+              <el-input-number v-model="form.iso45001Score.training" :min="0" :max="SCORE.iso45001.training" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`职业危害/防控措施(0~${SCORE.iso45001.hazard})`">
+              <el-input-number v-model="form.iso45001Score.hazard" :min="0" :max="SCORE.iso45001.hazard" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`工伤/职业病记录与整改(0~${SCORE.iso45001.incident})`">
+              <el-input-number v-model="form.iso45001Score.incident" :min="0" :max="SCORE.iso45001.incident" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <div class="score-summary">当前 ISO45001 小计: {{ iso45001Total }} / 15</div>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 模块 6:ISO9001(总分 20) -->
+        <el-tab-pane label="6.ISO9001" name="iso9001">
+          <el-form label-width="380px">
+            <el-form-item :label="`质量管理手册/目标(0~${SCORE.iso9001.manual})`">
+              <el-input-number v-model="form.iso9001Score.manual" :min="0" :max="SCORE.iso9001.manual" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`过程质量管控/记录(0~${SCORE.iso9001.process})`">
+              <el-input-number v-model="form.iso9001Score.process" :min="0" :max="SCORE.iso9001.process" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`内审/客户投诉/改进(0~${SCORE.iso9001.review})`">
+              <el-input-number v-model="form.iso9001Score.review" :min="0" :max="SCORE.iso9001.review" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`供应商评价/采购追溯(0~${SCORE.iso9001.supplier})`">
+              <el-input-number v-model="form.iso9001Score.supplier" :min="0" :max="SCORE.iso9001.supplier" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <el-form-item :label="`设备校准/检测仪器(0~${SCORE.iso9001.equipment})`">
+              <el-input-number v-model="form.iso9001Score.equipment" :min="0" :max="SCORE.iso9001.equipment" :step="1" controls-position="right" />
+              <span class="score-tip">分</span>
+            </el-form-item>
+            <div class="score-summary">当前 ISO9001 小计: {{ iso9001Total }} / 20</div>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 模块 7:致敏原管控 -->
+        <el-tab-pane label="7.致敏原管控" name="allergen">
+          <el-form label-width="320px">
+            <h4 class="section-title">7.1 访问前准备核查</h4>
+            <el-form-item label="提前获取工厂致敏原清单">
+              <el-select v-model="form.allergenEvaluate.prep1.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.prep1.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="访问人员已完成致敏原认知培训">
+              <el-select v-model="form.allergenEvaluate.prep2.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.prep2.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="按特性准备防护装备(N95/防护服)">
+              <el-select v-model="form.allergenEvaluate.prep3.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.prep3.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="访问人员无急性过敏症状">
+              <el-select v-model="form.allergenEvaluate.prep4.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.prep4.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+
+            <h4 class="section-title">7.2 现场区域管控核查</h4>
+            <el-form-item label="致敏原区物理隔离到位">
+              <el-select v-model="form.allergenEvaluate.area1.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.area1.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="致敏原区域有醒目标识">
+              <el-select v-model="form.allergenEvaluate.area2.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.area2.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="区域入口有准入要求提示">
+              <el-select v-model="form.allergenEvaluate.area3.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.area3.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="致敏原仓储区分区存放">
+              <el-select v-model="form.allergenEvaluate.area4.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.area4.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+
+            <h4 class="section-title">7.3 人员行为与防护管控核查</h4>
+            <el-form-item label="穿戴/脱卸防护装备合规">
+              <el-select v-model="form.allergenEvaluate.person1.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.person1.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="禁止跨致敏原/非致敏原区流动">
+              <el-select v-model="form.allergenEvaluate.person2.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.person2.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="防护装备按规定集中处理">
+              <el-select v-model="form.allergenEvaluate.person3.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.person3.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="员工已接受致敏原专项培训">
+              <el-select v-model="form.allergenEvaluate.person4.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.person4.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+
+            <h4 class="section-title">7.4 设备与清洁管控核查</h4>
+            <el-form-item label="致敏原专用设备无混用">
+              <el-select v-model="form.allergenEvaluate.equip1.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.equip1.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="设备清洁流程规范">
+              <el-select v-model="form.allergenEvaluate.equip2.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.equip2.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="清洁验证记录完整可追溯">
+              <el-select v-model="form.allergenEvaluate.equip3.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.equip3.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="清洁工具分区专用">
+              <el-select v-model="form.allergenEvaluate.equip4.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.equip4.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+
+            <h4 class="section-title">7.5 台账与追溯管控核查</h4>
+            <el-form-item label="致敏原台账完整可追溯">
+              <el-select v-model="form.allergenEvaluate.log1.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.log1.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="定期巡检与整改闭环">
+              <el-select v-model="form.allergenEvaluate.log2.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.log2.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+            <el-form-item label="变更(原料/工序)有评估记录">
+              <el-select v-model="form.allergenEvaluate.log3.result" placeholder="请选择" style="width: 160px">
+                <el-option label="合格" value="合格" />
+                <el-option label="不合格" value="不合格" />
+                <el-option label="不适用" value="不适用" />
+              </el-select>
+              <el-input v-model="form.allergenEvaluate.log3.remark" placeholder="问题描述/说明" style="width: 320px; margin-left: 8px" />
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 7.6 问题汇总 -->
+        <el-tab-pane label="7.6 问题汇总" name="summary">
+          <el-form label-width="auto">
+            <el-form-item label="问题描述及整改措施">
+              <el-input
+                v-model="form.summary"
+                type="textarea"
+                :rows="12"
+                placeholder="请填写整体问题描述及整改要求(无预设选项)"
+                maxlength="2000"
+                show-word-limit
+              />
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+      </el-tabs>
+
+      <!-- 总分显示 -->
+      <el-alert
+        class="mt-3"
+        :title="`访查实际得分: ${totalScore} / 100`"
+        type="info"
+        show-icon
+        :closable="false"
+      />
+    </template>
+
+    <template #footer>
+      <el-button @click="handleClose">取 消</el-button>
+      <el-button type="primary" :loading="submitLoading" @click="onSubmit">提 交</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, watch } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { submitFactoryVisitReport } from '@/api/risk/factoryVisit';
+
+const props = defineProps<{
+  visible: boolean;
+  visit: any;
+}>();
+const emit = defineEmits<{
+  'update:visible': [v: boolean];
+  success: [];
+}>();
+
+const activeTab = ref('basic');
+const submitLoading = ref(false);
+
+// 评分上限常量(参考需求文档)
+const SCORE = {
+  esg: { carbon: 3, waste: 3, emission: 3, energy: 3, labor: 3, health: 3, supply: 3, community: 3, management: 3, antibribery: 3 },
+  gmp: { layout: 4, process: 4, release: 4, material: 4, equipment: 4 },
+  iso14001: { manual: 3, facility: 4, factor: 4, review: 4 },
+  iso45001: { manual: 3, training: 4, hazard: 4, incident: 4 },
+  iso9001: { manual: 4, process: 4, review: 4, supplier: 4, equipment: 4 }
+};
+
+// 初始化表单默认值
+const makeEmptyBasic = () => ({
+  businessLicense: '',
+  foodLicense: '',
+  inspectionCert: '',
+  importCert: '',
+  operationLegal: '',
+  productLabel: '',
+  logisticsLegal: '',
+  gmpCert: '',
+  isoCert: ''
+});
+const makeEmptyEsg = () => ({
+  carbon: 0, waste: 0, emission: 0, energy: 0,
+  labor: 0, health: 0, supply: 0, community: 0,
+  management: 0, antibribery: 0
+});
+const makeEmptyGmp = () => ({ layout: 0, process: 0, release: 0, material: 0, equipment: 0 });
+const makeEmptyIso = () => ({ manual: 0, facility: 0, factor: 0, review: 0 });
+const makeEmptyIso2 = () => ({ manual: 0, training: 0, hazard: 0, incident: 0 });
+const makeEmptyIso3 = () => ({ manual: 0, process: 0, review: 0, supplier: 0, equipment: 0 });
+const makeEmptyAllergen = () => ({
+  prep1: { result: '', remark: '' }, prep2: { result: '', remark: '' }, prep3: { result: '', remark: '' }, prep4: { result: '', remark: '' },
+  area1: { result: '', remark: '' }, area2: { result: '', remark: '' }, area3: { result: '', remark: '' }, area4: { result: '', remark: '' },
+  person1: { result: '', remark: '' }, person2: { result: '', remark: '' }, person3: { result: '', remark: '' }, person4: { result: '', remark: '' },
+  equip1: { result: '', remark: '' }, equip2: { result: '', remark: '' }, equip3: { result: '', remark: '' }, equip4: { result: '', remark: '' },
+  log1: { result: '', remark: '' }, log2: { result: '', remark: '' }, log3: { result: '', remark: '' }
+});
+
+const form = reactive({
+  basicEvaluate: makeEmptyBasic(),
+  esgScore: makeEmptyEsg(),
+  gmpScore: makeEmptyGmp(),
+  iso14001Score: makeEmptyIso(),
+  iso45001Score: makeEmptyIso2(),
+  iso9001Score: makeEmptyIso3(),
+  allergenEvaluate: makeEmptyAllergen(),
+  summary: ''
+});
+
+// 监听 visit 变化:加载已有报告数据(仅在有 report 时)
+watch(
+  () => props.visit,
+  (v) => {
+    if (!v) return;
+    const r = v.report;
+    if (r) {
+      // 已有报告数据,合并
+      Object.assign(form.basicEvaluate, r.basicEvaluate || {});
+      Object.assign(form.esgScore, r.esgScore || {});
+      Object.assign(form.gmpScore, r.gmpScore || {});
+      Object.assign(form.iso14001Score, r.iso14001Score || {});
+      Object.assign(form.iso45001Score, r.iso45001Score || {});
+      Object.assign(form.iso9001Score, r.iso9001Score || {});
+      if (r.allergenEvaluate) {
+        for (const k of Object.keys(r.allergenEvaluate)) {
+          if (form.allergenEvaluate[k]) {
+            form.allergenEvaluate[k] = { ...form.allergenEvaluate[k], ...(r.allergenEvaluate[k] || {}) };
+          }
+        }
+      }
+      form.summary = r.summary || '';
+    }
+  },
+  { immediate: true }
+);
+
+// 计算各模块小计
+const sumObj = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + (Number(b) || 0), 0);
+const esgTotal = computed(() => sumObj(form.esgScore));
+const gmpTotal = computed(() => sumObj(form.gmpScore));
+const iso14001Total = computed(() => sumObj(form.iso14001Score));
+const iso45001Total = computed(() => sumObj(form.iso45001Score));
+const iso9001Total = computed(() => sumObj(form.iso9001Score));
+const totalScore = computed(
+  () => esgTotal.value + gmpTotal.value + iso14001Total.value + iso45001Total.value + iso9001Total.value
+);
+
+const handleClose = async () => {
+  try {
+    await ElMessageBox.confirm('报告尚未提交,确认关闭?', '提示', { type: 'warning' });
+    emit('update:visible', false);
+  } catch {
+    // 用户取消
+  }
+};
+
+const onSubmit = async () => {
+  submitLoading.value = true;
+  try {
+    const payload = {
+      visitId: props.visit.id,
+      basicEvaluate: form.basicEvaluate,
+      esgScore: form.esgScore,
+      gmpScore: form.gmpScore,
+      iso14001Score: form.iso14001Score,
+      iso45001Score: form.iso45001Score,
+      iso9001Score: form.iso9001Score,
+      allergenEvaluate: form.allergenEvaluate,
+      summary: form.summary,
+      totalScore: totalScore.value,
+      visitDate: props.visit.visitDate
+    };
+    const res: any = await submitFactoryVisitReport(payload);
+    ElMessage.success(`报告提交成功,得分 ${totalScore.value} 分,已转待审核`);
+    emit('update:visible', false);
+    emit('success');
+  } finally {
+    submitLoading.value = false;
+  }
+};
+</script>
+
+<style scoped lang="scss">
+.factory-report-dialog :deep(.el-dialog__body) {
+  max-height: 75vh;
+  overflow-y: auto;
+}
+.section-title {
+  font-weight: 600;
+  font-size: 14px;
+  color: #303133;
+  border-left: 3px solid #409eff;
+  padding-left: 10px;
+  margin: 16px 0 12px 0;
+}
+.score-tip {
+  margin-left: 8px;
+  color: #909399;
+}
+.score-summary {
+  margin-top: 16px;
+  padding: 8px 16px;
+  background: #ecf5ff;
+  border-radius: 4px;
+  color: #409eff;
+  font-weight: 600;
+  text-align: right;
+}
+</style>

+ 774 - 0
src/views/risk/factoryVisit/index.vue

@@ -0,0 +1,774 @@
+<template>
+  <div class="p-2 factory-visit-page">
+    <!-- 搜索区域 -->
+    <transition
+      :enter-active-class="proxy?.animate.searchAnimate.enter"
+      :leave-active-class="proxy?.animate.searchAnimate.leave"
+    >
+      <div v-show="showSearch" class="mb-[10px]">
+        <el-card shadow="hover">
+          <el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="auto">
+            <el-form-item label="访厂ID" prop="visitCode">
+              <el-input
+                v-model="queryParams.visitCode"
+                placeholder="请输入访厂ID"
+                clearable
+                style="width: 180px"
+                @keyup.enter="handleQuery"
+              />
+            </el-form-item>
+            <el-form-item label="公司名称" prop="companyName">
+              <el-input
+                v-model="queryParams.companyName"
+                placeholder="请输入公司名称"
+                clearable
+                style="width: 180px"
+                @keyup.enter="handleQuery"
+              />
+            </el-form-item>
+            <el-form-item label="状态" prop="status">
+              <el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 160px">
+                <el-option
+                  v-for="dict in visit_status_dict"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="审核结论" prop="auditResult">
+              <el-select
+                v-model="queryParams.auditResult"
+                placeholder="全部"
+                clearable
+                style="width: 160px"
+              >
+                <el-option
+                  v-for="dict in visit_audit_result_dict"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="访厂日期" prop="dateRange">
+              <el-date-picker
+                v-model="dateRange"
+                value-format="YYYY-MM-DD"
+                type="daterange"
+                range-separator="-"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                style="width: 240px"
+              />
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" icon="Search" @click="handleQuery">查询</el-button>
+              <el-button icon="Refresh" @click="resetQuery">重置</el-button>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+    </transition>
+
+    <!-- 数据表格区域 -->
+    <el-card shadow="hover">
+      <template #header>
+        <el-row :gutter="10" class="mb-2">
+          <el-col :span="1.5">
+            <el-button
+              v-hasPermi="['risk:factoryVisit:add']"
+              type="primary"
+              plain
+              icon="Plus"
+              @click="handleAdd"
+              >新增访厂任务</el-button
+            >
+          </el-col>
+          <el-col :span="1.5">
+            <el-button
+              v-hasPermi="['risk:factoryVisit:export']"
+              type="warning"
+              plain
+              icon="Download"
+              @click="handleExport"
+              >导出</el-button
+            >
+          </el-col>
+          <right-toolbar v-model:show-search="showSearch" @query-table="getList" />
+        </el-row>
+      </template>
+
+      <el-table v-loading="loading" :data="visitList" border>
+        <el-table-column label="访厂ID" align="center" prop="visitCode" width="150" />
+        <el-table-column
+          label="公司名称"
+          align="center"
+          prop="companyName"
+          :show-overflow-tooltip="true"
+        />
+        <el-table-column
+          label="地址"
+          align="center"
+          prop="companyAddress"
+          width="180"
+          :show-overflow-tooltip="true"
+        />
+        <el-table-column label="访厂日期" align="center" prop="visitDate" width="120">
+          <template #default="scope">
+            <span>{{ parseDate(scope.row.visitDate) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="访查实际得分" align="center" prop="totalScore" width="120">
+          <template #default="scope">
+            <span v-if="scope.row.totalScore != null">{{ scope.row.totalScore }}</span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="审核结论" align="center" prop="auditResult" width="100">
+          <template #default="scope">
+            <el-tag v-if="scope.row.auditResult" :type="getAuditResultTag(scope.row.auditResult)">{{
+              scope.row.auditResult
+            }}</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="访查人" align="center" prop="visitorName" width="100" />
+        <el-table-column
+          label="审核产品范围"
+          align="center"
+          prop="productScope"
+          width="160"
+          :show-overflow-tooltip="true"
+        />
+        <el-table-column label="审核部门" align="center" prop="auditDept" width="120" />
+        <el-table-column label="评审人" align="center" prop="reviewerName" width="100" />
+        <el-table-column label="状态" align="center" prop="status" width="100">
+          <template #default="scope">
+            <el-tag :type="getStatusTag(scope.row.status)">{{ scope.row.status }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="操作"
+          fixed="right"
+          align="center"
+          width="240"
+          class-name="small-padding fixed-width"
+        >
+          <template #default="scope">
+            <el-button
+              v-hasPermi="['risk:factoryVisit:list']"
+              link
+              type="primary"
+              icon="Document"
+              @click="handleView(scope.row)"
+              >查看详情</el-button
+            >
+            <el-button
+              v-if="scope.row.status === '待访厂'"
+              v-hasPermi="['risk:factoryVisit:report']"
+              link
+              type="primary"
+              icon="EditPen"
+              @click="handleFillReport(scope.row)"
+              >填写报告</el-button
+            >
+            <el-button
+              v-if="scope.row.status === '待审核'"
+              v-hasPermi="['risk:factoryVisit:audit']"
+              link
+              type="primary"
+              icon="CircleCheck"
+              @click="handleAudit(scope.row)"
+              >审核</el-button
+            >
+            <el-button
+              v-if="scope.row.status === '待访厂'"
+              v-hasPermi="['risk:factoryVisit:edit']"
+              link
+              type="primary"
+              icon="Edit"
+              @click="handleUpdate(scope.row)"
+              >编辑</el-button
+            >
+            <el-button
+              v-if="scope.row.status === '待访厂'"
+              v-hasPermi="['risk:factoryVisit:remove']"
+              link
+              type="danger"
+              icon="Delete"
+              @click="handleDelete(scope.row)"
+              >删除</el-button
+            >
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination
+        v-show="total > 0"
+        v-model:page="queryParams.pageNum"
+        v-model:limit="queryParams.pageSize"
+        :total="total"
+        @pagination="getList"
+      />
+    </el-card>
+
+    <!-- 新增/编辑对话框 -->
+    <el-dialog
+      v-model="dialog.visible"
+      :title="dialog.title"
+      width="780px"
+      append-to-body
+      @close="closeDialog"
+    >
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="公司名称" prop="companyName">
+              <el-input v-model="form.companyName" placeholder="请输入被访厂商名称" maxlength="200" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="审核部门" prop="auditDept">
+              <el-input v-model="form.auditDept" placeholder="请输入审核部门" maxlength="100" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="24">
+            <el-form-item label="厂商地址" prop="companyAddress">
+              <el-input
+                v-model="form.companyAddress"
+                placeholder="请输入厂商地址"
+                maxlength="500"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="访厂日期" prop="visitDate">
+              <el-date-picker
+                v-model="form.visitDate"
+                type="date"
+                value-format="YYYY-MM-DD"
+                placeholder="请选择访厂日期"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="访查人" prop="visitorName">
+              <el-input
+                v-model="form.visitorName"
+                placeholder="请输入访查人"
+                maxlength="50"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="评审人" prop="reviewerName">
+              <el-input
+                v-model="form.reviewerName"
+                placeholder="请输入评审人"
+                maxlength="50"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="审核产品范围" prop="productScope">
+              <el-input
+                v-model="form.productScope"
+                placeholder="如:烘焙类、冷链食品等"
+                maxlength="200"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="备注" prop="remark">
+          <el-input
+            v-model="form.remark"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入备注"
+            maxlength="1000"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="cancel">取 消</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="submitForm">确 定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 查看详情对话框 -->
+    <el-dialog v-model="viewDialog.visible" title="访厂详情" width="900px" append-to-body>
+      <el-descriptions v-if="viewDialog.data" :column="2" border>
+        <el-descriptions-item label="访厂ID">{{
+          viewDialog.data.visitCode
+        }}</el-descriptions-item>
+        <el-descriptions-item label="状态">
+          <el-tag :type="getStatusTag(viewDialog.data.status)">{{ viewDialog.data.status }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="公司名称">{{
+          viewDialog.data.companyName
+        }}</el-descriptions-item>
+        <el-descriptions-item label="审核部门">{{
+          viewDialog.data.auditDept || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="地址" :span="2">{{
+          viewDialog.data.companyAddress || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="访厂日期">{{
+          parseDate(viewDialog.data.visitDate)
+        }}</el-descriptions-item>
+        <el-descriptions-item label="访查人">{{
+          viewDialog.data.visitorName || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="评审人">{{
+          viewDialog.data.reviewerName || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="审核产品范围">{{
+          viewDialog.data.productScope || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="访查实际得分">{{
+          viewDialog.data.totalScore ?? '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="审核结论">
+          <el-tag
+            v-if="viewDialog.data.auditResult"
+            :type="getAuditResultTag(viewDialog.data.auditResult)"
+            >{{ viewDialog.data.auditResult }}</el-tag
+          >
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="审核人">{{
+          viewDialog.data.auditorName || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="审核时间">{{
+          parseDateTime(viewDialog.data.auditTime)
+        }}</el-descriptions-item>
+        <el-descriptions-item label="审核意见" :span="2">{{
+          viewDialog.data.auditRemark || '-'
+        }}</el-descriptions-item>
+        <el-descriptions-item label="备注" :span="2">{{
+          viewDialog.data.remark || '-'
+        }}</el-descriptions-item>
+      </el-descriptions>
+
+      <!-- 报告子表(已审核时也可查看) -->
+      <div v-if="viewDialog.data?.report" class="mt-4">
+        <el-divider content-position="left">访厂报告</el-divider>
+        <el-tabs v-model="activeReportTab" type="border-card">
+          <el-tab-pane label="基础信息" name="basic">
+            <pre class="report-json">{{
+              formatJson(viewDialog.data.report.basicEvaluate)
+            }}</pre>
+          </el-tab-pane>
+          <el-tab-pane label="ESG 评分" name="esg">
+            <pre class="report-json">{{ formatJson(viewDialog.data.report.esgScore) }}</pre>
+          </el-tab-pane>
+          <el-tab-pane label="GMP 评分" name="gmp">
+            <pre class="report-json">{{ formatJson(viewDialog.data.report.gmpScore) }}</pre>
+          </el-tab-pane>
+          <el-tab-pane label="ISO14001" name="iso14001">
+            <pre class="report-json">{{
+              formatJson(viewDialog.data.report.iso14001Score)
+            }}</pre>
+          </el-tab-pane>
+          <el-tab-pane label="ISO45001" name="iso45001">
+            <pre class="report-json">{{
+              formatJson(viewDialog.data.report.iso45001Score)
+            }}</pre>
+          </el-tab-pane>
+          <el-tab-pane label="ISO9001" name="iso9001">
+            <pre class="report-json">{{
+              formatJson(viewDialog.data.report.iso9001Score)
+            }}</pre>
+          </el-tab-pane>
+          <el-tab-pane label="致敏原管控" name="allergen">
+            <pre class="report-json">{{
+              formatJson(viewDialog.data.report.allergenEvaluate)
+            }}</pre>
+          </el-tab-pane>
+          <el-tab-pane label="问题汇总" name="summary">
+            <div class="report-summary">{{ viewDialog.data.report.summary || '(空)' }}</div>
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+
+      <template #footer>
+        <el-button @click="viewDialog.visible = false">关 闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 填写报告弹框 -->
+    <ReportDialog
+      v-if="reportDialog.visible"
+      v-model:visible="reportDialog.visible"
+      :visit="reportDialog.data"
+      @success="onReportSuccess"
+    />
+
+    <!-- 审核弹框 -->
+    <el-dialog
+      v-model="auditDialog.visible"
+      title="审核访厂任务"
+      width="560px"
+      append-to-body
+      @close="closeAuditDialog"
+    >
+      <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="100px">
+        <el-form-item label="访厂ID">
+          <span>{{ auditDialog.data?.visitCode }}</span>
+        </el-form-item>
+        <el-form-item label="公司名称">
+          <span>{{ auditDialog.data?.companyName }}</span>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-tag :type="getStatusTag(auditDialog.data?.status)">{{
+            auditDialog.data?.status
+          }}</el-tag>
+        </el-form-item>
+        <el-form-item label="访查得分">
+          <span>{{ auditDialog.data?.totalScore ?? '-' }}</span>
+        </el-form-item>
+        <el-form-item label="审核结论" prop="auditResult">
+          <el-select
+            v-model="auditForm.auditResult"
+            placeholder="请选择审核结论"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="dict in visit_audit_result_dict"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="审核意见" prop="auditRemark">
+          <el-input
+            v-model="auditForm.auditRemark"
+            type="textarea"
+            :rows="4"
+            placeholder="请输入审核意见"
+            maxlength="1000"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="auditDialog.visible = false">取 消</el-button>
+        <el-button type="primary" :loading="auditLoading" @click="submitAudit">确 定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="FactoryVisit">
+import { ref, reactive, computed, getCurrentInstance, watch } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import {
+  listFactoryVisit,
+  getFactoryVisit,
+  addFactoryVisit,
+  updateFactoryVisit,
+  delFactoryVisit,
+  exportFactoryVisit,
+  auditFactoryVisit
+} from '@/api/risk/factoryVisit';
+import ReportDialog from './components/ReportDialog.vue';
+
+const { proxy } = getCurrentInstance() as any;
+
+// 字典
+const { visit_status_dict, visit_audit_result_dict } = proxy.useDict(
+  'visit_status',
+  'visit_audit_result'
+);
+
+const visitList = ref<any[]>([]);
+const loading = ref(false);
+const submitLoading = ref(false);
+const auditLoading = ref(false);
+const total = ref(0);
+const showSearch = ref(true);
+const dateRange = ref<[string, string] | null>(null);
+const activeReportTab = ref('basic');
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  visitCode: undefined,
+  companyName: undefined,
+  status: undefined,
+  auditResult: undefined,
+  params: {}
+});
+
+const initialForm = {
+  id: undefined,
+  visitCode: undefined,
+  companyName: undefined,
+  companyAddress: undefined,
+  visitDate: undefined,
+  visitorName: undefined,
+  auditDept: undefined,
+  productScope: undefined,
+  reviewerName: undefined,
+  remark: undefined
+};
+const form = reactive({ ...initialForm });
+const rules = {
+  companyName: [{ required: true, message: '公司名称不能为空', trigger: 'blur' }]
+};
+
+const dialog = reactive({
+  visible: false,
+  title: ''
+});
+
+const viewDialog = reactive({
+  visible: false,
+  data: null as any
+});
+
+const reportDialog = reactive({
+  visible: false,
+  data: null as any
+});
+
+const auditDialog = reactive({
+  visible: false,
+  data: null as any
+});
+
+const initialAuditForm = {
+  id: undefined,
+  auditResult: undefined,
+  auditRemark: undefined
+};
+const auditForm = reactive({ ...initialAuditForm });
+const auditRules = {
+  auditResult: [{ required: true, message: '请选择审核结论', trigger: 'change' }],
+  auditRemark: [{ required: true, message: '请填写审核意见', trigger: 'blur' }]
+};
+
+watch(dateRange, (val) => {
+  if (val && val.length === 2) {
+    queryParams.params = { ...(queryParams.params || {}), beginVisitDate: val[0], endVisitDate: val[1] };
+  } else {
+    queryParams.params = { ...(queryParams.params || {}) };
+    delete (queryParams.params as any).beginVisitDate;
+    delete (queryParams.params as any).endVisitDate;
+  }
+});
+
+// ============ 列表 ============
+
+const getList = async () => {
+  loading.value = true;
+  try {
+    const res: any = await listFactoryVisit(queryParams);
+    visitList.value = res.rows || [];
+    total.value = res.total || 0;
+  } finally {
+    loading.value = false;
+  }
+};
+
+const handleQuery = () => {
+  queryParams.pageNum = 1;
+  getList();
+};
+
+const resetQuery = () => {
+  Object.assign(queryParams, {
+    pageNum: 1,
+    pageSize: 10,
+    visitCode: undefined,
+    companyName: undefined,
+    status: undefined,
+    auditResult: undefined,
+    params: {}
+  });
+  dateRange.value = null;
+  getList();
+};
+
+// ============ 新增/编辑 ============
+
+const handleAdd = () => {
+  Object.assign(form, initialForm);
+  dialog.title = '新增访厂任务';
+  dialog.visible = true;
+};
+
+const handleUpdate = async (row: any) => {
+  Object.assign(form, initialForm);
+  const res: any = await getFactoryVisit(row.id);
+  Object.assign(form, res.data || res);
+  dialog.title = '编辑访厂任务';
+  dialog.visible = true;
+};
+
+const submitForm = async () => {
+  const formRef = proxy.$refs.formRef;
+  await formRef.validate();
+  submitLoading.value = true;
+  try {
+    if (form.id) {
+      await updateFactoryVisit(form);
+      ElMessage.success('修改成功');
+    } else {
+      await addFactoryVisit(form);
+      ElMessage.success('新增成功');
+    }
+    dialog.visible = false;
+    getList();
+  } finally {
+    submitLoading.value = false;
+  }
+};
+
+const cancel = () => {
+  dialog.visible = false;
+  Object.assign(form, initialForm);
+};
+
+const closeDialog = () => {
+  Object.assign(form, initialForm);
+};
+
+const handleDelete = async (row: any) => {
+  await ElMessageBox.confirm(`确认删除访厂任务 "${row.visitCode}" ?`, '提示', {
+    type: 'warning'
+  });
+  await delFactoryVisit(row.id);
+  ElMessage.success('删除成功');
+  getList();
+};
+
+// ============ 查看详情 ============
+
+const handleView = async (row: any) => {
+  const res: any = await getFactoryVisit(row.id);
+  viewDialog.data = res.data || res;
+  viewDialog.visible = true;
+};
+
+// ============ 填写报告 ============
+
+const handleFillReport = async (row: any) => {
+  const res: any = await getFactoryVisit(row.id);
+  reportDialog.data = res.data || res;
+  reportDialog.visible = true;
+};
+
+const onReportSuccess = () => {
+  reportDialog.visible = false;
+  getList();
+};
+
+// ============ 审核 ============
+
+const handleAudit = (row: any) => {
+  Object.assign(auditForm, initialAuditForm);
+  auditDialog.data = row;
+  auditDialog.visible = true;
+};
+
+const submitAudit = async () => {
+  const ref: any = proxy.$refs.auditFormRef;
+  await ref.validate();
+  auditLoading.value = true;
+  try {
+    await auditFactoryVisit({
+      id: auditDialog.data.id,
+      auditResult: auditForm.auditResult,
+      auditRemark: auditForm.auditRemark
+    });
+    ElMessage.success('审核成功');
+    auditDialog.visible = false;
+    getList();
+  } finally {
+    auditLoading.value = false;
+  }
+};
+
+const closeAuditDialog = () => {
+  Object.assign(auditForm, initialAuditForm);
+};
+
+// ============ 导出 ============
+
+const handleExport = async () => {
+  const res: any = await exportFactoryVisit(queryParams);
+  proxy.download(res);
+};
+
+// ============ 辅助 ============
+
+const getAuditResultTag = (result?: string): 'success' | 'danger' | 'info' => {
+  if (result === '通过') return 'success';
+  if (result === '不通过') return 'danger';
+  if (result === '待定') return 'info';
+  return 'info';
+};
+
+const getStatusTag = (status?: string): 'warning' | 'primary' | 'success' | 'info' => {
+  if (status === '待访厂') return 'warning';
+  if (status === '待审核') return 'primary';
+  if (status === '已审核') return 'success';
+  return 'info';
+};
+
+const parseDate = (d?: string | Date) => {
+  if (!d) return '-';
+  const s = typeof d === 'string' ? d : d.toISOString();
+  return s.substring(0, 10);
+};
+
+const parseDateTime = (d?: string | Date) => {
+  if (!d) return '-';
+  const s = typeof d === 'string' ? d : d.toISOString();
+  return s.substring(0, 19).replace('T', ' ');
+};
+
+const formatJson = (data: any) => {
+  if (!data) return '(空)';
+  try {
+    return JSON.stringify(data, null, 2);
+  } catch {
+    return String(data);
+  }
+};
+
+getList();
+</script>
+
+<style scoped lang="scss">
+.factory-visit-page {
+  .report-json {
+    background: #f5f7fa;
+    padding: 12px;
+    border-radius: 4px;
+    font-size: 13px;
+    line-height: 1.6;
+    max-height: 400px;
+    overflow: auto;
+    white-space: pre-wrap;
+    word-break: break-all;
+  }
+  .report-summary {
+    background: #f5f7fa;
+    padding: 16px;
+    border-radius: 4px;
+    line-height: 1.8;
+    min-height: 100px;
+    white-space: pre-wrap;
+  }
+}
+</style>

+ 1081 - 0
src/views/risk/foodSafety/index.vue

@@ -0,0 +1,1081 @@
+<template>
+  <div class="p-2 food-safety-page">
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <h2 class="page-title">食品安全宣传</h2>
+      <p class="page-desc">开展各类食品安全宣传活动并归集宣传资料</p>
+    </div>
+
+    <!-- ① 顶部数据看板 -->
+    <el-row :gutter="20" class="mb-2">
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">总宣传活动</div>
+              <div class="stat-value">{{ stats.totalCount || 0 }}</div>
+              <div class="stat-extra stat-extra-up" v-if="stats.totalRate !== undefined">
+                <el-icon v-if="stats.totalRate >= 0"><CaretTop /></el-icon>
+                <el-icon v-else><CaretBottom /></el-icon>
+                <span>{{ Math.abs(stats.totalRate) }}% 较上月</span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-blue">
+              <el-icon><Calendar /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">参与人次</div>
+              <div class="stat-value">{{ stats.participantsTotal || 0 }}</div>
+              <div class="stat-extra stat-extra-up">
+                <span>累计参与人数</span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-green">
+              <el-icon><User /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">宣传资料数量</div>
+              <div class="stat-value">{{ stats.materialTotal || 0 }}</div>
+              <div class="stat-extra stat-extra-up">
+                <span>全部上传资料</span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-purple">
+              <el-icon><Files /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">即将进行</div>
+              <div class="stat-value">{{ stats.notStartedCount || 0 }}</div>
+              <div class="stat-extra stat-extra-warning">
+                <span>未开始状态活动</span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-orange">
+              <el-icon><Bell /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- ② 宣传活动列表 -->
+    <el-card shadow="hover">
+      <template #header>
+        <div class="section-header">
+          <span class="section-title">宣传活动列表</span>
+          <el-button type="primary" :icon="Plus" @click="handleAdd">新增活动</el-button>
+        </div>
+      </template>
+
+      <!-- 筛选 -->
+      <el-form ref="queryFormRef" :model="queryParams" :inline="true" class="filter-bar">
+        <el-form-item label="活动名称">
+          <el-input
+            v-model="queryParams.keyword"
+            placeholder="搜索活动名称/编号/负责人"
+            clearable
+            style="width: 240px"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="活动类型">
+          <el-select
+            v-model="queryParams.campaignType"
+            placeholder="全部类型"
+            clearable
+            style="width: 200px"
+            @change="handleQuery"
+          >
+            <el-option label="全部类型" value="" />
+            <el-option
+              v-for="d in campaignTypeDict"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select
+            v-model="queryParams.status"
+            placeholder="全部状态"
+            clearable
+            style="width: 160px"
+            @change="handleQuery"
+          >
+            <el-option label="全部状态" value="" />
+            <el-option
+              v-for="d in campaignStatusDict"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="resetQuery">重置</el-button>
+          <el-button type="primary" @click="handleQuery">搜索</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table v-loading="loading" :data="campaignList" border stripe>
+        <el-table-column label="活动编号" align="center" prop="campaignCode" width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="活动名称" align="center" prop="campaignName" min-width="180" :show-overflow-tooltip="true" />
+        <el-table-column label="活动类型" align="center" prop="campaignType" width="200">
+          <template #default="scope">
+            {{ getDictLabel(campaignTypeDict, scope.row.campaignType) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="开始时间" align="center" prop="startTime" width="170">
+          <template #default="scope">{{ formatDateTime(scope.row.startTime) }}</template>
+        </el-table-column>
+        <el-table-column label="结束时间" align="center" prop="endTime" width="170">
+          <template #default="scope">{{ formatDateTime(scope.row.endTime) }}</template>
+        </el-table-column>
+        <el-table-column label="参与人数" align="center" width="160">
+          <template #default="scope">
+            <span>实际 {{ scope.row.actualParticipants || 0 }} / 预计 {{ scope.row.expectedParticipants || 0 }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="负责人" align="center" prop="principalName" width="100" />
+        <el-table-column label="状态" align="center" width="100">
+          <template #default="scope">
+            <el-tag :type="getStatusTag(scope.row.status)">{{ scope.row.status || '-' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" align="center" width="320" fixed="right" class-name="small-padding fixed-width">
+          <template #default="scope">
+            <el-button link type="primary" @click="handleUploadMaterial(scope.row)">上传资料</el-button>
+            <el-button link type="primary" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button link type="success" v-if="scope.row.status === '未开始'" @click="handleStart(scope.row)">活动开始</el-button>
+            <el-button link type="warning" v-if="scope.row.status === '进行中'" @click="openFinishDialog(scope.row)">活动结束</el-button>
+            <el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination
+        v-show="total > 0"
+        v-model:page="queryParams.pageNum"
+        v-model:limit="queryParams.pageSize"
+        :total="total"
+        @pagination="getList"
+      />
+    </el-card>
+
+    <!-- ③ 新增/编辑活动弹窗 -->
+    <el-dialog
+      v-model="addDialog.visible"
+      :title="addDialog.title"
+      width="720px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="closeAddDialog"
+    >
+      <el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
+        <el-form-item label="活动名称" prop="campaignName">
+          <el-input v-model="addForm.campaignName" placeholder="请输入活动名称" maxlength="200" show-word-limit />
+        </el-form-item>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="活动类型" prop="campaignType">
+              <el-select v-model="addForm.campaignType" placeholder="请选择活动类型" style="width: 100%">
+                <el-option
+                  v-for="d in campaignTypeDict"
+                  :key="d.value"
+                  :label="d.label"
+                  :value="d.value"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="负责人" prop="principalName">
+              <el-input v-model="addForm.principalName" placeholder="请输入负责人姓名" maxlength="50" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="开始时间" prop="startTime">
+              <el-date-picker
+                v-model="addForm.startTime"
+                type="datetime"
+                placeholder="请选择开始时间"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                format="YYYY-MM-DD HH:mm"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="结束时间" prop="endTime">
+              <el-date-picker
+                v-model="addForm.endTime"
+                type="datetime"
+                placeholder="请选择结束时间"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                format="YYYY-MM-DD HH:mm"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="活动地点" prop="location">
+              <el-input v-model="addForm.location" placeholder="请输入活动地点" maxlength="200" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="预计参与人数" prop="expectedParticipants">
+              <el-input-number
+                v-model="addForm.expectedParticipants"
+                :min="0"
+                :max="99999"
+                placeholder="请输入预计参与人数"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="活动描述" prop="description">
+          <el-input
+            v-model="addForm.description"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入活动描述"
+            maxlength="2000"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="addForm.remark" placeholder="备注(选填)" maxlength="500" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="addDialog.visible = false">取 消</el-button>
+          <el-button type="primary" @click="submitAddForm">保 存</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- ④ 活动结束弹窗(录入活动总结) -->
+    <el-dialog
+      v-model="finishDialog.visible"
+      title="活动结束"
+      width="560px"
+      append-to-body
+      :close-on-click-modal="false"
+    >
+      <el-form ref="finishFormRef" :model="finishForm" :rules="finishRules" label-width="100px">
+        <el-form-item label="活动编号">
+          <span class="readonly-text">{{ finishForm.campaignCode }}</span>
+        </el-form-item>
+        <el-form-item label="活动名称">
+          <span class="readonly-text">{{ finishForm.campaignName }}</span>
+        </el-form-item>
+        <el-form-item label="实际参与人数" prop="actualParticipants">
+          <el-input-number
+            v-model="finishForm.actualParticipants"
+            :min="0"
+            :max="99999"
+            placeholder="请输入实际参与人数"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="活动总结" prop="summary">
+          <el-input
+            v-model="finishForm.summary"
+            type="textarea"
+            :rows="5"
+            placeholder="请输入活动总结(必填)"
+            maxlength="2000"
+            show-word-limit
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="finishDialog.visible = false">取 消</el-button>
+          <el-button type="primary" @click="submitFinishForm">确 定 结束</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- ⑤ 上传资料弹窗 -->
+    <el-dialog
+      v-model="materialDialog.visible"
+      title="上传宣传资料"
+      width="680px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="closeMaterialDialog"
+    >
+      <el-form ref="materialFormRef" :model="materialForm" :rules="materialRules" label-width="100px">
+        <el-form-item label="关联活动">
+          <span class="readonly-text">{{ currentCampaign.campaignName || '—' }} <span class="muted">({{ currentCampaign.campaignCode || '无编号' }})</span></span>
+        </el-form-item>
+        <el-form-item label="资料名称" prop="materialName">
+          <el-input v-model="materialForm.materialName" placeholder="请输入资料名称" maxlength="200" show-word-limit />
+        </el-form-item>
+        <el-form-item label="资料类型" prop="materialType">
+          <el-select v-model="materialForm.materialType" placeholder="请选择资料类型" style="width: 100%">
+            <el-option
+              v-for="d in materialTypeDict"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="资料概述" prop="materialSummary">
+          <el-input
+            v-model="materialForm.materialSummary"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入资料概述"
+            maxlength="1000"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="资料封面">
+          <el-upload
+            v-model:file-list="coverFileList"
+            :http-request="customUpload"
+            list-type="picture-card"
+            :limit="1"
+            accept=".jpg,.png,.jpeg"
+            :before-upload="beforeCoverUpload"
+            :on-success="handleCoverSuccess"
+            :on-error="handleUploadError"
+            :on-exceed="handleCoverExceed"
+            :on-remove="handleCoverRemove"
+          >
+            <template #default>+</template>
+          </el-upload>
+          <div class="el-upload__tip">支持 JPG/PNG/JPEG 格式,单个文件不超过 5MB</div>
+        </el-form-item>
+        <el-form-item label="资料附件" prop="attachmentUrl">
+          <el-upload
+            v-model:file-list="attachmentFileList"
+            :http-request="customUpload"
+            :limit="1"
+            :accept="'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.mp4,.mp3'"
+            :before-upload="beforeAttachmentUpload"
+            :on-success="handleAttachmentSuccess"
+            :on-error="handleUploadError"
+            :on-exceed="handleAttachmentExceed"
+            :on-remove="handleAttachmentRemove"
+          >
+            <template #trigger>
+              <div class="upload-trigger-area">
+                <div class="upload-action-row">
+                  <el-button type="primary" plain>选择附件</el-button>
+                  <span class="upload-placeholder">{{ attachmentFileList.length > 0 ? `已选择 ${attachmentFileList.length} 个附件` : '未选择任何附件' }}</span>
+                </div>
+                <div class="upload-tip-box">
+                  <div class="upload-tip-icon">
+                    <el-icon><UploadFilled /></el-icon>
+                  </div>
+                  <div class="upload-tip-text">点击上传或拖拽文件到此区域</div>
+                  <div class="upload-tip-desc">支持 PDF/Word/Excel/PPT/图片/音视频,单个文件不超过 50MB</div>
+                </div>
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="materialDialog.visible = false">取 消</el-button>
+          <el-button type="primary" @click="submitMaterialForm">上 传</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup name="FoodSafety" lang="ts">
+import { ref, reactive, toRefs, onMounted, getCurrentInstance } from 'vue';
+import {
+  listFoodSafetyCampaign,
+  getFoodSafetyCampaign,
+  addFoodSafetyCampaign,
+  updateFoodSafetyCampaign,
+  delFoodSafetyCampaign,
+  startFoodSafetyCampaign,
+  finishFoodSafetyCampaign,
+  getFoodSafetyCampaignStats
+} from '@/api/risk/foodSafetyCampaign';
+import { addFoodSafetyMaterial } from '@/api/risk/foodSafetyMaterial';
+import { uploadFile } from '@/api/risk/file';
+import { getDicts } from '@/api/system/dict/data';
+import type { DictDataVO } from '@/api/system/dict/data/types';
+import type {
+  RiskFoodSafetyCampaignBo,
+  RiskFoodSafetyCampaignVo,
+  FoodSafetyCampaignStatsVo,
+  RiskFoodSafetyMaterialBo
+} from '@/api/risk/types';
+import { ElMessage, FormInstance, UploadUserFile } from 'element-plus';
+import { Calendar, User, Files, Bell, CaretTop, CaretBottom, Plus, UploadFilled } from '@element-plus/icons-vue';
+import type { ComponentInternalInstance } from 'vue';
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+
+// ============ 列表与看板 ============
+const campaignList = ref<RiskFoodSafetyCampaignVo[]>([]);
+const total = ref(0);
+const loading = ref(false);
+const stats = ref<FoodSafetyCampaignStatsVo>({
+  totalCount: 0,
+  totalRate: 0,
+  participantsTotal: 0,
+  participantsRate: 0,
+  materialTotal: 0,
+  materialRate: 0,
+  notStartedCount: 0,
+  notStartedRate: 0,
+  lastMonthTotal: 0,
+  lastMonthParticipants: 0,
+  lastMonthMaterial: 0,
+  lastMonthNotStarted: 0
+});
+
+// ============ 字典 ============
+const campaignTypeDict = ref<Array<{ label: string; value: string }>>([]);
+const campaignStatusDict = ref<Array<{ label: string; value: string }>>([]);
+const materialTypeDict = ref<Array<{ label: string; value: string }>>([]);
+
+const mapDictData = (list: DictDataVO[]) => {
+  return (list || [])
+    .filter((d: any) => d.status === '0' || d.status === undefined)
+    .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
+    .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
+};
+
+const loadDicts = async () => {
+  try {
+    const [typeRes, statusRes, materialRes] = await Promise.all([
+      getDicts('risk_food_safety_campaign_type'),
+      getDicts('risk_food_safety_campaign_status'),
+      getDicts('risk_food_safety_material_type')
+    ]);
+    campaignTypeDict.value = mapDictData(typeRes.data || []);
+    campaignStatusDict.value = mapDictData(statusRes.data || []);
+    materialTypeDict.value = mapDictData(materialRes.data || []);
+  } catch (e) {
+    console.error('加载食品安全宣传字典失败', e);
+  }
+};
+
+const getDictLabel = (opts: Array<{ label: string; value: string }>, val: string) => {
+  if (!val) return '';
+  const found = opts.find((o) => o.value === val);
+  return found?.label || val;
+};
+
+// ============ 查询条件 ============
+const queryFormRef = ref<FormInstance>();
+const data = reactive<any>({
+  queryParams: {
+    pageNum: 1,
+    pageSize: 10,
+    keyword: '',
+    campaignType: '',
+    status: '',
+    params: {}
+  }
+});
+const { queryParams } = toRefs(data);
+
+// ============ 新增/编辑活动 ============
+const addDialog = reactive({ visible: false, title: '新增活动' });
+const addFormRef = ref<FormInstance>();
+const addForm = ref<RiskFoodSafetyCampaignBo>({
+  id: undefined,
+  campaignCode: '',
+  campaignName: '',
+  campaignType: '',
+  startTime: '',
+  endTime: '',
+  expectedParticipants: 0,
+  actualParticipants: 0,
+  location: '',
+  description: '',
+  principalId: undefined,
+  principalName: '',
+  status: '未开始',
+  summary: '',
+  remark: ''
+});
+const addRules = {
+  campaignName: [{ required: true, message: '请输入活动名称', trigger: 'blur' }],
+  campaignType: [{ required: true, message: '请选择活动类型', trigger: 'change' }],
+  startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
+  endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
+  principalName: [{ required: true, message: '请输入负责人姓名', trigger: 'blur' }]
+};
+
+// ============ 活动结束弹窗 ============
+const finishDialog = reactive({ visible: false });
+const finishFormRef = ref<FormInstance>();
+const finishForm = reactive({
+  id: undefined as number | string | undefined,
+  campaignCode: '',
+  campaignName: '',
+  actualParticipants: 0,
+  summary: ''
+});
+const finishRules = {
+  actualParticipants: [{ required: true, type: 'number', message: '请输入实际参与人数', trigger: 'blur' }],
+  summary: [{ required: true, message: '请输入活动总结', trigger: 'blur' }]
+};
+
+// ============ 上传资料弹窗 ============
+const materialDialog = reactive({ visible: false });
+const materialFormRef = ref<FormInstance>();
+const materialForm = ref<RiskFoodSafetyMaterialBo>({
+  id: undefined,
+  campaignId: undefined,
+  materialName: '',
+  materialType: '',
+  materialSummary: '',
+  coverUrl: '',
+  coverName: '',
+  attachmentUrl: '',
+  attachmentName: ''
+});
+const materialRules = {
+  materialName: [{ required: true, message: '请输入资料名称', trigger: 'blur' }],
+  materialType: [{ required: true, message: '请选择资料类型', trigger: 'change' }],
+  attachmentUrl: [{ required: true, message: '请上传资料附件', trigger: 'change' }]
+};
+const coverFileList = ref<UploadUserFile[]>([]);
+const attachmentFileList = ref<UploadUserFile[]>([]);
+// 当前上传资料关联的活动(仅用于在弹窗中展示名称/编号,不入库)
+const currentCampaign = ref<{
+  id: number | string | undefined;
+  campaignCode: string;
+  campaignName: string;
+}>({
+  id: undefined,
+  campaignCode: '',
+  campaignName: ''
+});
+
+// ============ 数据加载 ============
+const loadStats = async () => {
+  try {
+    const res = await getFoodSafetyCampaignStats();
+    if (res.data) {
+      stats.value = { ...stats.value, ...res.data };
+    }
+  } catch (e) {
+    console.error('加载看板数据失败', e);
+  }
+};
+
+const getList = async () => {
+  loading.value = true;
+  try {
+    const res: any = await listFoodSafetyCampaign(queryParams.value);
+    campaignList.value = res.rows || [];
+    total.value = res.total || 0;
+  } finally {
+    loading.value = false;
+  }
+};
+
+const handleQuery = () => {
+  queryParams.value.pageNum = 1;
+  getList();
+};
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields();
+  queryParams.value.keyword = '';
+  queryParams.value.campaignType = '';
+  queryParams.value.status = '';
+  queryParams.value.pageNum = 1;
+  getList();
+};
+
+const loadAll = async () => {
+  await Promise.all([loadStats(), getList()]);
+};
+
+// ============ 新增/编辑活动 ============
+const resetAddForm = () => {
+  addForm.value = {
+    id: undefined,
+    campaignCode: '',
+    campaignName: '',
+    campaignType: '',
+    startTime: '',
+    endTime: '',
+    expectedParticipants: 0,
+    actualParticipants: 0,
+    location: '',
+    description: '',
+    principalId: undefined,
+    principalName: '',
+    status: '未开始',
+    summary: '',
+    remark: ''
+  };
+  addFormRef.value?.resetFields();
+};
+
+const handleAdd = () => {
+  resetAddForm();
+  addDialog.title = '新增活动';
+  addDialog.visible = true;
+};
+
+const handleEdit = async (row: RiskFoodSafetyCampaignVo) => {
+  try {
+    const res: any = await getFoodSafetyCampaign(row.id);
+    const data = res.data || {};
+    addForm.value = {
+      id: data.id,
+      campaignCode: data.campaignCode,
+      campaignName: data.campaignName,
+      campaignType: data.campaignType,
+      startTime: data.startTime,
+      endTime: data.endTime,
+      expectedParticipants: data.expectedParticipants || 0,
+      actualParticipants: data.actualParticipants || 0,
+      location: data.location || '',
+      description: data.description || '',
+      principalId: data.principalId,
+      principalName: data.principalName || '',
+      status: data.status || '未开始',
+      summary: data.summary || '',
+      remark: data.remark || ''
+    };
+    addDialog.title = '编辑活动';
+    addDialog.visible = true;
+  } catch (e) {
+    ElMessage.error('获取活动详情失败');
+  }
+};
+
+const submitAddForm = () => {
+  addFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    try {
+      if (addForm.value.id) {
+        await updateFoodSafetyCampaign(addForm.value);
+        ElMessage.success('修改成功');
+      } else {
+        await addFoodSafetyCampaign(addForm.value);
+        ElMessage.success('新增成功');
+      }
+      addDialog.visible = false;
+      await loadAll();
+    } catch (e: any) {
+      ElMessage.error(e?.msg || '保存失败');
+    }
+  });
+};
+
+const closeAddDialog = () => {
+  addDialog.visible = false;
+  resetAddForm();
+};
+
+// ============ 活动开始 ============
+const handleStart = async (row: RiskFoodSafetyCampaignVo) => {
+  try {
+    await proxy?.$modal.confirm(`确认开始活动 [${row.campaignName}] 吗?`);
+    await startFoodSafetyCampaign(row.id);
+    ElMessage.success('活动已开始');
+    await loadAll();
+  } catch (e) {
+    // 取消
+  }
+};
+
+// ============ 活动结束 ============
+const openFinishDialog = (row: RiskFoodSafetyCampaignVo) => {
+  finishForm.id = row.id;
+  finishForm.campaignCode = row.campaignCode || '';
+  finishForm.campaignName = row.campaignName || '';
+  finishForm.actualParticipants = row.actualParticipants || row.expectedParticipants || 0;
+  finishForm.summary = '';
+  finishDialog.visible = true;
+};
+
+const submitFinishForm = () => {
+  finishFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    try {
+      await finishFoodSafetyCampaign(finishForm.id as number, { summary: finishForm.summary });
+      ElMessage.success('活动已结束');
+      finishDialog.visible = false;
+      await loadAll();
+    } catch (e: any) {
+      ElMessage.error(e?.msg || '结束活动失败');
+    }
+  });
+};
+
+// ============ 删除活动 ============
+const handleDelete = async (row: RiskFoodSafetyCampaignVo) => {
+  try {
+    await proxy?.$modal.confirm(`确认删除活动 [${row.campaignName}] 吗?`);
+    await delFoodSafetyCampaign(row.id);
+    ElMessage.success('删除成功');
+    await loadAll();
+  } catch (e) {
+    // 取消
+  }
+};
+
+// ============ 上传资料 ============
+const resetMaterialForm = () => {
+  materialForm.value = {
+    id: undefined,
+    campaignId: undefined,
+    materialName: '',
+    materialType: '',
+    materialSummary: '',
+    coverUrl: '',
+    coverName: '',
+    attachmentUrl: '',
+    attachmentName: ''
+  };
+  coverFileList.value = [];
+  attachmentFileList.value = [];
+  currentCampaign.value = {
+    id: undefined,
+    campaignCode: '',
+    campaignName: ''
+  };
+  materialFormRef.value?.resetFields();
+};
+
+const handleUploadMaterial = (row: RiskFoodSafetyCampaignVo) => {
+  resetMaterialForm();
+  currentCampaign.value = {
+    id: row.id,
+    campaignCode: row.campaignCode || '',
+    campaignName: row.campaignName || ''
+  };
+  materialForm.value.campaignId = row.id;
+  materialDialog.visible = true;
+};
+
+const closeMaterialDialog = () => {
+  materialDialog.visible = false;
+  resetMaterialForm();
+};
+
+const submitMaterialForm = () => {
+  materialFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    try {
+      // 提取封面
+      const cover: any = coverFileList.value.find((f: any) => f.status === 'success');
+      if (cover) {
+        materialForm.value.coverUrl = cover.url || cover.response?.data?.url || cover.response?.data?.ossId || '';
+        materialForm.value.coverName = cover.name || cover.response?.data?.originalName || '';
+      } else {
+        materialForm.value.coverUrl = '';
+        materialForm.value.coverName = '';
+      }
+      // 提取附件
+      const attach: any = attachmentFileList.value.find((f: any) => f.status === 'success');
+      if (attach) {
+        materialForm.value.attachmentUrl =
+          attach.url || attach.response?.data?.url || attach.response?.data?.ossId || '';
+        materialForm.value.attachmentName = attach.name || attach.response?.data?.originalName || '';
+      } else {
+        materialForm.value.attachmentUrl = '';
+        materialForm.value.attachmentName = '';
+      }
+      // 提交资料
+      await addFoodSafetyMaterial(materialForm.value);
+      ElMessage.success('资料上传成功');
+      materialDialog.visible = false;
+      await loadStats();
+    } catch (e: any) {
+      ElMessage.error(e?.msg || '资料上传失败');
+    }
+  });
+};
+
+// ============ 文件上传(MinIO) ============
+const COVER_EXTS = ['jpg', 'jpeg', 'png'];
+const COVER_MAX_SIZE = 5 * 1024 * 1024; // 5MB
+const ATTACH_EXTS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'mp4', 'mp3'];
+const ATTACH_MAX_SIZE = 50 * 1024 * 1024; // 50MB
+
+/**
+ * 自定义上传(走 axios baseURL,避免跨域/405)
+ */
+const customUpload = async (options: any) => {
+  try {
+    const res: any = await uploadFile(options.file);
+    if (res && res.code === 200 && res.data) {
+      options.file.url = res.data.url;
+      options.onSuccess(res, options.file);
+    } else {
+      options.onError(new Error(res?.msg || '上传失败'));
+    }
+  } catch (err: any) {
+    options.onError(err);
+  }
+};
+
+const beforeCoverUpload = (file: any) => {
+  const ext = (file.name.split('.').pop() || '').toLowerCase();
+  if (!COVER_EXTS.includes(ext)) {
+    ElMessage.warning(`封面不支持 .${ext},仅允许: ${COVER_EXTS.join('/')}`);
+    return false;
+  }
+  if (file.size > COVER_MAX_SIZE) {
+    ElMessage.warning(`封面 ${file.name} 超过 5MB 上限`);
+    return false;
+  }
+  return true;
+};
+
+const beforeAttachmentUpload = (file: any) => {
+  const ext = (file.name.split('.').pop() || '').toLowerCase();
+  if (!ATTACH_EXTS.includes(ext)) {
+    ElMessage.warning(`附件不支持 .${ext},仅允许: ${ATTACH_EXTS.join('/')}`);
+    return false;
+  }
+  if (file.size > ATTACH_MAX_SIZE) {
+    ElMessage.warning(`附件 ${file.name} 超过 50MB 上限`);
+    return false;
+  }
+  return true;
+};
+
+const handleCoverSuccess = (response: any, file: any) => {
+  if (response && response.code === 200) {
+    ElMessage.success(`封面 ${file.name} 上传成功`);
+  } else {
+    ElMessage.error(`封面 ${file.name} 上传失败: ${response?.msg || '未知错误'}`);
+  }
+};
+
+const handleAttachmentSuccess = (response: any, file: any) => {
+  if (response && response.code === 200) {
+    ElMessage.success(`附件 ${file.name} 上传成功`);
+  } else {
+    ElMessage.error(`附件 ${file.name} 上传失败: ${response?.msg || '未知错误'}`);
+  }
+};
+
+const handleUploadError = (error: any) => {
+  console.error('文件上传失败', error);
+  ElMessage.error(`文件上传失败: ${error?.message || '网络异常'}`);
+};
+
+const handleCoverExceed = () => {
+  ElMessage.warning('最多只能上传 1 张封面,如需更换请先删除已有封面');
+};
+
+const handleAttachmentExceed = () => {
+  ElMessage.warning('最多只能上传 1 个附件,如需更换请先删除已有附件');
+};
+
+const handleCoverRemove = () => {
+  materialForm.value.coverUrl = '';
+  materialForm.value.coverName = '';
+};
+
+const handleAttachmentRemove = () => {
+  materialForm.value.attachmentUrl = '';
+  materialForm.value.attachmentName = '';
+};
+
+// ============ 工具 ============
+const getStatusTag = (status: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
+  const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
+    未开始: 'info',
+    进行中: 'warning',
+    已结束: 'success'
+  };
+  return map[status] || 'info';
+};
+
+const formatDateTime = (val: any): string => {
+  if (!val) return '';
+  const d = new Date(val);
+  if (isNaN(d.getTime())) return String(val);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+};
+
+// ============ 生命周期 ============
+onMounted(async () => {
+  await loadDicts();
+  await loadAll();
+});
+</script>
+
+<style scoped lang="scss">
+.food-safety-page {
+  padding: 16px;
+  height: 100%;
+  overflow-y: auto;
+  box-sizing: border-box;
+}
+.page-header {
+  margin-bottom: 16px;
+  .page-title {
+    font-size: 20px;
+    font-weight: 600;
+    margin: 0;
+    color: #303133;
+  }
+  .page-desc {
+    font-size: 13px;
+    color: #909399;
+    margin: 4px 0 0;
+  }
+}
+
+.mb-2 { margin-bottom: 16px; }
+
+.section-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.section-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.filter-bar {
+  margin-bottom: 16px;
+}
+
+.stat-card {
+  border-radius: 8px;
+  :deep(.el-card__body) { padding: 20px; }
+}
+.stat-card-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.stat-info { flex: 1; }
+.stat-title {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 8px;
+  white-space: nowrap;
+}
+.stat-value {
+  font-size: 28px;
+  font-weight: 700;
+  color: #303133;
+  line-height: 1.2;
+  margin-bottom: 8px;
+}
+.stat-extra {
+  font-size: 12px;
+  color: #909399;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
+.stat-extra-up { color: #f56c6c; }
+.stat-extra-down { color: #67c23a; }
+.stat-extra-warning {
+  color: #e6a23c;
+  font-weight: 600;
+}
+.stat-icon {
+  width: 48px;
+  height: 48px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  color: #fff;
+}
+.stat-icon-blue { background: #ecf5ff; color: #409EFF; }
+.stat-icon-green { background: #f0f9eb; color: #67C23A; }
+.stat-icon-purple { background: #f4eefe; color: #8e44ad; }
+.stat-icon-orange { background: #fdf6ec; color: #E6A23C; }
+
+.readonly-text {
+  color: #606266;
+  font-size: 14px;
+}
+.muted { color: #909399; font-size: 12px; }
+
+.dialog-footer { text-align: right; }
+
+.upload-trigger-area { width: 100%; }
+.upload-action-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+.upload-placeholder {
+  font-size: 13px;
+  color: #909399;
+}
+.upload-tip-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 16px 0;
+  border: 1px dashed #d9d9d9;
+  border-radius: 4px;
+  background: #fafafa;
+}
+.upload-tip-icon {
+  color: #c0c4cc;
+  font-size: 24px;
+  margin-bottom: 6px;
+}
+.upload-tip-text {
+  font-size: 13px;
+  color: #606266;
+}
+.upload-tip-desc {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+}
+.el-upload__tip {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+  margin-top: 4px;
+}
+</style>

+ 5 - 0
src/views/risk/hiddenDanger/index.vue

@@ -109,6 +109,8 @@
             <el-tag :type="getStatusTag(row.handleStatus)">{{ row.handleStatus }}</el-tag>
           </template>
         </el-table-column>
+        <el-table-column label="隐患提报人" align="center" prop="createByName" width="110" :show-overflow-tooltip="true" />
+        <el-table-column label="隐患提报部门" align="center" prop="createDeptName" width="140" :show-overflow-tooltip="true" />
         <el-table-column label="操作" align="center" width="200" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleView(row)">查看</el-button>
@@ -345,6 +347,9 @@
         <el-descriptions-item label="处理时间">{{ viewForm.handleTime }}</el-descriptions-item>
         <el-descriptions-item label="处理结果">{{ viewForm.handleResult }}</el-descriptions-item>
         <el-descriptions-item label="处理措施">{{ viewForm.handleMeasures }}</el-descriptions-item>
+        <el-descriptions-item label="隐患提报人">{{ viewForm.createByName || viewForm.createBy || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="隐患提报部门">{{ viewForm.createDeptName || viewForm.createDept || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="创建时间">{{ viewForm.createTime }}</el-descriptions-item>
         <el-descriptions-item v-if="viewPhotoList.length > 0" label="现场照片">
           <el-image
             v-for="(img, idx) in viewPhotoList"

+ 22 - 9
src/views/risk/inspectionTask/index.vue

@@ -92,15 +92,14 @@
     </transition>
 
     <!-- 数据表格区域 -->
-    <el-card shadow="hover">
-      <template #header>
-        <el-row :gutter="10" class="mb-2">
-          <el-col :span="1.5">
-            <el-button type="primary" plain icon="Plus" @click="handleAdd">新增巡检打卡</el-button>
-          </el-col>
-          <right-toolbar v-model:show-search="showSearch" @query-table="getList" />
-        </el-row>
-      </template>
+    <el-card shadow="hover" class="list-card">
+      <div class="list-toolbar">
+        <span class="list-total">共 {{ total }} 条记录</span>
+        <div class="toolbar-actions">
+          <el-button type="primary" plain icon="Plus" @click="handleAdd">新增巡检打卡</el-button>
+          <right-toolbar v-model:show-search="showSearch" @query-table="getList" style="margin-left: 8px;" />
+        </div>
+      </div>
 
       <el-table v-loading="loading" :data="taskList" border>
         <el-table-column label="巡检任务名称" align="center" prop="taskName" :show-overflow-tooltip="true" />
@@ -994,4 +993,18 @@ onMounted(async () => {
 .audit-opinion.reject {
   color: #f56c6c;
 }
+.list-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.list-total {
+  font-size: 13px;
+  color: #606266;
+}
+.toolbar-actions {
+  display: flex;
+  align-items: center;
+}
 </style>

+ 998 - 0
src/views/risk/qualityInspectionTask/index.vue

@@ -0,0 +1,998 @@
+<template>
+  <div class="p-2 inspection-task-page">
+    <!-- 顶部数据卡片 -->
+    <el-row :gutter="20" class="mb-2">
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">今日应打卡</div>
+              <div class="stat-value">{{ stats.todayShould || 0 }}</div>
+            </div>
+            <div class="stat-icon stat-icon-blue">
+              <el-icon><Calendar /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">今日已打卡</div>
+              <div class="stat-value">{{ stats.todayChecked || 0 }}</div>
+            </div>
+            <div class="stat-icon stat-icon-green">
+              <el-icon><Check /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">异常打卡</div>
+              <div class="stat-value">{{ stats.abnormal || 0 }}</div>
+            </div>
+            <div class="stat-icon stat-icon-orange">
+              <el-icon><Warning /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">未打卡</div>
+              <div class="stat-value">{{ stats.unchecked || 0 }}</div>
+            </div>
+            <div class="stat-icon stat-icon-red">
+              <el-icon><Clock /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 搜索区域 -->
+    <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
+      <div v-show="showSearch" class="mb-[10px]">
+        <el-card shadow="hover">
+          <el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="80">
+            <el-form-item label="任务名称" prop="taskName">
+              <el-input v-model="queryParams.taskName" placeholder="请输入任务名称" clearable style="width: 200px" @keyup.enter="handleQuery" />
+            </el-form-item>
+            <el-form-item label="巡检类型" prop="taskType">
+              <el-select v-model="queryParams.taskType" placeholder="全部" clearable style="width: 180px">
+                <el-option v-for="dict in inspection_type_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="门店" prop="storeName">
+              <el-input v-model="queryParams.storeName" placeholder="请输入门店" clearable style="width: 180px" @keyup.enter="handleQuery" />
+            </el-form-item>
+            <el-form-item label="打卡状态" prop="recordStatus">
+              <el-select v-model="queryParams.recordStatus" placeholder="全部" clearable style="width: 180px">
+                <el-option v-for="dict in inspection_status_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="打卡结果" prop="checkResult">
+              <el-select v-model="queryParams.checkResult" placeholder="全部" clearable style="width: 180px">
+                <el-option v-for="dict in inspection_result_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" icon="Search" @click="handleQuery">查询</el-button>
+              <el-button icon="Refresh" @click="resetQuery">重置</el-button>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+    </transition>
+
+    <!-- 数据表格区域 -->
+    <el-card shadow="hover">
+      <template #header>
+        <el-row :gutter="10" class="mb-2">
+          <el-col :span="1.5">
+            <el-button v-hasPermi="['risk:qualityInspectionTask:add']" type="primary" plain icon="Plus" @click="handleAdd">新增巡检打卡</el-button>
+          </el-col>
+          <right-toolbar v-model:show-search="showSearch" @query-table="getList" />
+        </el-row>
+      </template>
+
+      <el-table v-loading="loading" :data="taskList" border>
+        <el-table-column label="巡检任务名称" align="center" prop="taskName" :show-overflow-tooltip="true" />
+        <el-table-column label="巡检类型" align="center" prop="taskType" width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="打卡方式" align="center" prop="checkMethod" width="100">
+          <template #default="scope">
+            <el-tag :type="getMethodTag(scope.row.checkMethod)">{{ scope.row.checkMethod || '常规' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="门店" align="center" prop="storeName" width="140" :show-overflow-tooltip="true" />
+        <el-table-column label="打卡地点" align="center" prop="location" width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="责任人" align="center" prop="inspectorName" width="100" />
+        <el-table-column label="打卡状态" align="center" prop="recordStatus" width="100">
+          <template #default="scope">
+            <el-tag :type="getStatusTag(scope.row.recordStatus)">{{ scope.row.recordStatus || '未打卡' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="打卡结果" align="center" prop="checkResult" width="100">
+          <template #default="scope">
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)">{{ scope.row.checkResult }}</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" fixed="right" align="center" width="240" class-name="small-padding fixed-width">
+          <template #default="scope">
+            <el-button v-hasPermi="['risk:qualityInspectionTask:list']" link type="primary" icon="Document" @click="handleViewRecords(scope.row)">打卡记录</el-button>
+            <el-button v-hasPermi="['risk:qualityInspectionTask:edit']" link type="primary" icon="Edit" @click="handleUpdate(scope.row)">编辑</el-button>
+            <el-button v-hasPermi="['risk:qualityInspectionTask:remove']" link type="danger" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination v-show="total > 0" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" :total="total" @pagination="getList" />
+    </el-card>
+
+    <!-- 新增/编辑对话框 -->
+    <el-dialog v-model="dialog.visible" :title="dialog.title" width="900px" append-to-body @close="closeDialog">
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="任务名称" prop="taskName">
+              <el-input v-model="form.taskName" placeholder="请输入巡检任务名称" maxlength="100" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="巡检类型" prop="taskType">
+              <el-select v-model="form.taskType" placeholder="请选择巡检类型" style="width: 100%">
+                <el-option v-for="dict in inspection_type_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="门店" prop="storeName">
+              <el-input v-model="form.storeName" placeholder="请输入门店/总部名称" maxlength="100" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="位置区域" prop="location">
+              <el-input v-model="form.location" placeholder="巡检点物理名称(如XX车间XX工位)" maxlength="200" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="巡检周期" prop="cycle">
+              <el-select v-model="form.cycle" placeholder="请选择巡检周期" style="width: 100%" @change="onCycleChange">
+                <el-option v-for="dict in inspection_cycle_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="开启扫码打卡" prop="scanEnabled">
+              <el-switch v-model="form.scanEnabled" active-text="是" inactive-text="否" @change="onScanChange" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="纬度" prop="lat">
+              <el-input-number v-model="form.lat" :precision="6" :min="-90" :max="90" controls-position="right" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="经度" prop="lng">
+              <el-input-number v-model="form.lng" :precision="6" :min="-180" :max="180" controls-position="right" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 巡检时间:根据周期动态适配 -->
+        <el-form-item label="巡检时间" prop="timeConfig">
+          <div v-if="form.cycle === '每日' || !form.cycle">
+            <el-tag v-for="(t, idx) in timeSlotList" :key="idx" closable @close="removeTimeSlot(idx)" style="margin-right: 8px">
+              {{ t }}
+            </el-tag>
+            <el-button size="small" type="primary" plain @click="openAddTimeSlot">添加时间段</el-button>
+          </div>
+          <div v-else-if="form.cycle === '每周'">
+            <el-checkbox-group v-model="weekDayList">
+              <el-checkbox v-for="d in weekDayOptions" :key="d.value" :label="d.value">{{ d.label }}</el-checkbox>
+            </el-checkbox-group>
+          </div>
+          <div v-else-if="form.cycle === '每月'">
+            <el-select v-model="monthDayList" multiple placeholder="选择执行日期" style="width: 100%">
+              <el-option v-for="d in 31" :key="d" :label="`${d}号`" :value="d" />
+            </el-select>
+          </div>
+        </el-form-item>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="责任人" prop="inspectorId">
+              <el-select
+                v-model="form.inspectorId"
+                placeholder="请选择责任人(本部门及下级部门)"
+                style="width: 100%"
+                filterable
+                clearable
+                @change="onInspectorChange"
+              >
+                <el-option v-for="u in deptChainUserOptions" :key="String(u.userId)" :label="u.nickName" :value="u.userId" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="审核人" prop="auditorId">
+              <el-select
+                v-model="form.auditorId"
+                placeholder="请选择审核人(本部门及下级部门)"
+                style="width: 100%"
+                filterable
+                clearable
+                @change="onAuditorChange"
+              >
+                <el-option v-for="u in deptChainUserOptions" :key="String(u.userId)" :label="u.nickName" :value="u.userId" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 打卡项目:多行 -->
+        <el-divider content-position="left">
+          <span style="font-weight: bold">打卡项目</span>
+          <el-button link type="primary" icon="Plus" @click="addInspectionItem">添加项目</el-button>
+        </el-divider>
+        <el-table :data="form.itemList" border>
+          <el-table-column label="序号" align="center" width="60">
+            <template #default="scope">{{ scope.$index + 1 }}</template>
+          </el-table-column>
+          <el-table-column label="项目大类" align="center" width="160">
+            <template #default="scope">
+              <el-select v-model="scope.row.itemType" placeholder="请选择" size="small" style="width: 100%">
+                <el-option v-for="dict in inspection_item_type_dict" :key="dict.value" :label="dict.label" :value="dict.value" />
+              </el-select>
+            </template>
+          </el-table-column>
+          <el-table-column label="巡检内容" align="center">
+            <template #default="scope">
+              <el-input v-model="scope.row.itemContent" placeholder="请输入巡检内容" size="small" />
+            </template>
+          </el-table-column>
+          <el-table-column label="巡检标准" align="center">
+            <template #default="scope">
+              <el-input v-model="scope.row.itemStandard" placeholder="请输入巡检标准" size="small" />
+            </template>
+          </el-table-column>
+          <el-table-column label="是否拍照" align="center" width="80">
+            <template #default="scope">
+              <el-switch v-model="scope.row.needPhoto" />
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" align="center" width="80">
+            <template #default="scope">
+              <el-button link type="danger" size="small" icon="Delete" @click="removeInspectionItem(scope.$index)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button type="primary" @click="submitForm">保存巡检任务</el-button>
+          <el-button @click="cancel">取 消</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- 添加时间段弹窗 -->
+    <el-dialog v-model="timeSlotDialog.visible" title="添加时间段" width="420px" append-to-body>
+      <el-form label-width="80px">
+        <el-form-item label="开始时间">
+          <el-time-picker v-model="timeSlotForm.start" placeholder="开始时间" value-format="HH:mm" format="HH:mm" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="结束时间">
+          <el-time-picker v-model="timeSlotForm.end" placeholder="结束时间" value-format="HH:mm" format="HH:mm" style="width: 100%" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="timeSlotDialog.visible = false">取消</el-button>
+        <el-button type="primary" @click="confirmAddTimeSlot">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 打卡记录对话框 -->
+    <el-dialog v-model="recordDialog.visible" title="打卡记录" width="900px" append-to-body>
+      <el-table v-loading="recordLoading" :data="recordList" border>
+        <el-table-column label="打卡时间" align="center" prop="checkInTime" width="180" />
+        <el-table-column label="打卡人" align="center" prop="inspectorName" width="100" />
+        <el-table-column label="打卡地点" align="center" prop="checkInLocation" :show-overflow-tooltip="true" />
+        <el-table-column label="问题数" align="center" prop="problemCount" width="80" />
+        <el-table-column label="总数" align="center" prop="totalCount" width="80" />
+        <el-table-column label="打卡状态" align="center" width="100">
+          <template #default="scope">
+            <el-tag :type="getStatusTag(scope.row.recordStatus)">{{ scope.row.recordStatus }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="打卡结果" align="center" width="100">
+          <template #default="scope">
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)">{{ scope.row.checkResult }}</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="审核状态" align="center" prop="auditStatus" width="100">
+          <template #default="scope">
+            <el-tag v-if="scope.row.auditStatus" :type="getAuditStatusTag(scope.row.auditStatus)">{{ scope.row.auditStatus }}</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" align="center" width="180" fixed="right">
+          <template #default="scope">
+            <el-button link type="primary" @click="handleViewDetail(scope.row)">查看详情</el-button>
+            <el-button
+              v-if="!scope.row.auditStatus || scope.row.auditStatus === '待审核'"
+              v-hasPermi="['risk:qualityInspectionRecord:review']"
+              link
+              type="primary"
+              @click="handleAuditRecord(scope.row)"
+            >审核</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <template #footer>
+        <el-button @click="recordDialog.visible = false">关 闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 打卡详情对话框 -->
+    <el-dialog v-model="detailDialog.visible" title="打卡详情" width="700px" append-to-body>
+      <el-descriptions :column="1" border>
+        <el-descriptions-item label="任务名称">{{ detailForm.taskName }}</el-descriptions-item>
+        <el-descriptions-item label="打卡时间">{{ detailForm.checkInTime }}</el-descriptions-item>
+        <el-descriptions-item label="打卡人">{{ detailForm.inspectorName }}</el-descriptions-item>
+        <el-descriptions-item label="打卡状态">
+          <el-tag :type="getStatusTag(detailForm.recordStatus)">{{ detailForm.recordStatus }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="打卡结果">
+          <el-tag v-if="detailForm.checkResult" :type="getResultTag(detailForm.checkResult)">{{ detailForm.checkResult }}</el-tag>
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="打卡地点">{{ detailForm.checkInLocation }}</el-descriptions-item>
+        <el-descriptions-item label="问题数/总数">{{ detailForm.problemCount || 0 }}/{{ detailForm.totalCount || 0 }}</el-descriptions-item>
+        <el-descriptions-item label="审核状态">
+          <el-tag v-if="detailForm.auditStatus" :type="getAuditStatusTag(detailForm.auditStatus)">{{ detailForm.auditStatus }}</el-tag>
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item v-if="detailForm.auditorName" label="审核人">{{ detailForm.auditorName }} ({{ detailForm.auditTime || '-' }})</el-descriptions-item>
+        <el-descriptions-item v-if="detailForm.auditOpinion" label="审核意见" :span="1">
+          <span :class="['audit-opinion', detailForm.auditStatus === '审核不通过' ? 'reject' : 'pass']">
+            {{ detailForm.auditOpinion }}
+          </span>
+        </el-descriptions-item>
+      </el-descriptions>
+      <h4 style="margin: 16px 0 8px">巡检项目明细</h4>
+      <el-table :data="detailForm.itemList" border size="small">
+        <el-table-column label="项目大类" prop="itemType" width="120" />
+        <el-table-column label="巡检内容" prop="itemContent" />
+        <el-table-column label="巡检结果" prop="checkResult" width="100">
+          <template #default="scope">
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)" size="small">{{ scope.row.checkResult }}</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="照片" width="80">
+          <template #default="scope">
+            <span v-if="scope.row.checkPhotos">{{ scope.row.checkPhotos }}</span>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+      </el-table>
+      <template #footer>
+        <el-button @click="detailDialog.visible = false">关 闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 审核弹窗 -->
+    <el-dialog
+      v-model="auditDialog.visible"
+      title="巡检打卡审核"
+      width="520px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="closeAuditDialog"
+    >
+      <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="100px">
+        <el-form-item label="任务名称">
+          <span class="readonly-text">{{ auditForm.taskName }}</span>
+        </el-form-item>
+        <el-form-item label="打卡人">
+          <span class="readonly-text">{{ auditForm.inspectorName }}</span>
+        </el-form-item>
+        <el-form-item label="打卡时间">
+          <span class="readonly-text">{{ auditForm.checkInTime }}</span>
+        </el-form-item>
+        <el-form-item label="审核结果" prop="auditStatus">
+          <el-radio-group v-model="auditForm.auditStatus">
+            <el-radio value="审核通过">通过</el-radio>
+            <el-radio value="审核不通过">不通过</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-if="auditForm.auditStatus === '审核不通过'" label="不通过原因" prop="auditOpinion">
+          <el-input
+            v-model="auditForm.auditOpinion"
+            type="textarea"
+            :rows="4"
+            placeholder="请输入不通过原因"
+            maxlength="500"
+            show-word-limit
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="cancelAudit">取消</el-button>
+          <el-button v-hasPermi="['risk:qualityInspectionRecord:review']" type="primary" @click="submitAudit">确认审核</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script setup name="QualityInspectionTask" lang="ts">
+import { ref, reactive, toRefs, onMounted, getCurrentInstance } from 'vue';
+import {
+  listQualityInspectionTask,
+  getQualityInspectionTask,
+  addQualityInspectionTask,
+  updateQualityInspectionTask,
+  delQualityInspectionTask,
+  getQualityInspectionStats,
+  listQualityInspectionRecordByTask
+} from '@/api/risk/qualityInspectionTask';
+// 巡检项目明细(项目大类/巡检内容/巡检标准/是否拍照)的保存,暂时复用风控同一张 risk_inspection_item 表
+// 后续如果品控需要单独字段,可以新增 /quality/inspectionItem 路由
+import { listInspectionItemByTask } from '@/api/risk/inspectionItem';
+import { auditInspectionRecord } from '@/api/risk/inspectionRecord';
+import type {
+  RiskInspectionTaskBo,
+  RiskInspectionTaskVo,
+  RiskInspectionItemVo,
+  RiskInspectionRecordVo,
+  RiskInspectionRecordBo,
+  InspectionStatsVo
+} from '@/api/risk/types';
+import { listByCurrentDeptChainUsers } from '@/api/system/user';
+import type { UserVO } from '@/api/system/user/types';
+import { FormInstance } from 'element-plus';
+import type { ComponentInternalInstance } from 'vue';
+import { Calendar, Check, Warning, Clock } from '@element-plus/icons-vue';
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+
+interface InspectionItemForm {
+  id?: number;
+  itemType: string;
+  itemContent: string;
+  itemStandard: string;
+  needPhoto: boolean;
+}
+
+const taskList = ref<RiskInspectionTaskVo[]>([]);
+const loading = ref(true);
+const showSearch = ref(true);
+const total = ref(0);
+const queryFormRef = ref<FormInstance>();
+const formRef = ref<FormInstance>();
+const recordList = ref<RiskInspectionRecordVo[]>([]);
+const recordLoading = ref(false);
+// 责任人/审核人选择器数据(当前登录用户所在部门及所有下级部门用户)
+const deptChainUserOptions = ref<UserVO[]>([]);
+// 字典(品控专用字典 quality_inspection_type / quality_project_category,
+//  通用字典 inspection_status / inspection_result / inspection_cycle 与风控共用)
+const {
+  quality_inspection_type: inspection_type_dict,
+  quality_project_category: inspection_item_type_dict,
+  inspection_status: inspection_status_dict,
+  inspection_result: inspection_result_dict,
+  inspection_cycle: inspection_cycle_dict
+} = toRefs<any>(
+  proxy?.useDict(
+    'quality_inspection_type',
+    'quality_project_category',
+    'inspection_status',
+    'inspection_result',
+    'inspection_cycle'
+  )
+);
+
+// 统计
+const stats = ref<InspectionStatsVo>({
+  todayShould: 0,
+  todayChecked: 0,
+  abnormal: 0,
+  unchecked: 0
+});
+
+// 时间配置
+const timeSlotList = ref<string[]>([]);
+const weekDayList = ref<string[]>([]);
+const monthDayList = ref<number[]>([]);
+const weekDayOptions = [
+  { value: '1', label: '周一' },
+  { value: '2', label: '周二' },
+  { value: '3', label: '周三' },
+  { value: '4', label: '周四' },
+  { value: '5', label: '周五' },
+  { value: '6', label: '周六' },
+  { value: '7', label: '周日' }
+];
+
+const timeSlotDialog = reactive({ visible: false });
+const timeSlotForm = reactive({ start: '', end: '' });
+
+const data = reactive<any>({
+  form: {
+    id: undefined,
+    taskName: '',
+    taskType: '',
+    storeName: '',
+    location: '',
+    cycle: '每日',
+    scanEnabled: false,
+    checkMethod: '常规',
+    lng: undefined,
+    lat: undefined,
+    inspectorName: '',
+    auditorName: '',
+    dayOfWeek: '',
+    dayOfMonth: '',
+    timeSlot: '',
+    itemList: [] as InspectionItemForm[]
+  } as any,
+  queryParams: {
+    pageNum: 1,
+    pageSize: 10,
+    taskName: '',
+    taskType: '',
+    storeName: '',
+    recordStatus: '',
+    checkResult: ''
+  },
+  rules: {
+    taskName: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
+    taskType: [{ required: true, message: '请选择巡检类型', trigger: 'change' }],
+    cycle: [{ required: true, message: '请选择巡检周期', trigger: 'change' }],
+    storeName: [{ required: true, message: '请输入门店', trigger: 'blur' }],
+    location: [{ required: true, message: '请输入位置区域', trigger: 'blur' }],
+    inspectorId: [{ required: true, message: '请选择责任人', trigger: 'change' }],
+    auditorId: [{ required: true, message: '请选择审核人', trigger: 'change' }]
+  }
+});
+
+const { queryParams, form, rules } = toRefs(data);
+
+const dialog = reactive<DialogOption>({ visible: false, title: '' });
+const recordDialog = reactive<DialogOption>({ visible: false, title: '打卡记录' });
+const detailDialog = reactive({ visible: false });
+const detailForm = ref<any>({ itemList: [] });
+const auditDialog = reactive({ visible: false, title: '巡检打卡审核' });
+const auditFormRef = ref<FormInstance>();
+const getList = async () => {
+  loading.value = true;
+  try {
+    const res = await listQualityInspectionTask(queryParams.value);
+    taskList.value = res.rows || [];
+    total.value = res.total || 0;
+  } finally {
+    loading.value = false;
+  }
+};
+
+const loadStats = async () => {
+  const res = await getQualityInspectionStats();
+  if (res.data) {
+    stats.value = res.data;
+  }
+};
+
+const loadAll = async () => {
+  await Promise.all([getList(), loadStats()]);
+};
+
+const handleQuery = () => {
+  queryParams.value.pageNum = 1;
+  getList();
+};
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields();
+  queryParams.value.pageNum = 1;
+  handleQuery();
+};
+
+const resetForm = () => {
+  form.value = {
+    id: undefined,
+    taskName: '',
+    taskType: '',
+    storeName: '',
+    location: '',
+    cycle: '每日',
+    scanEnabled: false,
+    checkMethod: '常规',
+    lng: undefined,
+    lat: undefined,
+    inspectorId: undefined,
+    inspectorName: '',
+    auditorId: undefined,
+    auditorName: '',
+    dayOfWeek: '',
+    dayOfMonth: '',
+    timeSlot: '',
+    itemList: [] as InspectionItemForm[]
+  };
+  timeSlotList.value = [];
+  weekDayList.value = [];
+  monthDayList.value = [];
+  formRef.value?.resetFields();
+};
+
+/**
+ * 拉取当前登录用户所在部门及所有下级部门的用户列表(责任人/审核人选择器数据源)
+ */
+const loadDeptChainUsers = async () => {
+  try {
+    const res = await listByCurrentDeptChainUsers();
+    deptChainUserOptions.value = res.data || [];
+  } catch (e) {
+    deptChainUserOptions.value = [];
+  }
+};
+
+/**
+ * 选择责任人后,同步回填 inspectorName 字段(便于提交到后端)
+ */
+const onInspectorChange = (val: string | number | undefined) => {
+  if (val === undefined || val === null || val === '') {
+    form.value.inspectorName = '';
+    return;
+  }
+  const user = deptChainUserOptions.value.find((u) => String(u.userId) === String(val));
+  form.value.inspectorName = user?.nickName || user?.userName || '';
+};
+
+/**
+ * 选择审核人后,同步回填 auditorName 字段
+ */
+const onAuditorChange = (val: string | number | undefined) => {
+  if (val === undefined || val === null || val === '') {
+    form.value.auditorName = '';
+    return;
+  }
+  const user = deptChainUserOptions.value.find((u) => String(u.userId) === String(val));
+  form.value.auditorName = user?.nickName || user?.userName || '';
+};
+
+const onCycleChange = (val: string) => {
+  timeSlotList.value = [];
+  weekDayList.value = [];
+  monthDayList.value = [];
+};
+
+const onScanChange = (val: boolean | string | number) => {
+  form.value.checkMethod = val ? '扫码' : '常规';
+};
+
+const openAddTimeSlot = () => {
+  timeSlotForm.start = '';
+  timeSlotForm.end = '';
+  timeSlotDialog.visible = true;
+};
+
+const confirmAddTimeSlot = () => {
+  if (!timeSlotForm.start || !timeSlotForm.end) {
+    proxy?.$modal.msgWarning('请选择开始和结束时间');
+    return;
+  }
+  timeSlotList.value.push(`${timeSlotForm.start}-${timeSlotForm.end}`);
+  timeSlotDialog.visible = false;
+};
+
+const removeTimeSlot = (idx: number) => {
+  timeSlotList.value.splice(idx, 1);
+};
+
+const addInspectionItem = () => {
+  form.value.itemList.push({
+    itemType: '',
+    itemContent: '',
+    itemStandard: '',
+    needPhoto: false
+  });
+};
+
+const removeInspectionItem = (idx: number) => {
+  form.value.itemList.splice(idx, 1);
+};
+
+const handleAdd = () => {
+  resetForm();
+  dialog.visible = true;
+  dialog.title = '新增品控巡检打卡';
+};
+
+const handleUpdate = async (row: RiskInspectionTaskVo) => {
+  resetForm();
+  // 编辑时,确保选择器数据源已加载(避免选择器中无对应选项)
+  if (deptChainUserOptions.value.length === 0) {
+    await loadDeptChainUsers();
+  }
+  const res = await getQualityInspectionTask(row.id);
+  Object.assign(form.value, res.data);
+  // 恢复时间配置
+  if (res.data?.timeSlot) {
+    timeSlotList.value = res.data.timeSlot.split(',').filter((s: string) => s);
+  }
+  if (res.data?.dayOfWeek) {
+    weekDayList.value = res.data.dayOfWeek.split(',').filter((s: string) => s);
+  }
+  if (res.data?.dayOfMonth) {
+    monthDayList.value = res.data.dayOfMonth.split(',').filter((s: string) => s).map((n: string) => Number(n));
+  }
+  // 加载打卡项目
+  try {
+    const itemRes = await listInspectionItemByTask(row.id);
+    form.value.itemList = (itemRes.data || []).map((it: RiskInspectionItemVo) => ({
+      id: it.id,
+      itemType: it.itemType,
+      itemContent: it.itemContent,
+      itemStandard: it.itemStandard,
+      needPhoto: false
+    }));
+  } catch (e) {
+    form.value.itemList = [];
+  }
+  dialog.visible = true;
+  dialog.title = '编辑品控巡检任务';
+};
+
+const handleViewRecords = async (row: RiskInspectionTaskVo) => {
+  recordLoading.value = true;
+  try {
+    const res = await listQualityInspectionRecordByTask(row.id);
+    recordList.value = res.data || [];
+  } finally {
+    recordLoading.value = false;
+  }
+  recordDialog.visible = true;
+};
+
+const handleViewDetail = (row: RiskInspectionRecordVo) => {
+  detailForm.value = { ...row, itemList: [] };
+  detailDialog.visible = true;
+};
+
+// ============ 审核相关 ============
+const auditData = reactive({
+  auditForm: {
+    id: undefined as string | number | undefined,
+    taskName: '',
+    inspectorName: '',
+    checkInTime: '',
+    auditStatus: '审核通过',
+    auditOpinion: ''
+  },
+  auditRules: {
+    auditStatus: [{ required: true, message: '请选择审核结果', trigger: 'change' }],
+    auditOpinion: [
+      {
+        validator: (_rule: any, value: any, callback: any) => {
+          if (auditData.auditForm.auditStatus === '审核不通过' && !value) {
+            callback(new Error('审核不通过时必须填写原因'));
+          } else {
+            callback();
+          }
+        },
+        trigger: 'blur'
+      }
+    ]
+  }
+});
+
+const { auditForm, auditRules } = toRefs(auditData);
+
+/** 打卡记录列表 - 打开审核弹窗 */
+const handleAuditRecord = (row: RiskInspectionRecordVo) => {
+  auditForm.value = {
+    id: row.id,
+    taskName: row.taskName || '',
+    inspectorName: row.inspectorName || '',
+    checkInTime: row.checkInTime || '',
+    auditStatus: '审核通过',
+    auditOpinion: ''
+  };
+  auditDialog.visible = true;
+};
+
+/** 审核弹窗 - 提交 */
+const submitAudit = () => {
+  auditFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    const submitData: RiskInspectionRecordBo = {
+      id: auditForm.value.id,
+      auditStatus: auditForm.value.auditStatus,
+      auditOpinion: auditForm.value.auditOpinion
+    };
+    await auditInspectionRecord(submitData);
+    proxy?.$modal.msgSuccess('审核成功');
+    auditDialog.visible = false;
+    // 刷新当前打卡记录列表(若弹窗开着则一起刷新)
+    if (recordDialog.visible && recordList.value.length > 0) {
+      const taskId = recordList.value[0]?.taskId;
+      if (taskId) {
+        const res = await listQualityInspectionRecordByTask(taskId);
+        recordList.value = res.data || [];
+      }
+    }
+  });
+};
+
+const cancelAudit = () => {
+  auditDialog.visible = false;
+  auditFormRef.value?.resetFields();
+};
+
+const closeAuditDialog = () => {
+  auditDialog.visible = false;
+  auditFormRef.value?.resetFields();
+};
+
+/** 审核状态 tag 颜色(待审核=warning/审核通过=success/审核不通过=danger) */
+const getAuditStatusTag = (s: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
+  const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
+    待审核: 'warning',
+    审核通过: 'success',
+    审核不通过: 'danger'
+  };
+  return (map[s] || 'info') as any;
+};
+
+const handleDelete = async (row: RiskInspectionTaskVo) => {
+  await proxy?.$modal.confirm('是否确认删除任务 "' + row.taskName + '" ?');
+  await delQualityInspectionTask(row.id);
+  proxy?.$modal.msgSuccess('删除成功');
+  await loadAll();
+};
+
+const submitForm = () => {
+  formRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    // 同步时间配置
+    if (form.value.cycle === '每日') {
+      form.value.timeSlot = timeSlotList.value.join(',');
+      form.value.dayOfWeek = '';
+      form.value.dayOfMonth = '';
+    } else if (form.value.cycle === '每周') {
+      form.value.dayOfWeek = weekDayList.value.join(',');
+      form.value.timeSlot = '';
+      form.value.dayOfMonth = '';
+    } else if (form.value.cycle === '每月') {
+      form.value.dayOfMonth = monthDayList.value.join(',');
+      form.value.timeSlot = '';
+      form.value.dayOfWeek = '';
+    }
+    form.value.scanEnabled = form.value.scanEnabled ? '1' : '0';
+
+    if (form.value.id) {
+      await updateQualityInspectionTask(form.value as RiskInspectionTaskBo);
+      proxy?.$modal.msgSuccess('修改成功');
+    } else {
+      await addQualityInspectionTask(form.value as RiskInspectionTaskBo);
+      proxy?.$modal.msgSuccess('新增成功');
+    }
+    dialog.visible = false;
+    await loadAll();
+  });
+};
+
+const cancel = () => {
+  dialog.visible = false;
+  resetForm();
+};
+
+const closeDialog = () => {
+  dialog.visible = false;
+  resetForm();
+};
+
+const getMethodTag = (m: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
+  const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
+    扫码: 'warning',
+    常规: 'primary'
+  };
+  return (map[m] || 'info') as any;
+};
+
+const getStatusTag = (s: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
+  const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
+    已打卡: 'success',
+    未打卡: 'danger',
+    异常打卡: 'warning'
+  };
+  return (map[s] || 'info') as any;
+};
+
+const getResultTag = (r: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
+  const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
+    正常: 'success',
+    异常: 'danger'
+  };
+  return (map[r] || 'info') as any;
+};
+
+onMounted(async () => {
+  await loadAll();
+  await loadDeptChainUsers();
+});
+</script>
+
+<style scoped lang="scss">
+.inspection-task-page {
+  padding: 16px;
+  height: 100%;
+  overflow-y: auto;
+  box-sizing: border-box;
+}
+.stat-card {
+  border-radius: 8px;
+  :deep(.el-card__body) { padding: 20px; }
+}
+.stat-card-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.stat-info { flex: 1; }
+.stat-title {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 8px;
+  white-space: nowrap;
+}
+.stat-value {
+  font-size: 28px;
+  font-weight: 700;
+  color: #303133;
+  line-height: 1.2;
+}
+.stat-icon {
+  width: 48px;
+  height: 48px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  color: #fff;
+}
+.stat-icon-blue { background: #ecf5ff; color: #409EFF; }
+.stat-icon-orange { background: #fdf6ec; color: #E6A23C; }
+.stat-icon-green { background: #f0f9eb; color: #67C23A; }
+.stat-icon-red { background: #fef0f0; color: #F56C6C; }
+.mb-2 { margin-bottom: 8px; }
+.mt-2 { margin-top: 8px; }
+.readonly-text {
+  color: #303133;
+}
+.audit-opinion.pass {
+  color: #67c23a;
+}
+.audit-opinion.reject {
+  color: #f56c6c;
+}
+</style>

+ 103 - 4
src/views/risk/specialEquipment/index.vue

@@ -153,6 +153,29 @@
           </el-col>
         </el-row>
 
+        <!-- 设备铭牌照片(必填,单张) -->
+        <el-form-item label="设备铭牌照片" prop="nameplateUrl">
+          <div class="upload-row">
+            <el-button type="primary" plain @click="triggerNameplateUpload">选择文件</el-button>
+            <span class="upload-tip">{{ nameplatePhotoPreview ? '已选择 1 个文件' : '未选择任何文件' }}</span>
+            <el-button v-if="nameplatePhotoPreview" link type="danger" style="margin-left: 8px" @click="clearNameplatePhoto">清空</el-button>
+          </div>
+          <input
+            ref="nameplatePhotoInputRef"
+            type="file"
+            accept=".jpg,.jpeg,.png"
+            style="display: none"
+            @change="onNameplatePhotoInputChange"
+          />
+          <div v-if="nameplatePhotoPreview" class="photo-preview-list">
+            <div class="photo-thumb">
+              <el-image :src="nameplatePhotoPreview" :preview-src-list="[nameplatePhotoPreview]" :initial-index="0" fit="cover" />
+              <el-icon class="photo-remove" @click="removeNameplatePhoto"><CircleCloseFilled /></el-icon>
+            </div>
+          </div>
+          <div class="el-upload__tip">支持 JPG、PNG 格式,大小不超过 5MB(必填)</div>
+        </el-form-item>
+
         <el-form-item label="设备照片">
           <div class="upload-row">
             <el-button type="primary" plain @click="triggerEquipmentUpload">选择文件</el-button>
@@ -419,6 +442,10 @@ const equipmentPhotoList = ref<any[]>([]);
 const equipmentPhotoPreview = ref<string[]>([]);
 const equipmentPhotoInputRef = ref<HTMLInputElement>();
 
+// 设备铭牌照片(必填,单张)
+const nameplatePhotoPreview = ref<string>('');
+const nameplatePhotoInputRef = ref<HTMLInputElement>();
+
 // 维护照片队列(自管文件)
 const maintainPreviewPhotos = ref<string[]>([]);
 const maintainPhotoInputRef = ref<HTMLInputElement>();
@@ -434,7 +461,9 @@ const data = reactive<any>({
     equipmentName: '',
     equipmentType: '',
     location: '',
-    photoList: []
+    photoList: [],
+    nameplateUrl: '',
+    nameplateName: ''
   } as any,
   queryParams: {
     pageNum: 1,
@@ -449,7 +478,20 @@ const data = reactive<any>({
     equipmentCode: [{ required: true, message: '设备编号不能为空', trigger: 'blur' }],
     equipmentName: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
     equipmentType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
-    location: [{ required: true, message: '请输入安装位置', trigger: 'blur' }]
+    location: [{ required: true, message: '请输入安装位置', trigger: 'blur' }],
+    // 设备铭牌照片:必填(新增校验,失败时弹错)
+    nameplateUrl: [
+      {
+        validator: (_rule: any, value: any, callback: any) => {
+          if (!value) {
+            callback(new Error('请上传设备铭牌照片'));
+          } else {
+            callback();
+          }
+        },
+        trigger: 'change'
+      }
+    ]
   }
 });
 
@@ -534,7 +576,7 @@ const recordEquipmentName = ref('');
 
 const mapDictData = (list: DictDataVO[]) => {
   return (list || [])
-    .filter((d) => d.status === '0' || d.status === undefined)
+    .filter((d: any) => d.status === '0' || d.status === undefined)
     .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
     .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
 };
@@ -639,6 +681,8 @@ const handleUpdate = async (row: RiskSpecialEquipmentVo) => {
   const photos = parsePhotoList(form.value.photoList);
   equipmentPhotoPreview.value = [...photos];
   equipmentPhotoList.value = [];
+  // 回填设备铭牌照片(编辑场景)
+  nameplatePhotoPreview.value = form.value.nameplateUrl || '';
   dialog.visible = true;
   dialog.title = '编辑特种设备';
 };
@@ -701,6 +745,58 @@ const clearEquipmentPhotos = () => {
   equipmentPhotoPreview.value = [];
 };
 
+// ============ 设备铭牌照片上传(必填,单张) ============
+const triggerNameplateUpload = () => {
+  nameplatePhotoInputRef.value?.click();
+};
+
+const onNameplatePhotoInputChange = async (e: Event) => {
+  const input = e.target as HTMLInputElement;
+  const files = Array.from(input.files || []);
+  if (files.length === 0) return;
+  const file = files[0];
+  if (!['image/jpeg', 'image/png', 'image/jpg'].includes(file.type)) {
+    ElMessage.error(`文件 ${file.name} 格式不支持,仅允许 JPG/PNG`);
+    input.value = '';
+    return;
+  }
+  if (file.size / 1024 / 1024 >= 5) {
+    ElMessage.error(`文件 ${file.name} 超过 5MB`);
+    input.value = '';
+    return;
+  }
+  await uploadNameplatePhoto(file);
+  input.value = '';
+};
+
+const uploadNameplatePhoto = async (file: File) => {
+  try {
+    const res: any = await uploadFile(file);
+    const url: string | undefined = res?.url || res?.data?.url;
+    const originalName: string = res?.data?.originalName || file.name;
+    if (url) {
+      nameplatePhotoPreview.value = url;
+      form.value.nameplateUrl = url;
+      form.value.nameplateName = originalName;
+      ElMessage.success(`${file.name} 上传成功`);
+    } else {
+      ElMessage.error(`${file.name} 上传失败`);
+    }
+  } catch (e: any) {
+    ElMessage.error(`${file.name} 上传失败: ${e?.message || '网络异常'}`);
+  }
+};
+
+const removeNameplatePhoto = () => {
+  nameplatePhotoPreview.value = '';
+  form.value.nameplateUrl = '';
+  form.value.nameplateName = '';
+};
+
+const clearNameplatePhoto = () => {
+  removeNameplatePhoto();
+};
+
 // ============ 维护照片上传 ============
 const triggerMaintainUpload = () => {
   maintainPhotoInputRef.value?.click();
@@ -776,10 +872,13 @@ const reset = () => {
     equipmentName: '',
     equipmentType: '',
     location: '',
-    photoList: []
+    photoList: [],
+    nameplateUrl: '',
+    nameplateName: ''
   } as any;
   equipmentPhotoPreview.value = [];
   equipmentPhotoList.value = [];
+  nameplatePhotoPreview.value = '';
   formRef.value?.resetFields();
 };
 

+ 1649 - 0
src/views/risk/supervision/index.vue

@@ -0,0 +1,1649 @@
+<template>
+  <div class="p-2 supervision-page">
+    <!-- 页面标题 -->
+    <div class="page-header">
+      <h2 class="page-title">监督抽检 / 平台客诉</h2>
+      <p class="page-desc">门店抽检登记 · 不合格案件处理 · 平台客诉处理</p>
+    </div>
+
+    <!-- ① 顶部 4 张数据看板 -->
+    <el-row :gutter="20" class="mb-2">
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">今日待审核抽检</div>
+              <div class="stat-value">{{ stats.todayPending || 0 }}</div>
+              <div class="stat-extra">
+                <span>当日所有门店抽检单据总量</span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-blue">
+              <el-icon><Document /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">不合格待处理案件</div>
+              <div class="stat-value">{{ stats.pendingCases || 0 }}</div>
+              <div class="stat-extra stat-extra-warning">
+                <span>处于各处置节点的未闭环案件</span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-red">
+              <el-icon><Warning /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">待处理客诉移交</div>
+              <div class="stat-value">{{ stats.pendingComplaints || 0 }}</div>
+              <div class="stat-extra">
+                <span>待处理的消费者客诉单据</span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-orange">
+              <el-icon><Phone /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-card-content">
+            <div class="stat-info">
+              <div class="stat-title">本月抽检合格率</div>
+              <div class="stat-value">{{ (stats.monthPassRate?.passRate ?? 0).toFixed(2) }}<span class="unit">%</span></div>
+              <div class="stat-extra stat-extra-up">
+                <span>合格 {{ stats.monthPassRate?.passCount || 0 }} / 总数 {{ stats.monthPassRate?.totalCount || 0 }} ({{ stats.monthPassRate?.month || '' }})</span>
+              </div>
+            </div>
+            <div class="stat-icon stat-icon-green">
+              <el-icon><DataAnalysis /></el-icon>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- ② 门店抽检登记 -->
+    <el-card shadow="hover" class="mb-2">
+      <template #header>
+        <div class="section-header">
+          <span class="section-title">门店抽检登记</span>
+          <el-button type="primary" :icon="Plus" @click="handleAddInspection">新增抽检单</el-button>
+        </div>
+      </template>
+
+      <el-form ref="inspQueryFormRef" :model="inspQuery" :inline="true" class="filter-bar">
+        <el-form-item label="关键词">
+          <el-input
+            v-model="inspQuery.keyword"
+            placeholder="搜索抽检单号/门店/商品"
+            clearable
+            style="width: 240px"
+            @keyup.enter="handleInspQuery"
+          />
+        </el-form-item>
+        <el-form-item label="抽检结果">
+          <el-select
+            v-model="inspQuery.inspectionResult"
+            placeholder="全部"
+            clearable
+            style="width: 160px"
+            @change="handleInspQuery"
+          >
+            <el-option label="全部" value="" />
+            <el-option
+              v-for="d in inspectionResultDict"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select
+            v-model="inspQuery.status"
+            placeholder="全部"
+            clearable
+            style="width: 160px"
+            @change="handleInspQuery"
+          >
+            <el-option label="全部" value="" />
+            <el-option
+              v-for="d in inspectionStatusDict"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="resetInspQuery">重置</el-button>
+          <el-button type="primary" @click="handleInspQuery">搜索</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table v-loading="inspLoading" :data="inspectionList" border stripe>
+        <el-table-column label="抽检单号" align="center" prop="inspectionCode" width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="门店名称" align="center" prop="storeName" min-width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="抽检时间" align="center" prop="inspectionTime" width="170">
+          <template #default="scope">{{ formatDateTime(scope.row.inspectionTime) }}</template>
+        </el-table-column>
+        <el-table-column label="抽检商品" align="center" prop="productName" min-width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="抽检结果" align="center" width="110">
+          <template #default="scope">
+            <el-tag :type="scope.row.inspectionResult === '抽检合格' ? 'success' : 'danger'">
+              {{ scope.row.inspectionResult || '-' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="不合格原因" align="center" prop="failReason" min-width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="审核状态" align="center" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.status === '已审核' ? 'success' : 'warning'">{{ scope.row.status || '-' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="抽检人" align="center" prop="inspectorName" width="100" />
+        <el-table-column label="操作" align="center" width="280" fixed="right" class-name="small-padding fixed-width">
+          <template #default="scope">
+            <el-button link type="primary" @click="viewInspection(scope.row)">查看详情</el-button>
+            <el-button link type="warning" v-if="scope.row.inspectionResult === '抽检不合格' && !scope.row._hasCase" @click="openCreateCaseFromInspection(scope.row)">案件处理</el-button>
+            <el-button link type="success" v-if="scope.row.status === '待审核'" @click="auditInspection(scope.row)">审核</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination
+        v-show="inspTotal > 0"
+        v-model:page="inspQuery.pageNum"
+        v-model:limit="inspQuery.pageSize"
+        :total="inspTotal"
+        @pagination="getInspectionList"
+      />
+    </el-card>
+
+    <!-- ③ 不合格案件处理 -->
+    <el-card shadow="hover" class="mb-2">
+      <template #header>
+        <div class="section-header">
+          <span class="section-title">不合格案件处理</span>
+          <el-tag size="small">5 节点流程:风险控制 → 异议复检 → 调查取证 → 调查报告 → 闭环管理</el-tag>
+        </div>
+      </template>
+
+      <el-form ref="caseQueryFormRef" :model="caseQuery" :inline="true" class="filter-bar">
+        <el-form-item label="关键词">
+          <el-input
+            v-model="caseQuery.keyword"
+            placeholder="搜索案件编号/抽检单号/门店/产品"
+            clearable
+            style="width: 260px"
+            @keyup.enter="handleCaseQuery"
+          />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select
+            v-model="caseQuery.status"
+            placeholder="全部"
+            clearable
+            style="width: 160px"
+            @change="handleCaseQuery"
+          >
+            <el-option label="全部" value="" />
+            <el-option
+              v-for="d in caseStatusDict"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="resetCaseQuery">重置</el-button>
+          <el-button type="primary" @click="handleCaseQuery">搜索</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table v-loading="caseLoading" :data="caseList" border stripe>
+        <el-table-column label="案件编号" align="center" prop="caseCode" width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="关联抽检单" align="center" prop="inspectionCode" width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="涉事门店" align="center" prop="storeName" min-width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="不合格产品" align="center" prop="productName" min-width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="风险等级" align="center" width="110">
+          <template #default="scope">
+            <el-tag :type="getRiskLevelTag(scope.row.riskLevel)">{{ scope.row.riskLevel || '-' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="当前节点" align="center" width="180">
+          <template #default="scope">
+            <span>{{ scope.row.currentNode || '-' }} / 5 · {{ scope.row.nodeName || '' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" align="center" width="170">
+          <template #default="scope">{{ formatDateTime(scope.row.createTime) }}</template>
+        </el-table-column>
+        <el-table-column label="状态" align="center" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.status === '已闭环' ? 'success' : 'warning'">{{ scope.row.status || '-' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" align="center" width="200" fixed="right" class-name="small-padding fixed-width">
+          <template #default="scope">
+            <el-button link type="primary" @click="viewCaseDetail(scope.row)">查看详情</el-button>
+            <el-button
+              link
+              type="success"
+              v-if="canProcessCase(scope.row)"
+              @click="openProcessCase(scope.row)"
+            >处理</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination
+        v-show="caseTotal > 0"
+        v-model:page="caseQuery.pageNum"
+        v-model:limit="caseQuery.pageSize"
+        :total="caseTotal"
+        @pagination="getCaseList"
+      />
+    </el-card>
+
+    <!-- ④ 移交监管部门平台客诉处理 -->
+    <el-card shadow="hover" class="mb-2">
+      <template #header>
+        <div class="section-header">
+          <span class="section-title">移交监管部门平台客诉处理</span>
+          <div>
+            <el-tag size="small" class="mr-1">7 节点流程:风险控制 → 调查取证 → 调查判断 → 部门协同 → 依规处理 → 溯源整改 → 闭环管理</el-tag>
+            <el-button type="primary" :icon="Plus" @click="handleAddComplaint">新增客诉</el-button>
+          </div>
+        </div>
+      </template>
+
+      <el-form ref="cptQueryFormRef" :model="cptQuery" :inline="true" class="filter-bar">
+        <el-form-item label="关键词">
+          <el-input
+            v-model="cptQuery.keyword"
+            placeholder="搜索客诉编号/投诉人/门店/内容"
+            clearable
+            style="width: 260px"
+            @keyup.enter="handleCptQuery"
+          />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select
+            v-model="cptQuery.status"
+            placeholder="全部"
+            clearable
+            style="width: 160px"
+            @change="handleCptQuery"
+          >
+            <el-option label="全部" value="" />
+            <el-option
+              v-for="d in complaintStatusDict"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="resetCptQuery">重置</el-button>
+          <el-button type="primary" @click="handleCptQuery">搜索</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table v-loading="cptLoading" :data="complaintList" border stripe>
+        <el-table-column label="客诉编号" align="center" prop="complaintCode" width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="客诉来源" align="center" width="120">
+          <template #default="scope">{{ getDictLabel(complaintSourceDict, scope.row.source) }}</template>
+        </el-table-column>
+        <el-table-column label="投诉人" align="center" prop="complainantName" width="100" />
+        <el-table-column label="涉事门店" align="center" prop="storeName" min-width="160" :show-overflow-tooltip="true" />
+        <el-table-column label="客诉内容" align="center" prop="complaintContent" min-width="200" :show-overflow-tooltip="true" />
+        <el-table-column label="移交时间" align="center" prop="transferTime" width="170">
+          <template #default="scope">{{ formatDateTime(scope.row.transferTime) }}</template>
+        </el-table-column>
+        <el-table-column label="当前节点" align="center" width="180">
+          <template #default="scope">
+            <span>{{ scope.row.currentNode || '-' }} / 7 · {{ scope.row.nodeName || '' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" align="center" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.status === '已闭环' ? 'success' : 'warning'">{{ scope.row.status || '-' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" align="center" width="200" fixed="right" class-name="small-padding fixed-width">
+          <template #default="scope">
+            <el-button link type="primary" @click="viewComplaintDetail(scope.row)">查看详情</el-button>
+            <el-button
+              link
+              type="success"
+              v-if="scope.row.status === '处置中'"
+              @click="openProcessComplaint(scope.row)"
+            >处理</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination
+        v-show="cptTotal > 0"
+        v-model:page="cptQuery.pageNum"
+        v-model:limit="cptQuery.pageSize"
+        :total="cptTotal"
+        @pagination="getComplaintList"
+      />
+    </el-card>
+
+    <!-- ⑤ 新增抽检单弹窗 -->
+    <el-dialog
+      v-model="inspDialog.visible"
+      :title="inspDialog.title"
+      width="720px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="closeInspDialog"
+    >
+      <el-form ref="inspFormRef" :model="inspForm" :rules="inspRules" label-width="100px">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="门店名称" prop="storeName">
+              <el-input v-model="inspForm.storeName" placeholder="请输入门店名称" maxlength="200" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="抽检时间" prop="inspectionTime">
+              <el-date-picker
+                v-model="inspForm.inspectionTime"
+                type="datetime"
+                placeholder="请选择抽检时间"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                format="YYYY-MM-DD HH:mm"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="抽检商品" prop="productName">
+          <el-input v-model="inspForm.productName" placeholder="请输入抽检商品" maxlength="200" />
+        </el-form-item>
+        <el-form-item label="抽检结果" prop="inspectionResult">
+          <el-radio-group v-model="inspForm.inspectionResult">
+            <el-radio
+              v-for="d in inspectionResultDict"
+              :key="d.value"
+              :value="d.value"
+            >{{ d.label }}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-if="inspForm.inspectionResult === '抽检不合格'" label="不合格原因" prop="failReason">
+          <el-input
+            v-model="inspForm.failReason"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入不合格原因"
+            maxlength="1000"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="抽检人" prop="inspectorName">
+          <el-input v-model="inspForm.inspectorName" placeholder="请输入抽检人姓名" maxlength="50" />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="inspForm.remark" placeholder="备注(选填)" maxlength="500" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="inspDialog.visible = false">取 消</el-button>
+          <el-button type="primary" @click="submitInspForm">保 存</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- ⑥ 抽检详情只读弹窗 -->
+    <el-dialog
+      v-model="inspDetailDialog.visible"
+      title="抽检单详情"
+      width="640px"
+      append-to-body
+      :close-on-click-modal="false"
+    >
+      <el-descriptions :column="2" border size="default">
+        <el-descriptions-item label="抽检单号">{{ inspDetail.inspectionCode }}</el-descriptions-item>
+        <el-descriptions-item label="抽检时间">{{ formatDateTime(inspDetail.inspectionTime) }}</el-descriptions-item>
+        <el-descriptions-item label="门店名称">{{ inspDetail.storeName }}</el-descriptions-item>
+        <el-descriptions-item label="抽检人">{{ inspDetail.inspectorName }}</el-descriptions-item>
+        <el-descriptions-item label="抽检商品" :span="2">{{ inspDetail.productName }}</el-descriptions-item>
+        <el-descriptions-item label="抽检结果">
+          <el-tag :type="inspDetail.inspectionResult === '抽检合格' ? 'success' : 'danger'">
+            {{ inspDetail.inspectionResult }}
+          </el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="审核状态">
+          <el-tag :type="inspDetail.status === '已审核' ? 'success' : 'warning'">{{ inspDetail.status }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item v-if="inspDetail.inspectionResult === '抽检不合格'" label="不合格原因" :span="2">
+          {{ inspDetail.failReason || '—' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="备注" :span="2">{{ inspDetail.remark || '—' }}</el-descriptions-item>
+      </el-descriptions>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="inspDetailDialog.visible = false">关 闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- ⑦ 案件处理详情弹窗(5 节点步骤条 + 当前节点处理 + 历史节点时间线) -->
+    <el-dialog
+      v-model="caseDialog.visible"
+      :title="caseDialog.title"
+      width="960px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="closeCaseDialog"
+    >
+      <div v-loading="caseDialog.loading">
+        <!-- 案件基本信息 -->
+        <el-descriptions :column="3" border size="small" class="mb-2">
+          <el-descriptions-item label="案件编号">{{ caseDetail.caseCode }}</el-descriptions-item>
+          <el-descriptions-item label="关联抽检单">{{ caseDetail.inspectionCode }}</el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <el-tag :type="caseDetail.status === '已闭环' ? 'success' : 'warning'">{{ caseDetail.status }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="涉事门店">{{ caseDetail.storeName }}</el-descriptions-item>
+          <el-descriptions-item label="不合格产品">{{ caseDetail.productName }}</el-descriptions-item>
+          <el-descriptions-item label="风险等级">
+            <el-tag :type="getRiskLevelTag(caseDetail.riskLevel)">{{ caseDetail.riskLevel }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="处理人">{{ caseDetail.handlerName }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{ formatDateTime(caseDetail.createTime) }}</el-descriptions-item>
+          <el-descriptions-item label="风险描述" :span="3">{{ caseDetail.riskDesc }}</el-descriptions-item>
+          <el-descriptions-item label="控制措施" :span="3">{{ caseDetail.controlMeasures }}</el-descriptions-item>
+        </el-descriptions>
+
+        <!-- 5 节点步骤条 -->
+        <el-steps :active="getCaseActive()" finish-status="success" align-center class="mb-2">
+          <el-step v-for="(name, idx) in CASE_NODE_NAMES" :key="idx" :title="`${idx + 1}. ${name}`" />
+        </el-steps>
+
+        <!-- 当前节点处理表单 -->
+        <el-card v-if="caseDetail.status === '处置中'" shadow="never" class="mb-2">
+          <template #header>
+            <span class="section-title">当前节点处理 · {{ caseDetail.nodeName }}</span>
+          </template>
+          <el-form ref="processCaseFormRef" :model="processCaseForm" :rules="processCaseRules" label-width="100px">
+            <el-form-item label="处理意见" prop="opinion">
+              <el-input
+                v-model="processCaseForm.opinion"
+                type="textarea"
+                :rows="3"
+                placeholder="请输入当前节点处理意见"
+                maxlength="2000"
+                show-word-limit
+              />
+            </el-form-item>
+            <el-form-item label="凭证附件">
+              <el-upload
+                v-model:file-list="caseFileList"
+                :http-request="customUpload"
+                :limit="1"
+                :accept="'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png'"
+                :before-upload="beforeAttachmentUpload"
+                :on-success="handleAttachmentSuccess"
+                :on-error="handleUploadError"
+                :on-exceed="handleAttachmentExceed"
+                :on-remove="handleAttachmentRemove"
+              >
+                <el-button type="primary" plain>选择凭证附件</el-button>
+                <template #tip>
+                  <div class="el-upload__tip">支持 PDF/Word/Excel/PPT/图片,单个文件不超过 50MB</div>
+                </template>
+              </el-upload>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" :loading="caseDialog.processing" @click="submitProcessCase">提交并流转下一节点</el-button>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <!-- 历史节点时间线 -->
+        <el-card shadow="never">
+          <template #header>
+            <span class="section-title">历史节点记录</span>
+          </template>
+          <el-empty v-if="!caseDetail.nodeRecords || caseDetail.nodeRecords.length === 0" description="暂无节点记录" :image-size="60" />
+          <el-timeline v-else>
+            <el-timeline-item
+              v-for="n in caseDetail.nodeRecords"
+              :key="n.id"
+              :timestamp="formatDateTime(n.handleTime)"
+              placement="top"
+              :type="n.nodeCode === caseDetail.currentNode ? 'primary' : 'success'"
+            >
+              <div class="timeline-node">
+                <div class="timeline-title">节点{{ n.nodeCode }} · {{ n.nodeName }}</div>
+                <div class="timeline-meta">处理人:{{ n.handlerName || '—' }}</div>
+                <div class="timeline-opinion">{{ n.opinion }}</div>
+                <div v-if="n.attachmentUrl" class="timeline-attach">
+                  <el-link :href="n.attachmentUrl" target="_blank" type="primary">{{ n.attachmentName || '查看附件' }}</el-link>
+                </div>
+              </div>
+            </el-timeline-item>
+          </el-timeline>
+        </el-card>
+      </div>
+
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="caseDialog.visible = false">关 闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- ⑧ 案件处理弹窗(从不合格抽检单发起) -->
+    <el-dialog
+      v-model="createCaseDialog.visible"
+      title="发起案件处理"
+      width="680px"
+      append-to-body
+      :close-on-click-modal="false"
+    >
+      <el-form ref="createCaseFormRef" :model="createCaseForm" :rules="createCaseRules" label-width="100px">
+        <el-form-item label="关联抽检单">
+          <span class="readonly-text">{{ createCaseForm.inspectionCode }} · {{ createCaseForm.storeName }} · {{ createCaseForm.productName }}</span>
+        </el-form-item>
+        <el-form-item label="风险等级" prop="riskLevel">
+          <el-select v-model="createCaseForm.riskLevel" placeholder="请选择风险等级" style="width: 100%">
+            <el-option
+              v-for="d in riskLevelDict"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="风险描述" prop="riskDesc">
+          <el-input v-model="createCaseForm.riskDesc" type="textarea" :rows="3" placeholder="请输入风险描述" maxlength="2000" show-word-limit />
+        </el-form-item>
+        <el-form-item label="控制措施" prop="controlMeasures">
+          <el-input v-model="createCaseForm.controlMeasures" type="textarea" :rows="3" placeholder="请输入控制措施" maxlength="2000" show-word-limit />
+        </el-form-item>
+        <el-form-item label="处理人" prop="handlerName">
+          <el-input v-model="createCaseForm.handlerName" placeholder="请输入处理人姓名" maxlength="50" />
+        </el-form-item>
+        <el-form-item label="初始意见" prop="opinion">
+          <el-input v-model="createCaseForm.opinion" type="textarea" :rows="3" placeholder="请输入风险控制节点处理意见" maxlength="2000" show-word-limit />
+        </el-form-item>
+        <el-form-item label="凭证附件">
+          <el-upload
+            v-model:file-list="caseFileList"
+            :http-request="customUpload"
+            :limit="1"
+            :accept="'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png'"
+            :before-upload="beforeAttachmentUpload"
+            :on-success="handleAttachmentSuccess"
+            :on-error="handleUploadError"
+            :on-exceed="handleAttachmentExceed"
+            :on-remove="handleAttachmentRemove"
+          >
+            <el-button type="primary" plain>选择凭证附件</el-button>
+            <template #tip>
+              <div class="el-upload__tip">支持 PDF/Word/Excel/PPT/图片,单个文件不超过 50MB</div>
+            </template>
+          </el-upload>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="createCaseDialog.visible = false">取 消</el-button>
+          <el-button type="primary" :loading="createCaseDialog.submitting" @click="submitCreateCase">发起案件</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- ⑨ 客诉处理详情弹窗(7 节点步骤条 + 当前节点处理 + 历史节点时间线) -->
+    <el-dialog
+      v-model="cptDialog.visible"
+      title="客诉处理详情"
+      width="960px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="closeCptDialog"
+    >
+      <div v-loading="cptDialog.loading">
+        <el-descriptions :column="3" border size="small" class="mb-2">
+          <el-descriptions-item label="客诉编号">{{ cptDetail.complaintCode }}</el-descriptions-item>
+          <el-descriptions-item label="客诉来源">{{ getDictLabel(complaintSourceDict, cptDetail.source) }}</el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <el-tag :type="cptDetail.status === '已闭环' ? 'success' : 'warning'">{{ cptDetail.status }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="投诉人">{{ cptDetail.complainantName }}</el-descriptions-item>
+          <el-descriptions-item label="联系电话">{{ cptDetail.complainantPhone }}</el-descriptions-item>
+          <el-descriptions-item label="涉事门店">{{ cptDetail.storeName }}</el-descriptions-item>
+          <el-descriptions-item label="移交时间" :span="2">{{ formatDateTime(cptDetail.transferTime) }}</el-descriptions-item>
+          <el-descriptions-item label="客诉内容" :span="3">{{ cptDetail.complaintContent }}</el-descriptions-item>
+        </el-descriptions>
+
+        <el-steps :active="getCptActive()" finish-status="success" align-center class="mb-2">
+          <el-step v-for="(name, idx) in COMPLAINT_NODE_NAMES" :key="idx" :title="`${idx + 1}. ${name}`" />
+        </el-steps>
+
+        <el-card v-if="cptDetail.status === '处置中'" shadow="never" class="mb-2">
+          <template #header>
+            <span class="section-title">当前节点处理 · {{ cptDetail.nodeName }}</span>
+          </template>
+          <el-form ref="processCptFormRef" :model="processCptForm" :rules="processCptRules" label-width="100px">
+            <el-form-item label="处理意见" prop="opinion">
+              <el-input
+                v-model="processCptForm.opinion"
+                type="textarea"
+                :rows="3"
+                placeholder="请输入当前环节处理意见"
+                maxlength="2000"
+                show-word-limit
+              />
+            </el-form-item>
+            <el-form-item label="凭证附件">
+              <el-upload
+                v-model:file-list="cptFileList"
+                :http-request="customUpload"
+                :limit="1"
+                :accept="'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png'"
+                :before-upload="beforeAttachmentUpload"
+                :on-success="handleCptAttachmentSuccess"
+                :on-error="handleUploadError"
+                :on-exceed="handleAttachmentExceed"
+                :on-remove="handleCptAttachmentRemove"
+              >
+                <el-button type="primary" plain>选择凭证附件</el-button>
+                <template #tip>
+                  <div class="el-upload__tip">支持 PDF/Word/Excel/PPT/图片,单个文件不超过 50MB</div>
+                </template>
+              </el-upload>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" :loading="cptDialog.processing" @click="submitProcessComplaint">提交并流转下一环节</el-button>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <el-card shadow="never">
+          <template #header>
+            <span class="section-title">历史节点记录</span>
+          </template>
+          <el-empty v-if="!cptDetail.nodeRecords || cptDetail.nodeRecords.length === 0" description="暂无节点记录" :image-size="60" />
+          <el-timeline v-else>
+            <el-timeline-item
+              v-for="n in cptDetail.nodeRecords"
+              :key="n.id"
+              :timestamp="formatDateTime(n.handleTime)"
+              placement="top"
+              :type="n.nodeCode === cptDetail.currentNode ? 'primary' : 'success'"
+            >
+              <div class="timeline-node">
+                <div class="timeline-title">节点{{ n.nodeCode }} · {{ n.nodeName }}</div>
+                <div class="timeline-meta">处理人:{{ n.handlerName || '—' }}</div>
+                <div class="timeline-opinion">{{ n.opinion }}</div>
+                <div v-if="n.attachmentUrl" class="timeline-attach">
+                  <el-link :href="n.attachmentUrl" target="_blank" type="primary">{{ n.attachmentName || '查看附件' }}</el-link>
+                </div>
+              </div>
+            </el-timeline-item>
+          </el-timeline>
+        </el-card>
+      </div>
+
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="cptDialog.visible = false">关 闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+    <!-- ⑩ 新增客诉弹窗 -->
+    <el-dialog
+      v-model="addCptDialog.visible"
+      title="新增客诉"
+      width="720px"
+      append-to-body
+      :close-on-click-modal="false"
+      @close="closeAddCptDialog"
+    >
+      <el-form ref="addCptFormRef" :model="addCptForm" :rules="addCptRules" label-width="100px">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="客诉来源" prop="source">
+              <el-select v-model="addCptForm.source" placeholder="请选择客诉来源" style="width: 100%">
+                <el-option
+                  v-for="d in complaintSourceDict"
+                  :key="d.value"
+                  :label="d.label"
+                  :value="d.value"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="移交时间" prop="transferTime">
+              <el-date-picker
+                v-model="addCptForm.transferTime"
+                type="datetime"
+                placeholder="请选择移交时间"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                format="YYYY-MM-DD HH:mm"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="投诉人姓名" prop="complainantName">
+              <el-input v-model="addCptForm.complainantName" placeholder="请输入投诉人姓名" maxlength="50" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="联系电话" prop="complainantPhone">
+              <el-input v-model="addCptForm.complainantPhone" placeholder="请输入联系电话" maxlength="20" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="涉事门店" prop="storeName">
+          <el-input v-model="addCptForm.storeName" placeholder="请输入涉事门店名称" maxlength="200" />
+        </el-form-item>
+        <el-form-item label="客诉内容" prop="complaintContent">
+          <el-input v-model="addCptForm.complaintContent" type="textarea" :rows="4" placeholder="请输入客诉内容" maxlength="2000" show-word-limit />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="addCptForm.remark" placeholder="备注(选填)" maxlength="500" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="addCptDialog.visible = false">取 消</el-button>
+          <el-button type="primary" :loading="addCptDialog.submitting" @click="submitAddComplaint">保 存</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup name="Supervision" lang="ts">
+import { ref, reactive, onMounted, getCurrentInstance } from 'vue';
+import type { ComponentInternalInstance } from 'vue';
+import {
+  listInspection,
+  getInspection,
+  addInspection,
+  auditInspection as apiAuditInspection
+} from '@/api/risk/supervisionInspection';
+import {
+  listCase,
+  getCase,
+  addCase,
+  processCase
+} from '@/api/risk/supervisionCase';
+import {
+  listComplaint,
+  getComplaint,
+  addComplaint,
+  processComplaint
+} from '@/api/risk/supervisionComplaint';
+import { getSupervisionStats } from '@/api/risk/supervision';
+import { uploadFile } from '@/api/risk/file';
+import { getDicts } from '@/api/system/dict/data';
+import type { DictDataVO } from '@/api/system/dict/data/types';
+import type {
+  RiskSupervisionInspectionBo,
+  RiskSupervisionInspectionVo,
+  RiskSupervisionCaseBo,
+  RiskSupervisionCaseVo,
+  RiskSupervisionComplaintBo,
+  RiskSupervisionComplaintVo,
+  SupervisionStatsVo
+} from '@/api/risk/types';
+import { ElMessage, FormInstance, UploadUserFile } from 'element-plus';
+import {
+  Document,
+  Warning,
+  Phone,
+  DataAnalysis,
+  Plus
+} from '@element-plus/icons-vue';
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+
+// ============ 常量 ============
+const CASE_NODE_NAMES = ['风险控制', '异议复检', '调查取证', '调查报告', '闭环管理'];
+const COMPLAINT_NODE_NAMES = ['风险控制', '调查取证', '调查判断', '部门协同', '依规处理', '溯源整改', '闭环管理'];
+
+// ============ 字典 ============
+const inspectionResultDict = ref<Array<{ label: string; value: string }>>([]);
+const inspectionStatusDict = ref<Array<{ label: string; value: string }>>([]);
+const riskLevelDict = ref<Array<{ label: string; value: string }>>([]);
+const complaintSourceDict = ref<Array<{ label: string; value: string }>>([]);
+const caseStatusDict = ref<Array<{ label: string; value: string }>>([]);
+const complaintStatusDict = ref<Array<{ label: string; value: string }>>([]);
+
+const mapDictData = (list: DictDataVO[]) =>
+  (list || [])
+    .filter((d: any) => d.status === '0' || d.status === undefined)
+    .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
+    .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
+
+const loadDicts = async () => {
+  try {
+    const [
+      resultRes,
+      statusRes,
+      levelRes,
+      sourceRes,
+      caseStRes,
+      cptStRes
+    ] = await Promise.all([
+      getDicts('risk_supervision_inspection_result'),
+      getDicts('risk_supervision_inspection_status'),
+      getDicts('risk_supervision_risk_level'),
+      getDicts('risk_supervision_complaint_source'),
+      getDicts('risk_supervision_case_status'),
+      getDicts('risk_supervision_complaint_status')
+    ]);
+    inspectionResultDict.value = mapDictData(resultRes.data || []);
+    inspectionStatusDict.value = mapDictData(statusRes.data || []);
+    riskLevelDict.value = mapDictData(levelRes.data || []);
+    complaintSourceDict.value = mapDictData(sourceRes.data || []);
+    caseStatusDict.value = mapDictData(caseStRes.data || []);
+    complaintStatusDict.value = mapDictData(cptStRes.data || []);
+  } catch (e) {
+    console.error('加载监督抽检字典失败', e);
+  }
+};
+
+const getDictLabel = (opts: Array<{ label: string; value: string }>, val: string) => {
+  if (!val) return '';
+  const found = opts.find((o) => o.value === val);
+  return found?.label || val;
+};
+
+// ============ 顶部看板 ============
+const stats = ref<SupervisionStatsVo>({
+  todayPending: 0,
+  pendingCases: 0,
+  pendingComplaints: 0,
+  monthPassRate: { month: '', passCount: 0, totalCount: 0, passRate: 0 }
+});
+
+const loadStats = async () => {
+  try {
+    const res: any = await getSupervisionStats();
+    if (res?.data) {
+      stats.value = { ...stats.value, ...res.data };
+    }
+  } catch (e) {
+    console.error('加载看板失败', e);
+  }
+};
+
+// ============ 抽检单列表 ============
+const inspectionList = ref<RiskSupervisionInspectionVo[]>([]);
+const inspTotal = ref(0);
+const inspLoading = ref(false);
+const inspQuery = ref({
+  pageNum: 1,
+  pageSize: 10,
+  keyword: '',
+  inspectionResult: '',
+  status: ''
+});
+
+const getInspectionList = async () => {
+  inspLoading.value = true;
+  try {
+    const res: any = await listInspection(inspQuery.value);
+    inspectionList.value = res.rows || [];
+    inspTotal.value = res.total || 0;
+  } finally {
+    inspLoading.value = false;
+  }
+};
+
+const handleInspQuery = () => {
+  inspQuery.value.pageNum = 1;
+  getInspectionList();
+};
+
+const resetInspQuery = () => {
+  inspQuery.value = {
+    pageNum: 1,
+    pageSize: 10,
+    keyword: '',
+    inspectionResult: '',
+    status: ''
+  };
+  getInspectionList();
+};
+
+// ============ 案件列表 ============
+const caseList = ref<RiskSupervisionCaseVo[]>([]);
+const caseTotal = ref(0);
+const caseLoading = ref(false);
+const caseQuery = ref({
+  pageNum: 1,
+  pageSize: 10,
+  keyword: '',
+  status: ''
+});
+
+const getCaseList = async () => {
+  caseLoading.value = true;
+  try {
+    const res: any = await listCase(caseQuery.value);
+    caseList.value = res.rows || [];
+    caseTotal.value = res.total || 0;
+  } finally {
+    caseLoading.value = false;
+  }
+};
+
+const handleCaseQuery = () => {
+  caseQuery.value.pageNum = 1;
+  getCaseList();
+};
+
+const resetCaseQuery = () => {
+  caseQuery.value = { pageNum: 1, pageSize: 10, keyword: '', status: '' };
+  getCaseList();
+};
+
+// ============ 客诉列表 ============
+const complaintList = ref<RiskSupervisionComplaintVo[]>([]);
+const cptTotal = ref(0);
+const cptLoading = ref(false);
+const cptQuery = ref({
+  pageNum: 1,
+  pageSize: 10,
+  keyword: '',
+  status: ''
+});
+
+const getComplaintList = async () => {
+  cptLoading.value = true;
+  try {
+    const res: any = await listComplaint(cptQuery.value);
+    complaintList.value = res.rows || [];
+    cptTotal.value = res.total || 0;
+  } finally {
+    cptLoading.value = false;
+  }
+};
+
+const handleCptQuery = () => {
+  cptQuery.value.pageNum = 1;
+  getComplaintList();
+};
+
+const resetCptQuery = () => {
+  cptQuery.value = { pageNum: 1, pageSize: 10, keyword: '', status: '' };
+  getComplaintList();
+};
+
+// ============ 新增抽检单 ============
+const inspDialog = reactive({ visible: false, title: '新增抽检单' });
+const inspFormRef = ref<FormInstance>();
+const inspForm = ref<RiskSupervisionInspectionBo>({
+  id: undefined,
+  inspectionCode: '',
+  storeName: '',
+  inspectionTime: '',
+  productName: '',
+  inspectionResult: '抽检合格',
+  failReason: '',
+  inspectorName: '',
+  remark: ''
+});
+const inspRules = {
+  storeName: [{ required: true, message: '请输入门店名称', trigger: 'blur' }],
+  inspectionTime: [{ required: true, message: '请选择抽检时间', trigger: 'change' }],
+  productName: [{ required: true, message: '请输入抽检商品', trigger: 'blur' }],
+  inspectionResult: [{ required: true, message: '请选择抽检结果', trigger: 'change' }],
+  failReason: [{
+    validator(_: any, val: any, cb: any) {
+      if (inspForm.value.inspectionResult === '抽检不合格' && !val) {
+        cb(new Error('请输入不合格原因'));
+      } else {
+        cb();
+      }
+    },
+    trigger: 'blur'
+  }],
+  inspectorName: [{ required: true, message: '请输入抽检人姓名', trigger: 'blur' }]
+};
+
+const resetInspForm = () => {
+  inspForm.value = {
+    id: undefined,
+    inspectionCode: '',
+    storeName: '',
+    inspectionTime: '',
+    productName: '',
+    inspectionResult: '抽检合格',
+    failReason: '',
+    inspectorName: '',
+    remark: ''
+  };
+  inspFormRef.value?.resetFields();
+};
+
+const handleAddInspection = () => {
+  resetInspForm();
+  inspDialog.title = '新增抽检单';
+  inspDialog.visible = true;
+};
+
+const closeInspDialog = () => {
+  inspDialog.visible = false;
+  resetInspForm();
+};
+
+const submitInspForm = () => {
+  inspFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    try {
+      await addInspection(inspForm.value);
+      ElMessage.success('新增成功');
+      inspDialog.visible = false;
+      await getInspectionList();
+      await loadStats();
+    } catch (e: any) {
+      ElMessage.error(e?.msg || '新增失败');
+    }
+  });
+};
+
+// ============ 抽检详情(只读) ============
+const inspDetailDialog = reactive({ visible: false });
+const inspDetail = ref<RiskSupervisionInspectionVo>({} as any);
+
+const viewInspection = async (row: RiskSupervisionInspectionVo) => {
+  try {
+    const res: any = await getInspection(row.id);
+    inspDetail.value = res.data || {};
+    inspDetailDialog.visible = true;
+  } catch (e) {
+    ElMessage.error('获取抽检单详情失败');
+  }
+};
+
+// ============ 审核抽检单 ============
+const auditInspection = async (row: RiskSupervisionInspectionVo) => {
+  try {
+    await proxy?.$modal.confirm(`确认审核抽检单 [${row.inspectionCode}] 吗?`);
+    await apiAuditInspection(row.id);
+    ElMessage.success('审核成功');
+    await getInspectionList();
+    await loadStats();
+  } catch (e) {
+    // 取消
+  }
+};
+
+// ============ 案件处理详情弹窗 ============
+const caseDialog = reactive({ visible: false, title: '案件处理详情', loading: false, processing: false });
+const caseDetail = ref<RiskSupervisionCaseVo>({} as any);
+const processCaseFormRef = ref<FormInstance>();
+const processCaseForm = ref<RiskSupervisionCaseBo>({
+  id: undefined,
+  opinion: '',
+  attachmentUrl: '',
+  attachmentName: ''
+});
+const processCaseRules = {
+  opinion: [{ required: true, message: '请输入处理意见', trigger: 'blur' }]
+};
+
+const viewCaseDetail = async (row: RiskSupervisionCaseVo) => {
+  caseDialog.title = '案件处理详情';
+  caseDialog.visible = true;
+  caseDialog.loading = true;
+  try {
+    const res: any = await getCase(row.id);
+    caseDetail.value = res.data || {};
+    resetProcessCaseForm();
+  } finally {
+    caseDialog.loading = false;
+  }
+};
+
+const openProcessCase = async (row: RiskSupervisionCaseVo) => {
+  caseDialog.title = `案件处理 · 节点${row.currentNode} · ${row.nodeName}`;
+  caseDialog.visible = true;
+  caseDialog.loading = true;
+  try {
+    const res: any = await getCase(row.id);
+    caseDetail.value = res.data || {};
+    resetProcessCaseForm();
+  } finally {
+    caseDialog.loading = false;
+  }
+};
+
+const resetProcessCaseForm = () => {
+  processCaseForm.value = { id: caseDetail.value?.id, opinion: '', attachmentUrl: '', attachmentName: '' };
+  caseFileList.value = [];
+  processCaseFormRef.value?.resetFields();
+};
+
+const getCaseActive = (): number => {
+  // 已闭环:全部完成 → 5;处置中:currentNode - 1 (el-steps active 是已完成数)
+  if (!caseDetail.value) return 0;
+  if (caseDetail.value.status === '已闭环') return CASE_NODE_NAMES.length;
+  return (caseDetail.value.currentNode || 1) - 1;
+};
+
+const canProcessCase = (row: RiskSupervisionCaseVo): boolean => {
+  if (row.status !== '处置中') return false;
+  // 异议复检 / 闭环管理 节点不展示处理按钮(异议复检为流程节点, 闭环管理为已完成)
+  const noProcessNodes = ['异议复检', '闭环管理'];
+  return !noProcessNodes.includes(row.nodeName || '');
+};
+
+const closeCaseDialog = () => {
+  caseDialog.visible = false;
+  caseDetail.value = {} as any;
+  caseFileList.value = [];
+};
+
+const submitProcessCase = () => {
+  processCaseFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    caseDialog.processing = true;
+    try {
+      processCaseForm.value.id = caseDetail.value.id;
+      await processCase(processCaseForm.value);
+      ElMessage.success('节点处理成功,已流转下一节点');
+      caseDialog.processing = false;
+      // 刷新详情
+      const res: any = await getCase(caseDetail.value.id);
+      caseDetail.value = res.data || {};
+      caseFileList.value = [];
+      processCaseForm.value = { id: caseDetail.value.id, opinion: '', attachmentUrl: '', attachmentName: '' };
+      await getCaseList();
+      await loadStats();
+    } catch (e: any) {
+      caseDialog.processing = false;
+      ElMessage.error(e?.msg || '节点处理失败');
+    }
+  });
+};
+
+// ============ 从不合格抽检单发起案件 ============
+const createCaseDialog = reactive({ visible: false, submitting: false });
+const createCaseFormRef = ref<FormInstance>();
+const createCaseForm = ref<RiskSupervisionCaseBo>({
+  inspectionId: undefined,
+  inspectionCode: '',
+  storeName: '',
+  productName: '',
+  riskLevel: '',
+  riskDesc: '',
+  controlMeasures: '',
+  handlerName: '',
+  opinion: '',
+  attachmentUrl: '',
+  attachmentName: ''
+});
+const createCaseRules = {
+  riskLevel: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
+  riskDesc: [{ required: true, message: '请输入风险描述', trigger: 'blur' }],
+  controlMeasures: [{ required: true, message: '请输入控制措施', trigger: 'blur' }],
+  handlerName: [{ required: true, message: '请输入处理人姓名', trigger: 'blur' }],
+  opinion: [{ required: true, message: '请输入风险控制节点处理意见', trigger: 'blur' }]
+};
+
+const openCreateCaseFromInspection = async (row: RiskSupervisionInspectionVo) => {
+  try {
+    const res: any = await getInspection(row.id);
+    const data = res.data || {};
+    createCaseForm.value = {
+      inspectionId: data.id,
+      inspectionCode: data.inspectionCode,
+      storeName: data.storeName,
+      productName: data.productName,
+      riskLevel: '',
+      riskDesc: '',
+      controlMeasures: '',
+      handlerName: '',
+      opinion: '',
+      attachmentUrl: '',
+      attachmentName: ''
+    };
+    caseFileList.value = [];
+    createCaseDialog.visible = true;
+  } catch (e) {
+    ElMessage.error('加载抽检单失败');
+  }
+};
+
+const submitCreateCase = () => {
+  createCaseFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    createCaseDialog.submitting = true;
+    try {
+      await addCase(createCaseForm.value);
+      ElMessage.success('案件已发起,初始节点:风险控制');
+      createCaseDialog.submitting = false;
+      createCaseDialog.visible = false;
+      caseFileList.value = [];
+      await getCaseList();
+      await getInspectionList();
+      await loadStats();
+    } catch (e: any) {
+      createCaseDialog.submitting = false;
+      ElMessage.error(e?.msg || '案件发起失败');
+    }
+  });
+};
+
+// ============ 客诉处理详情 ============
+const cptDialog = reactive({ visible: false, loading: false, processing: false });
+const cptDetail = ref<RiskSupervisionComplaintVo>({} as any);
+const processCptFormRef = ref<FormInstance>();
+const processCptForm = ref<RiskSupervisionComplaintBo>({
+  id: undefined,
+  opinion: '',
+  processAttachmentUrl: '',
+  processAttachmentName: ''
+});
+const processCptRules = {
+  opinion: [{ required: true, message: '请输入处理意见', trigger: 'blur' }]
+};
+
+const viewComplaintDetail = async (row: RiskSupervisionComplaintVo) => {
+  cptDialog.visible = true;
+  cptDialog.loading = true;
+  try {
+    const res: any = await getComplaint(row.id);
+    cptDetail.value = res.data || {};
+    resetProcessCptForm();
+  } finally {
+    cptDialog.loading = false;
+  }
+};
+
+const openProcessComplaint = async (row: RiskSupervisionComplaintVo) => {
+  cptDialog.visible = true;
+  cptDialog.loading = true;
+  try {
+    const res: any = await getComplaint(row.id);
+    cptDetail.value = res.data || {};
+    resetProcessCptForm();
+  } finally {
+    cptDialog.loading = false;
+  }
+};
+
+const resetProcessCptForm = () => {
+  processCptForm.value = {
+    id: cptDetail.value?.id,
+    opinion: '',
+    processAttachmentUrl: '',
+    processAttachmentName: ''
+  };
+  cptFileList.value = [];
+  processCptFormRef.value?.resetFields();
+};
+
+const getCptActive = (): number => {
+  if (!cptDetail.value) return 0;
+  if (cptDetail.value.status === '已闭环') return COMPLAINT_NODE_NAMES.length;
+  return (cptDetail.value.currentNode || 1) - 1;
+};
+
+const closeCptDialog = () => {
+  cptDialog.visible = false;
+  cptDetail.value = {} as any;
+  cptFileList.value = [];
+};
+
+const submitProcessComplaint = () => {
+  processCptFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    cptDialog.processing = true;
+    try {
+      processCptForm.value.id = cptDetail.value.id;
+      await processComplaint(processCptForm.value);
+      ElMessage.success('客诉环节处理成功,已流转下一环节');
+      cptDialog.processing = false;
+      const res: any = await getComplaint(cptDetail.value.id);
+      cptDetail.value = res.data || {};
+      cptFileList.value = [];
+      processCptForm.value = {
+        id: cptDetail.value.id,
+        opinion: '',
+        processAttachmentUrl: '',
+        processAttachmentName: ''
+      };
+      await getComplaintList();
+      await loadStats();
+    } catch (e: any) {
+      cptDialog.processing = false;
+      ElMessage.error(e?.msg || '客诉节点处理失败');
+    }
+  });
+};
+
+// ============ 新增客诉 ============
+const addCptDialog = reactive({ visible: false, submitting: false });
+const addCptFormRef = ref<FormInstance>();
+const addCptForm = ref<RiskSupervisionComplaintBo>({
+  source: '',
+  complainantName: '',
+  complainantPhone: '',
+  storeName: '',
+  transferTime: '',
+  complaintContent: '',
+  remark: ''
+});
+const addCptRules = {
+  source: [{ required: true, message: '请选择客诉来源', trigger: 'change' }],
+  complainantName: [{ required: true, message: '请输入投诉人姓名', trigger: 'blur' }],
+  storeName: [{ required: true, message: '请输入涉事门店', trigger: 'blur' }],
+  complaintContent: [{ required: true, message: '请输入客诉内容', trigger: 'blur' }]
+};
+
+const resetAddCptForm = () => {
+  addCptForm.value = {
+    source: '',
+    complainantName: '',
+    complainantPhone: '',
+    storeName: '',
+    transferTime: '',
+    complaintContent: '',
+    remark: ''
+  };
+  addCptFormRef.value?.resetFields();
+};
+
+const handleAddComplaint = () => {
+  resetAddCptForm();
+  addCptDialog.visible = true;
+};
+
+const closeAddCptDialog = () => {
+  addCptDialog.visible = false;
+  resetAddCptForm();
+};
+
+const submitAddComplaint = () => {
+  addCptFormRef.value?.validate(async (valid: boolean) => {
+    if (!valid) return;
+    addCptDialog.submitting = true;
+    try {
+      await addComplaint(addCptForm.value);
+      ElMessage.success('新增客诉成功');
+      addCptDialog.submitting = false;
+      addCptDialog.visible = false;
+      await getComplaintList();
+      await loadStats();
+    } catch (e: any) {
+      addCptDialog.submitting = false;
+      ElMessage.error(e?.msg || '新增客诉失败');
+    }
+  });
+};
+
+// ============ 文件上传 ============
+const ATTACH_EXTS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png'];
+const ATTACH_MAX_SIZE = 50 * 1024 * 1024;
+const caseFileList = ref<UploadUserFile[]>([]);
+const cptFileList = ref<UploadUserFile[]>([]);
+
+const customUpload = async (options: any) => {
+  try {
+    const res: any = await uploadFile(options.file);
+    if (res && res.code === 200 && res.data) {
+      options.file.url = res.data.url;
+      options.onSuccess(res, options.file);
+    } else {
+      options.onError(new Error(res?.msg || '上传失败'));
+    }
+  } catch (err: any) {
+    options.onError(err);
+  }
+};
+
+const beforeAttachmentUpload = (file: any) => {
+  const ext = (file.name.split('.').pop() || '').toLowerCase();
+  if (!ATTACH_EXTS.includes(ext)) {
+    ElMessage.warning(`不支持 .${ext},仅允许: ${ATTACH_EXTS.join('/')}`);
+    return false;
+  }
+  if (file.size > ATTACH_MAX_SIZE) {
+    ElMessage.warning(`${file.name} 超过 50MB 上限`);
+    return false;
+  }
+  return true;
+};
+
+const handleAttachmentSuccess = (response: any, file: any) => {
+  if (response && response.code === 200) {
+    ElMessage.success(`${file.name} 上传成功`);
+    const url = response.data?.url || file.url;
+    const name = response.data?.originalName || file.name;
+    if (createCaseDialog.visible) {
+      createCaseForm.value.attachmentUrl = url;
+      createCaseForm.value.attachmentName = name;
+    } else {
+      processCaseForm.value.attachmentUrl = url;
+      processCaseForm.value.attachmentName = name;
+    }
+  } else {
+    ElMessage.error(`${file.name} 上传失败: ${response?.msg || '未知错误'}`);
+  }
+};
+
+const handleCptAttachmentSuccess = (response: any, file: any) => {
+  if (response && response.code === 200) {
+    ElMessage.success(`${file.name} 上传成功`);
+    const url = response.data?.url || file.url;
+    const name = response.data?.originalName || file.name;
+    processCptForm.value.processAttachmentUrl = url;
+    processCptForm.value.processAttachmentName = name;
+  } else {
+    ElMessage.error(`${file.name} 上传失败: ${response?.msg || '未知错误'}`);
+  }
+};
+
+const handleUploadError = (error: any) => {
+  console.error('文件上传失败', error);
+  ElMessage.error(`文件上传失败: ${error?.message || '网络异常'}`);
+};
+
+const handleAttachmentExceed = () => {
+  ElMessage.warning('最多只能上传 1 个附件,如需更换请先删除已有附件');
+};
+
+const handleAttachmentRemove = () => {
+  if (createCaseDialog.visible) {
+    createCaseForm.value.attachmentUrl = '';
+    createCaseForm.value.attachmentName = '';
+  } else {
+    processCaseForm.value.attachmentUrl = '';
+    processCaseForm.value.attachmentName = '';
+  }
+};
+
+const handleCptAttachmentRemove = () => {
+  processCptForm.value.processAttachmentUrl = '';
+  processCptForm.value.processAttachmentName = '';
+};
+
+// ============ 工具 ============
+const getRiskLevelTag = (level: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
+  const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
+    高风险: 'danger',
+    中风险: 'warning',
+    低风险: 'info'
+  };
+  return map[level] || 'info';
+};
+
+const formatDateTime = (val: any): string => {
+  if (!val) return '';
+  const d = new Date(val);
+  if (isNaN(d.getTime())) return String(val);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+};
+
+// ============ 生命周期 ============
+onMounted(async () => {
+  await loadDicts();
+  await Promise.all([loadStats(), getInspectionList(), getCaseList(), getComplaintList()]);
+});
+</script>
+
+<style scoped lang="scss">
+.supervision-page {
+  padding: 16px;
+  height: 100%;
+  overflow-y: auto;
+  box-sizing: border-box;
+}
+.page-header {
+  margin-bottom: 16px;
+  .page-title {
+    font-size: 20px;
+    font-weight: 600;
+    margin: 0;
+    color: #303133;
+  }
+  .page-desc {
+    font-size: 13px;
+    color: #909399;
+    margin: 4px 0 0;
+  }
+}
+.mb-2 { margin-bottom: 16px; }
+.mr-1 { margin-right: 8px; }
+.section-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+.section-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+.filter-bar {
+  margin-bottom: 16px;
+}
+.stat-card {
+  border-radius: 8px;
+  :deep(.el-card__body) { padding: 20px; }
+}
+.stat-card-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.stat-info { flex: 1; min-width: 0; }
+.stat-title {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 8px;
+  white-space: nowrap;
+}
+.stat-value {
+  font-size: 26px;
+  font-weight: 700;
+  color: #303133;
+  line-height: 1.2;
+  margin-bottom: 8px;
+  .unit {
+    font-size: 14px;
+    font-weight: 400;
+    margin-left: 2px;
+    color: #606266;
+  }
+}
+.stat-extra {
+  font-size: 12px;
+  color: #909399;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
+.stat-extra-up { color: #67c23a; }
+.stat-extra-warning {
+  color: #e6a23c;
+  font-weight: 600;
+}
+.stat-icon {
+  width: 48px;
+  height: 48px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  color: #fff;
+}
+.stat-icon-blue { background: #ecf5ff; color: #409EFF; }
+.stat-icon-red { background: #fef0f0; color: #f56c6c; }
+.stat-icon-orange { background: #fdf6ec; color: #E6A23C; }
+.stat-icon-green { background: #f0f9eb; color: #67C23A; }
+.readonly-text {
+  color: #606266;
+  font-size: 14px;
+}
+.muted { color: #909399; font-size: 12px; }
+.dialog-footer { text-align: right; }
+.el-upload__tip {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+  margin-top: 4px;
+}
+.timeline-node {
+  .timeline-title {
+    font-size: 14px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 4px;
+  }
+  .timeline-meta {
+    font-size: 12px;
+    color: #909399;
+    margin-bottom: 6px;
+  }
+  .timeline-opinion {
+    font-size: 13px;
+    color: #606266;
+    line-height: 1.6;
+    white-space: pre-wrap;
+    word-break: break-all;
+    margin-bottom: 6px;
+  }
+  .timeline-attach {
+    font-size: 12px;
+  }
+}
+</style>

+ 203 - 5
src/views/risk/warning/index.vue

@@ -173,6 +173,36 @@
             style="width: 100%"
           />
         </el-form-item>
+
+        <el-form-item label="附件上传">
+          <el-upload
+            v-model:file-list="attachmentFileList"
+            :http-request="customUpload"
+            :limit="1"
+            :accept="'.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png'"
+            :before-upload="beforeAttachmentUpload"
+            :on-success="handleAttachmentSuccess"
+            :on-error="handleUploadError"
+            :on-exceed="handleAttachmentExceed"
+            :on-remove="handleAttachmentRemove"
+          >
+            <template #trigger>
+              <div class="upload-trigger-area">
+                <div class="upload-action-row">
+                  <el-button type="primary" plain>选择文件</el-button>
+                  <span class="upload-placeholder">{{ attachmentFileList.length > 0 ? '已选择 1 个文件' : '未选择任何文件' }}</span>
+                </div>
+                <div class="upload-tip-box">
+                  <div class="upload-tip-icon">
+                    <el-icon><UploadFilled /></el-icon>
+                  </div>
+                  <div class="upload-tip-text">点击上传或拖拽文件到此区域</div>
+                  <div class="upload-tip-desc">支持 PDF、Word、Excel、图片格式,单个文件不超过10MB</div>
+                </div>
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
       </el-form>
       <template #footer>
         <div class="dialog-footer">
@@ -198,6 +228,18 @@
           <div class="multi-line">{{ viewForm.warningContent || '—' }}</div>
         </el-descriptions-item>
         <el-descriptions-item label="备注">{{ viewForm.remark || '—' }}</el-descriptions-item>
+        <el-descriptions-item v-if="viewForm.attachmentUrl" label="附件">
+          <a
+            class="attachment-link"
+            :href="viewForm.attachmentUrl"
+            target="_blank"
+            rel="noopener noreferrer"
+            @click.prevent="handleDownloadAttachment(viewForm.attachmentUrl)"
+          >
+            <el-icon><Document /></el-icon>
+            <span>{{ viewForm.attachmentName || '下载附件' }}</span>
+          </a>
+        </el-descriptions-item>
       </el-descriptions>
       <template #footer>
         <el-button @click="viewDialog.visible = false">关闭</el-button>
@@ -213,8 +255,9 @@ import { listWarning, getWarning, addWarning, delWarning } from '@/api/risk/warn
 import { getDicts } from '@/api/system/dict/data';
 import type { DictDataVO } from '@/api/system/dict/data/types';
 import type { RiskWarningBo, RiskWarningVo } from '@/api/risk/types';
-import { ElMessage, FormInstance } from 'element-plus';
-import { Plus } from '@element-plus/icons-vue';
+import { uploadFile } from '@/api/risk/file';
+import { ElMessage, FormInstance, UploadUserFile } from 'element-plus';
+import { Document, Plus, UploadFilled } from '@element-plus/icons-vue';
 import type { ComponentInternalInstance } from 'vue';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
@@ -226,6 +269,7 @@ const total = ref(0);
 const dateRange = ref<string[]>([]);
 const warningTypeOptions = ref<Array<{ label: string; value: string }>>([]);
 const warningLevelOptions = ref<Array<{ label: string; value: string }>>([]);
+const attachmentFileList = ref<UploadUserFile[]>([]);
 
 const queryFormRef = ref<FormInstance>();
 const formRef = ref<FormInstance>();
@@ -257,7 +301,7 @@ const getDictLabel = (opts: Array<{ label: string; value: string }>, val: string
 
 const mapDictData = (list: DictDataVO[]) => {
   return (list || [])
-    .filter((d) => d.status === '0' || d.status === undefined)
+    .filter((d: any) => d.status === '0' || d.status === undefined)
     .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
     .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
 };
@@ -288,7 +332,9 @@ const data = reactive<any>({
     expireTime: '',
     releaserName: '',
     publishScope: '全员',
-    status: '已发布'
+    status: '已发布',
+    attachmentUrl: '',
+    attachmentName: ''
   } as any,
   queryParams: {
     pageNum: 1,
@@ -393,6 +439,16 @@ const submitForm = () => {
       // 自动填充发布人
       form.value.releaserName = form.value.releaserName || userStore.nickname || 'admin';
       form.value.publishScope = '全员';
+      // 提取附件 URL 与原始文件名
+      const attach: any = attachmentFileList.value.find((f: any) => f.status === 'success');
+      if (attach) {
+        form.value.attachmentUrl =
+          attach.url || attach.response?.data?.url || attach.response?.data?.ossId || '';
+        form.value.attachmentName = attach.name || attach.response?.data?.originalName || '';
+      } else {
+        form.value.attachmentUrl = '';
+        form.value.attachmentName = '';
+      }
       await addWarning(form.value as RiskWarningBo);
       proxy?.$modal.msgSuccess('发布成功');
       dialog.visible = false;
@@ -419,8 +475,11 @@ const reset = () => {
     expireTime: '',
     releaserName: '',
     publishScope: '全员',
-    status: '已发布'
+    status: '已发布',
+    attachmentUrl: '',
+    attachmentName: ''
   } as any;
+  attachmentFileList.value = [];
   formRef.value?.resetFields();
 };
 
@@ -429,6 +488,91 @@ const closeDialog = () => {
   reset();
 };
 
+// ============ 附件上传(MinIO) ============
+const ALLOWED_EXTS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png'];
+const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+
+/**
+ * 自定义上传(走 axios baseURL,避免跨域/405)
+ */
+const customUpload = async (options: any) => {
+  try {
+    const res: any = await uploadFile(options.file);
+    if (res && res.code === 200 && res.data) {
+      // 把后端返回的 url 写入 file 对象,方便 onSuccess 后回填
+      options.file.url = res.data.url;
+      options.onSuccess(res, options.file);
+    } else {
+      options.onError(new Error(res?.msg || '上传失败'));
+    }
+  } catch (err: any) {
+    options.onError(err);
+  }
+};
+
+/**
+ * 上传前校验扩展名 + 大小
+ */
+const beforeAttachmentUpload = (file: any) => {
+  const ext = (file.name.split('.').pop() || '').toLowerCase();
+  if (!ALLOWED_EXTS.includes(ext)) {
+    ElMessage.warning(`不支持的文件类型 .${ext},仅允许: ${ALLOWED_EXTS.join('/')}`);
+    return false;
+  }
+  if (file.size > MAX_FILE_SIZE) {
+    ElMessage.warning(`文件 ${file.name} 超过 10MB 上限`);
+    return false;
+  }
+  return true;
+};
+
+/**
+ * 单个文件上传成功
+ */
+const handleAttachmentSuccess = (response: any, file: any) => {
+  if (response && response.code === 200) {
+    ElMessage.success(`附件 ${file.name} 上传成功`);
+  } else {
+    ElMessage.error(`附件 ${file.name} 上传失败: ${response?.msg || '未知错误'}`);
+  }
+};
+
+/**
+ * 上传失败
+ */
+const handleUploadError = (error: any) => {
+  console.error('附件上传失败', error);
+  ElMessage.error(`附件上传失败: ${error?.message || '网络异常'}`);
+};
+
+/**
+ * 超过上传数量限制
+ */
+const handleAttachmentExceed = () => {
+  ElMessage.warning('最多只能上传 1 个附件,如需更换请先删除已有附件');
+};
+
+/**
+ * 移除附件
+ */
+const handleAttachmentRemove = () => {
+  form.value.attachmentUrl = '';
+  form.value.attachmentName = '';
+  ElMessage.success('已移除附件');
+};
+
+/**
+ * 下载附件(详情弹窗点击触发)
+ */
+const handleDownloadAttachment = (url?: string) => {
+  if (!url) {
+    ElMessage.warning('该警示未上传附件');
+    return;
+  }
+  // MinIO public 桶,直接打开即可下载
+  window.open(url, '_blank');
+};
+
 const formatDateTime = (val: any): string => {
   if (!val) return '';
   const d = new Date(val);
@@ -485,4 +629,58 @@ onMounted(async () => {
   word-break: break-all;
   line-height: 1.6;
 }
+.attachment-link {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  color: #409eff;
+  text-decoration: none;
+  font-size: 13px;
+  cursor: pointer;
+  transition: color 0.2s;
+}
+.attachment-link:hover {
+  color: #66b1ff;
+  text-decoration: underline;
+}
+.attachment-link .el-icon {
+  font-size: 16px;
+}
+.upload-trigger-area {
+  width: 100%;
+}
+.upload-action-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+.upload-placeholder {
+  font-size: 13px;
+  color: #909399;
+}
+.upload-tip-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 16px 0;
+  border: 1px dashed #d9d9d9;
+  border-radius: 4px;
+  background: #fafafa;
+}
+.upload-tip-icon {
+  color: #c0c4cc;
+  font-size: 24px;
+  margin-bottom: 6px;
+}
+.upload-tip-text {
+  font-size: 13px;
+  color: #606266;
+}
+.upload-tip-desc {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+}
 </style>

+ 124 - 0
src/views/risk/workReport/index.vue

@@ -61,6 +61,12 @@
           :show-overflow-tooltip="true"
           min-width="180"
         />
+        <el-table-column
+          label="工期(天)"
+          align="center"
+          prop="durationDays"
+          width="90"
+        />
         <el-table-column
           label="施工开始时间"
           align="center"
@@ -136,6 +142,24 @@
           </el-col>
         </el-row>
 
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="工期" prop="durationDays">
+              <el-input-number
+                v-model="form.durationDays"
+                :min="1"
+                :max="3650"
+                :step="1"
+                placeholder="请输入工期,如:3天"
+                controls-position="right"
+                style="width: 100%"
+                @change="handleDurationChange"
+              />
+              <span class="form-tip">单位:天,录入工期+开始时间自动算结束时间</span>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
         <el-row :gutter="20">
           <el-col :span="12">
             <el-form-item label="施工开始时间" prop="workStartTime">
@@ -146,6 +170,7 @@
                 value-format="YYYY-MM-DD HH:mm:ss"
                 format="YYYY-MM-DD HH:mm"
                 style="width: 100%"
+                @change="handleStartTimeChange"
               />
             </el-form-item>
           </el-col>
@@ -158,6 +183,7 @@
                 value-format="YYYY-MM-DD HH:mm:ss"
                 format="YYYY-MM-DD HH:mm"
                 style="width: 100%"
+                @change="handleEndTimeChange"
               />
             </el-form-item>
           </el-col>
@@ -266,6 +292,9 @@
         <el-descriptions-item label="项目名称">
           {{ viewForm.workTitle }}
         </el-descriptions-item>
+        <el-descriptions-item label="工期(天)">
+          {{ viewForm.durationDays ?? '-' }}
+        </el-descriptions-item>
         <el-descriptions-item label="施工开始时间">
           {{ viewForm.workStartTime }}
         </el-descriptions-item>
@@ -414,6 +443,7 @@ const data = reactive<any>({
     workContent: '',
     workStartTime: '',
     workEndTime: '',
+    durationDays: undefined,
     reporterName: '',
     contactPhone: '',
     photoList: [],
@@ -432,6 +462,21 @@ const data = reactive<any>({
   rules: {
     workType: [{ required: true, message: '请选择作业类型', trigger: 'change' }],
     workTitle: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
+    durationDays: [
+      { required: true, message: '请输入工期', trigger: 'blur' },
+      {
+        validator: (_rule: any, value: any, callback: any) => {
+          if (value === undefined || value === null || value === '') {
+            callback(new Error('请输入工期'));
+          } else if (Number(value) < 1) {
+            callback(new Error('工期必须大于等于1天'));
+          } else {
+            callback();
+          }
+        },
+        trigger: 'blur'
+      }
+    ],
     workStartTime: [{ required: true, message: '请选择施工开始时间', trigger: 'change' }],
     workEndTime: [
       { required: true, message: '请选择施工结束时间', trigger: 'change' },
@@ -651,6 +696,7 @@ const reset = () => {
     workContent: '',
     workStartTime: '',
     workEndTime: '',
+    durationDays: undefined,
     reporterName: '',
     contactPhone: '',
     photoList: [],
@@ -669,6 +715,78 @@ const closeDialog = () => {
   reset();
 };
 
+// ============ 工期 ↔ 时间 联动计算 ============
+
+/**
+ * 根据工期(天)和开始时间,计算结束时间字符串
+ */
+const computeEndTimeByDuration = (startTime: string, days: number): string => {
+  if (!startTime || !days || days <= 0) return '';
+  const start = new Date(startTime);
+  if (isNaN(start.getTime())) return '';
+  const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000);
+  // 格式化为 YYYY-MM-DD HH:mm:ss (与 value-format 一致)
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return (
+    end.getFullYear() +
+    '-' +
+    pad(end.getMonth() + 1) +
+    '-' +
+    pad(end.getDate()) +
+    ' ' +
+    pad(end.getHours()) +
+    ':' +
+    pad(end.getMinutes()) +
+    ':' +
+    pad(end.getSeconds())
+  );
+};
+
+/**
+ * 根据开始时间和结束时间,向上取整计算工期(天)
+ * 1ms ~ 1天 都视为1天
+ */
+const computeDurationByRange = (startTime: string, endTime: string): number => {
+  if (!startTime || !endTime) return 0;
+  const start = new Date(startTime);
+  const end = new Date(endTime);
+  if (isNaN(start.getTime()) || isNaN(end.getTime())) return 0;
+  const diff = end.getTime() - start.getTime();
+  if (diff <= 0) return 0;
+  const oneDay = 24 * 60 * 60 * 1000;
+  return Math.ceil(diff / oneDay);
+};
+
+/** 工期变化 → 若有开始时间,自动算出结束时间 */
+const handleDurationChange = (val: number | undefined) => {
+  if (!val || val <= 0) return;
+  if (form.value.workStartTime) {
+    form.value.workEndTime = computeEndTimeByDuration(form.value.workStartTime, val);
+  }
+};
+
+/** 开始时间变化 → 若结束时间已填,重算工期;否则若工期已填,重算结束时间 */
+const handleStartTimeChange = (val: string | null) => {
+  if (!val) return;
+  if (form.value.workEndTime) {
+    // 结束时间已有 → 重算工期(用户期望改开始时间后工期随之变化)
+    const days = computeDurationByRange(val, form.value.workEndTime);
+    if (days > 0) form.value.durationDays = days;
+  } else if (form.value.durationDays && form.value.durationDays > 0) {
+    // 结束时间为空 + 工期有值 → 重算结束时间
+    form.value.workEndTime = computeEndTimeByDuration(val, form.value.durationDays);
+  }
+};
+
+/** 结束时间变化 → 若开始时间已填,重算工期 */
+const handleEndTimeChange = (val: string | null) => {
+  if (!val) return;
+  if (form.value.workStartTime) {
+    const days = computeDurationByRange(form.value.workStartTime, val);
+    if (days > 0) form.value.durationDays = days;
+  }
+};
+
 // ============ 上传相关 ============
 
 const handleUploadError = () => {
@@ -912,6 +1030,12 @@ onMounted(() => {
 .readonly-text {
   color: #303133;
 }
+.form-tip {
+  display: inline-block;
+  margin-left: 8px;
+  font-size: 12px;
+  color: #909399;
+}
 .audit-opinion.pass {
   color: #67c23a;
 }