25 コミット 75356f9214 ... 915eafaefa

作者 SHA1 メッセージ 日付
  yangjingjing 915eafaefa feat(risk): 巡检任务编辑 - 扫码时间根据巡检时段自动同步 1 ヶ月 前
  yangjingjing a22af8d1d6 feat(risk): 地图选点组件 + 字典化 + 审计 UI 重做 + 401 修复 1 ヶ月 前
  yangjingjing f9fab2936f feat(inspection): 扫码任务弹窗补 3 字段(设备名称/开启时间/间隔) 1 ヶ月 前
  yangjingjing eef1357ed4 fix(inspection): 巡检结果 0/1 翻译成'正常/异常'中文 + 颜色正确(绿/红) 1 ヶ月 前
  yangjingjing 033448ef2a fix(inspection): 打卡详情照片列从 URL 文本改为 el-image 缩略图(支持多图/点击放大) 1 ヶ月 前
  yangjingjing fa6ebd5aeb fix(inspection): 修扫码开关状态丢失 + 打卡项目 itemType→itemCategory + 拍照回显写死 1 ヶ月 前
  yangjingjing 8c2c059e36 feat(inspection): 新增/编辑弹窗门店改下拉(总部+门店) + storeId 强转 string 匹配 1 ヶ月 前
  yangjingjing cf9712edb8 feat(inspection): 巡检管理筛选-门店改下拉(总部+65门店) + 同步 quality 版 1 ヶ月 前
  yangjingjing 0612627e3d fix: 更新前端配置及风险相关页面 1 ヶ月 前
  yangjingjing 3877b8f3de feat(risk): 作业上报加工期字段 + 列表/详情展示 1 ヶ月 前
  yangjingjing 4b58e21eff fix(risk): 作业上报按设计稿改字典 + 删工期列 1 ヶ月 前
  yangjingjing c9f80706fc feat(inspection): 巡检任务表单加「打卡设备编码」字段(开启扫码时显示) 1 ヶ月 前
  yangjingjing ab0d7734d7 fix(auth): 修「重新登录」点完不跳登录页的递归弹窗 bug 1 ヶ月 前
  yangjingjing 3aaf5845c5 fix(risk): 危险源上报按设计稿精简字段 1 ヶ月 前
  yangjingjing 20663be423 fix(risk): 抽检/客诉商品字段「供应商」→「生产商」 1 ヶ月 前
  yangjingjing fff65aa305 feat(risk): 岗位证书 tag 按证书类型区分颜色 1 ヶ月 前
  yangjingjing f9ee41b550 feat(risk): 人员信息页工作门店改 el-select 拉门店 1 ヶ月 前
  yangjingjing 7d167fa5e4 feat(risk): 风险警示模块按设计稿实现 1 ヶ月 前
  yangjingjing 25c3b0fa96 feat: 监督抽检/平台客诉页面升级 1 ヶ月 前
  yangjingjing 2dab4d6e7e feat(big-screen): 移除按钮, 菜单点击自动浏览器原生全屏 + ESC 退出 2 ヶ月 前
  yangjingjing 90873856f3 fix(big-screen): 全屏后 UI 自适应 100vh 2 ヶ月 前
  yangjingjing cd62c1a152 fix(layout): 大屏全屏判定同时按路径 fallback 2 ヶ月 前
  yangjingjing 7cf248f270 feat(big-screen): 点击风控大屏菜单直接全屏展示 2 ヶ月 前
  yangjingjing 3e480072cc fix(big-screen): 严格按参考图重写大屏 UI 布局 2 ヶ月 前
  yangjingjing cdc8a09029 feat(大屏 3.3): 新增大屏主页面 + API + 路由注册 (暗色主题/6卡片/5图表/预警走马灯) 2 ヶ月 前

+ 3 - 0
.env.development

@@ -11,6 +11,9 @@ VITE_APP_BASE_API = '/dev-api'
 # 应用访问路径 例如使用前缀 /admin/
 VITE_APP_CONTEXT_PATH = '/'
 
+# 腾讯地图 API Key(地图选点组件使用)
+VITE_QQMAP_KEY = 'I4OBZ-ULG6Q-JXZ5N-4HSCN-PE4AZ-3TFRU'
+
 # 监控地址
 VITE_APP_MONITOR_ADMIN = 'http://localhost:9090/admin/applications'
 

+ 3 - 0
.env.production

@@ -8,6 +8,9 @@ VITE_APP_ENV = 'production'
 # 应用访问路径 例如使用前缀 /admin/
 VITE_APP_CONTEXT_PATH = '/'
 
+# 腾讯地图 API Key(地图选点组件使用)
+VITE_QQMAP_KEY = 'I4OBZ-ULG6Q-JXZ5N-4HSCN-PE4AZ-3TFRU'
+
 # 监控地址
 VITE_APP_MONITOR_ADMIN = '/admin/applications'
 

+ 0 - 4
.eslintrc-auto-import.json

@@ -5,10 +5,6 @@
     "ComputedRef": true,
     "DirectiveBinding": true,
     "EffectScope": true,
-    "ElLoading": true,
-    "ElMessage": true,
-    "ElMessageBox": true,
-    "ElNotification": true,
     "ExtractDefaultPropTypes": true,
     "ExtractPropTypes": true,
     "ExtractPublicPropTypes": true,

+ 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 }
+  });
+};

+ 2 - 2
src/api/risk/foodSafetyCampaign.ts

@@ -73,11 +73,11 @@ export const startFoodSafetyCampaign = (id: string | number): AxiosPromise => {
 };
 
 /**
- * 活动结束(状态:进行中 → 已结束),body 中传 summary
+ * 活动结束(状态:进行中 → 已结束),body 中传 summary + actualParticipants
  */
 export const finishFoodSafetyCampaign = (
   id: string | number,
-  data: { summary: string }
+  data: { summary: string; actualParticipants: number }
 ): AxiosPromise => {
   return request({
     url: '/risk/foodSafety/campaign/finish/' + id,

+ 9 - 1
src/api/risk/inspectionRecord.ts

@@ -1,6 +1,6 @@
 import request from '@/utils/request';
 import { AxiosPromise } from 'axios';
-import { RiskInspectionRecordBo, RiskInspectionRecordVo } from '@/api/risk/types';
+import { RiskInspectionRecordBo, RiskInspectionRecordVo, RiskInspectionRecordItemVo } from '@/api/risk/types';
 
 // 查询巡检打卡记录列表
 export const listInspectionRecord = (query: RiskInspectionRecordBo): AxiosPromise<RiskInspectionRecordVo[]> => {
@@ -61,4 +61,12 @@ export const exportInspectionRecord = (query: RiskInspectionRecordBo): AxiosProm
     method: 'post',
     params: query
   });
+};
+
+// 查询某条打卡记录的项目明细(详情弹窗用,展示 APP 端提交的每个 item 结果)
+export const listInspectionRecordItemByRecord = (recordId: string | number): AxiosPromise<RiskInspectionRecordItemVo[]> => {
+  return request({
+    url: '/risk/inspectionRecordItem/listByRecord/' + recordId,
+    method: 'get'
+  });
 };

+ 127 - 2
src/api/risk/types.ts

@@ -749,6 +749,14 @@ export interface RiskInspectionTaskVo {
   createTime: string;
   updateBy: string;
   updateTime: string;
+  /** 打卡状态(由 Service 填充,关联 APP 端) */
+  recordStatus?: string;
+  /** 打卡结果 0/1(由 Service 填充) */
+  checkResult?: string;
+  /** 项目总数(由 Service 填充) */
+  totalCount?: number;
+  /** 最近一次打卡的巡检项目明细(由 Service 填充,展示 APP 端提交的项目结果) */
+  items?: RiskInspectionTaskItemVo[];
 }
 
 // ==================== 巡检项目 ====================
@@ -791,6 +799,41 @@ export interface RiskInspectionItemVo {
   updateTime: string;
 }
 
+/** 巡检任务项目定义 VO(对应 risk_inspection_task_item,含最近一次打卡结果) */
+export interface RiskInspectionTaskItemVo {
+  id: string | number;
+  taskId: string | number;
+  itemCategory?: string;
+  itemName?: string;
+  itemType?: string;
+  itemContent?: string;
+  itemStandard?: string;
+  needPhoto?: string;
+  sortOrder?: number;
+  checkResult?: string;
+  remark?: string;
+  problemCount?: number;
+  photos?: string;
+  checkTime?: string;
+}
+
+/** 巡检打卡记录项目明细 VO(对应 risk_inspection_record_item,每次打卡一条明细) */
+export interface RiskInspectionRecordItemVo {
+  id: string | number;
+  recordId: string | number;
+  taskId: string | number;
+  itemId?: string | number;
+  itemCategory?: string;
+  itemType?: string;
+  itemContent?: string;
+  itemStandard?: string;
+  checkResult?: string;
+  problemCount?: number;
+  remark?: string;
+  photos?: string;
+  checkPhotos?: string;
+}
+
 // ==================== 打卡记录 ====================
 export interface RiskInspectionRecordBo {
   id?: string | number;
@@ -1102,6 +1145,8 @@ export interface SupervisionCaseNodeVo {
   nodeCode: number;
   nodeName: string;
   opinion: string;
+  /** 节点扩展数据(JSON字符串,按节点类型存不同字段) */
+  extraData?: string;
   attachmentUrl: string;
   attachmentName: string;
   handlerId: string | number;
@@ -1117,6 +1162,8 @@ export interface SupervisionComplaintNodeVo {
   nodeCode: number;
   nodeName: string;
   opinion: string;
+  /** 节点扩展数据(JSON字符串,按节点类型存不同字段) */
+  extraData?: string;
   attachmentUrl: string;
   attachmentName: string;
   handlerId: string | number;
@@ -1136,8 +1183,24 @@ export interface RiskSupervisionInspectionBo {
   storeName?: string;
   /** 抽检时间 */
   inspectionTime?: string;
-  /** 抽检商品 */
+  /** 抽检商品名称 */
   productName?: string;
+  /** 抽检商品条码 */
+  productBarcode?: string;
+  /** 抽检商品批号 */
+  lotNo?: string;
+  /** 抽检商品生产日期 */
+  productionDate?: string;
+  /** 抽检商品供应商 */
+  supplier?: string;
+  /** 抽样数量 */
+  sampleQuantity?: number;
+  /** 检测机构 */
+  detectOrg?: string;
+  /** 不合格项目(字典:risk_supervision_fail_item) */
+  failItem?: string;
+  /** 不合格指标数值 */
+  failValue?: string;
   /** 抽检结果(字典:抽检合格/抽检不合格) */
   inspectionResult?: string;
   /** 不合格原因 */
@@ -1163,6 +1226,14 @@ export interface RiskSupervisionInspectionVo {
   storeName: string;
   inspectionTime: string;
   productName: string;
+  productBarcode?: string;
+  lotNo?: string;
+  productionDate?: string;
+  supplier?: string;
+  sampleQuantity?: number;
+  detectOrg?: string;
+  failItem?: string;
+  failValue?: string;
   inspectionResult: string;
   failReason: string;
   status: string;
@@ -1215,6 +1286,17 @@ export interface RiskSupervisionCaseBo {
   attachmentUrl?: string;
   /** 凭证附件原始文件名 */
   attachmentName?: string;
+  /**
+   * 节点扩展数据(JSON字符串,按节点类型存不同字段)
+   * <p>
+   * 1-风险控制: {riskLevel, riskDesc, controlMeasure, handlerId, handlerName, attachmentUrl, attachmentName, opinion}
+   * 2-异议复检: {reviewReason, applyDate, reviewOrg, sampleInfo, sendRecord, reviewResult, reviewConclusion, reportUrl, reportName, opinion}
+   * 3-调查取证: {producerQualUrl/Name, productQualUrl/Name, inventoryUrl/Name, opinion}
+   * 4-调查报告: {reason, responsible, lossList, investigation, attachmentUrl/Name, opinion}
+   * 5-闭环管理: {causeAnalysis, rectification, opinion}
+   * </p>
+   */
+  extraData?: string;
   params?: any;
 }
 
@@ -1254,6 +1336,18 @@ export interface RiskSupervisionComplaintBo {
   complainantName?: string;
   /** 投诉人电话 */
   complainantPhone?: string;
+  /** 客诉商品名称 */
+  productName?: string;
+  /** 客诉商品条码 */
+  productBarcode?: string;
+  /** 客诉商品批号 */
+  lotNo?: string;
+  /** 客诉商品生产日期 */
+  productionDate?: string;
+  /** 客诉商品供应商 */
+  supplier?: string;
+  /** 客诉数量 */
+  quantity?: number;
   /** 涉事门店ID */
   storeId?: string | number;
   /** 涉事门店 */
@@ -1266,7 +1360,7 @@ export interface RiskSupervisionComplaintBo {
   attachmentUrl?: string;
   /** 客诉附件原始文件名 */
   attachmentName?: string;
-  /** 当前节点(1-7) */
+  /** 当前节点(1-5) */
   currentNode?: number;
   /** 当前节点名称 */
   nodeName?: string;
@@ -1282,6 +1376,17 @@ export interface RiskSupervisionComplaintBo {
   processAttachmentUrl?: string;
   /** 处理环节附件原始文件名 */
   processAttachmentName?: string;
+  /**
+   * 节点扩展数据(JSON字符串,按节点类型存不同字段)
+   * <p>
+   * 1-风险控制: {riskLevel, riskDesc, controlMeasure, handlerId, handlerName, attachmentUrl, attachmentName, opinion}
+   * 2-异议复检: {reviewReason, applyDate, reviewOrg, sampleInfo, sendRecord, reviewResult, reviewConclusion, reportUrl, reportName, opinion}
+   * 3-调查取证: {producerQualUrl/Name, productQualUrl/Name, inventoryUrl/Name, opinion}
+   * 4-调查报告: {reason, responsible, lossList, investigation, attachmentUrl/Name, opinion}
+   * 5-闭环管理: {causeAnalysis, rectification, opinion}
+   * </p>
+   */
+  extraData?: string;
   params?: any;
 }
 
@@ -1292,6 +1397,12 @@ export interface RiskSupervisionComplaintVo {
   source: string;
   complainantName: string;
   complainantPhone: string;
+  productName?: string;
+  productBarcode?: string;
+  lotNo?: string;
+  productionDate?: string;
+  supplier?: string;
+  quantity?: number;
   storeId: string | number;
   storeName: string;
   transferTime: string;
@@ -1319,6 +1430,8 @@ export interface SupervisionStatsVo {
   pendingComplaints: number;
   /** 本月抽检合格率 */
   monthPassRate: SupervisionMonthPassRate;
+  /** 趋势数据(较前一期增长百分比,正数上升,负数下降,null 表示无对比) */
+  trend?: SupervisionTrendVo;
 }
 
 export interface SupervisionMonthPassRate {
@@ -1332,3 +1445,15 @@ export interface SupervisionMonthPassRate {
   passRate: number;
 }
 
+/** 趋势信息 */
+export interface SupervisionTrendVo {
+  /** 今日待审核抽检 较前日变化百分比(例如 12.0 表示 +12%) */
+  todayPendingPct?: number | null;
+  /** 不合格待处理案件 较前日变化百分比 */
+  pendingCasesPct?: number | null;
+  /** 待处理客诉移交 较前日变化百分比 */
+  pendingComplaintsPct?: number | null;
+  /** 本月抽检合格率 较上月变化百分点(例如 2.3 表示 +2.3 个百分点) */
+  monthPassRateDelta?: number | null;
+}
+

+ 1 - 0
src/assets/styles/sidebar.scss

@@ -47,6 +47,7 @@
 
     .el-scrollbar {
       height: 100%;
+      width: 100%;
     }
 
     &.has-logo {

+ 1 - 1
src/assets/styles/variables.module.scss

@@ -347,7 +347,7 @@ $--color-warning: #e6a23c;
 $--color-danger: #f56c6c;
 $--color-info: #909399;
 
-$base-sidebar-width: 200px;
+$base-sidebar-width: 250px;
 
 // the :export directive is the magic sauce for webpack
 // https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass

+ 388 - 0
src/components/MapPickerDialog/index.vue

@@ -0,0 +1,388 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="地图选点"
+    width="800px"
+    :close-on-click-modal="false"
+    append-to-body
+    @open="onOpen"
+    @close="onClose"
+  >
+    <div class="map-picker">
+      <div class="map-toolbar">
+        <el-autocomplete
+          v-model="keyword"
+          :fetch-suggestions="fetchSuggestions"
+          :trigger-on-focus="true"
+          placeholder="搜索地点(输入联想,回车或点选)"
+          clearable
+          style="width: 280px"
+          @keyup.enter="searchPlace"
+          @select="onSelectSuggestion"
+        >
+          <template #append>
+            <el-button :icon="Search" @click="searchPlace" />
+          </template>
+          <template #default="{ item }">
+            <div class="suggestion-item">
+              <div class="suggestion-title">{{ item.title }}</div>
+              <div class="suggestion-addr">{{ item.address }}</div>
+            </div>
+          </template>
+        </el-autocomplete>
+        <el-tooltip content="定位到我的位置" placement="top">
+          <el-button
+            class="map-locate-btn"
+            :icon="Aim"
+            circle
+            :loading="locating"
+            @click="locateMe"
+          />
+        </el-tooltip>
+        <span class="map-tip">点击地图或搜索结果选择位置</span>
+        <span class="map-coord">当前: {{ coordText }}</span>
+      </div>
+      <div ref="mapContainer" class="map-container" />
+    </div>
+    <template #footer>
+      <el-button @click="visible = false">取消</el-button>
+      <el-button type="primary" :disabled="!pickedLat" @click="confirm">确定</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Search, Aim } from '@element-plus/icons-vue'
+
+const props = defineProps<{
+  modelValue: boolean
+  /** 初始纬度,弹窗打开时地图中心 */
+  initLat?: number
+  /** 初始经度 */
+  initLng?: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: boolean): void
+  (e: 'pick', payload: { lat: number; lng: number; address?: string }): void
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: v => emit('update:modelValue', v)
+})
+
+const keyword = ref('')
+const mapContainer = ref<HTMLDivElement | null>(null)
+const pickedLat = ref<number | null>(null)
+const pickedLng = ref<number | null>(null)
+const pickedAddress = ref('')
+const locating = ref(false)
+const coordText = computed(() => {
+  if (pickedLat.value == null || pickedLng.value == null) return '未选点'
+  return `纬度 ${pickedLat.value.toFixed(6)}, 经度 ${pickedLng.value.toFixed(6)}`
+})
+
+let TMap: any = null
+let map: any = null
+let marker: any = null
+let scriptLoaded = false
+let scriptLoading: Promise<void> | null = null
+
+function getBrowserLocation(timeoutMs = 8000, highAccuracy = true): Promise<{ lat: number; lng: number } | null> {
+  return new Promise(resolve => {
+    if (!navigator?.geolocation) return resolve(null)
+    const timer = setTimeout(() => resolve(null), timeoutMs)
+    navigator.geolocation.getCurrentPosition(
+      pos => {
+        clearTimeout(timer)
+        resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude })
+      },
+      () => {
+        clearTimeout(timer)
+        resolve(null)
+      },
+      { enableHighAccuracy: highAccuracy, timeout: timeoutMs, maximumAge: 30000 }
+    )
+  })
+}
+
+async function locateMe() {
+  if (!map) return
+  locating.value = true
+  try {
+    const geo = await getBrowserLocation()
+    if (!geo) {
+      ElMessage.warning('无法获取位置,请检查浏览器定位权限')
+      return
+    }
+    map.setCenter({ lat: geo.lat, lng: geo.lng })
+    map.setZoom(18)
+    placeMarker(geo.lat, geo.lng)
+  } finally {
+    locating.value = false
+  }
+}
+
+async function resolveInitialCenter(): Promise<{ lat: number; lng: number }> {
+  // 已填了经纬度 → 用填的
+  if (typeof props.initLat === 'number' && typeof props.initLng === 'number' && (props.initLat !== 0 || props.initLng !== 0)) {
+    return { lat: props.initLat, lng: props.initLng }
+  }
+  // 没填 → 自动定位
+  const geo = await getBrowserLocation()
+  if (geo) return geo
+  return { lat: 39.908823, lng: 116.397470 } // 默认北京
+}
+
+function loadScript(): Promise<void> {
+  if (scriptLoaded) return Promise.resolve()
+  if (scriptLoading) return scriptLoading
+  const key = import.meta.env.VITE_QQMAP_KEY
+  if (!key) {
+    return Promise.reject(new Error('缺少腾讯地图 VITE_QQMAP_KEY 配置'))
+  }
+  scriptLoading = new Promise((resolve, reject) => {
+    const existing = document.querySelector('script[data-qqmap-gljs]') as HTMLScriptElement | null
+    if (existing) {
+      scriptLoaded = true
+      resolve()
+      return
+    }
+    const s = document.createElement('script')
+    s.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(key)}&libraries=service`
+    s.async = true
+    s.dataset.qqmapGljs = '1'
+    s.onload = () => {
+      scriptLoaded = true
+      resolve()
+    }
+    s.onerror = () => reject(new Error('腾讯地图脚本加载失败'))
+    document.head.appendChild(s)
+  })
+  return scriptLoading
+}
+
+async function onOpen() {
+  pickedLat.value = null
+  pickedLng.value = null
+  pickedAddress.value = ''
+  keyword.value = ''
+  try {
+    await loadScript()
+  } catch (e: any) {
+    ElMessage.error(e?.message || '地图加载失败')
+    return
+  }
+  // 等待 DOM 容器就绪
+  await new Promise(r => setTimeout(r, 50))
+  if (!mapContainer.value) return
+  const win = window as any
+  TMap = win.TMap || win.tMap
+  if (!TMap) {
+    ElMessage.error('腾讯地图 SDK 未挂载')
+    return
+  }
+  // 自动定位: 优先级 form.lat/lng > 浏览器 Geolocation > 默认北京
+  const center = await resolveInitialCenter()
+  map = new TMap.Map(mapContainer.value, {
+    center: new TMap.LatLng(center.lat, center.lng),
+    zoom: 18,
+    pitch: 0,
+    rotation: 0
+  })
+  map.on('click', (evt: any) => {
+    const { lat, lng } = evt.latLng
+    placeMarker(lat, lng)
+  })
+  // 如果有初始坐标,放一个 marker
+  if (typeof props.initLat === 'number' && typeof props.initLng === 'number') {
+    placeMarker(props.initLat, props.initLng)
+  }
+  // 异步逆地理得到地址(用 WebService API,避免依赖 GL service 库)
+  reverseGeocode(pickedLat.value, pickedLng.value).then(addr => {
+    if (addr) pickedAddress.value = addr
+  }).catch(() => {})
+}
+
+function placeMarker(lat: number, lng: number) {
+  if (!map || !TMap) return
+  pickedLat.value = lat
+  pickedLng.value = lng
+  if (marker) {
+    // 腾讯 GL JS MultiMarker 没有 setPosition,用 updateGeometries 更新位置
+    try {
+      marker.updateGeometries([{ id: 'pick', position: new TMap.LatLng(lat, lng) }])
+    } catch {
+      // 旧版本 API: 先删再加
+      try { marker.del(['pick']) } catch {}
+      marker.add({ id: 'pick', position: new TMap.LatLng(lat, lng) })
+    }
+  } else {
+    marker = new TMap.MultiMarker({
+      map,
+      styles: {
+        marker: new TMap.MarkerStyle({
+          width: 24,
+          height: 32,
+          src: 'https://mapapi.qq.com/web/lbs/javascriptV2/img/marker.png',
+          anchor: { x: 12, y: 32 }
+        })
+      },
+      geometries: [{ id: 'pick', position: new TMap.LatLng(lat, lng) }]
+    })
+  }
+  // 异步逆地理得到地址(不阻塞选点)
+  reverseGeocode(lat, lng).then(addr => {
+    if (addr) pickedAddress.value = addr
+  }).catch(() => {})
+}
+
+async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
+  const key = import.meta.env.VITE_QQMAP_KEY
+  if (!key) return null
+  try {
+    const url = `https://apis.map.qq.com/ws/geocoder/v1/?location=${lat},${lng}&key=${encodeURIComponent(key)}&get_poi=0`
+    const res = await fetch(url)
+    const data = await res.json()
+    if (data?.status === 0 && data?.result?.address) return data.result.address
+  } catch {}
+  return null
+}
+
+async function searchPlace() {
+  if (!keyword.value.trim()) return
+  const kw = keyword.value.trim()
+  try {
+    // 1. 先不传 region 搜(让腾讯自己判断)
+    let data = await fetchSearch(kw)
+    // 2. 没结果 → 用 cluster 第一个城市重试
+    if ((!data || data.length === 0) && lastClusterCity.value) {
+      data = await fetchSearch(kw, lastClusterCity.value)
+    }
+    if (!data || data.length === 0) {
+      ElMessage.warning('未找到该地点,请尝试更详细的关键词(如"城市+地点")')
+      return
+    }
+    applyPoi(data[0])
+  } catch (e: any) {
+    ElMessage.error(e?.message || '搜索失败')
+  }
+}
+
+// 应用一个 POI 到地图(选点 + 平移 + 缩放)
+function applyPoi(poi: any) {
+  if (!map || !poi?.location) return
+  const { lat, lng } = poi.location
+  map.setCenter({ lat, lng })
+  map.setZoom(18)
+  placeMarker(lat, lng)
+  if (poi.address && !pickedAddress.value) pickedAddress.value = poi.address
+  if (poi.title) keyword.value = poi.title
+}
+
+// el-autocomplete 联想回调
+async function fetchSuggestions(queryString: string, cb: (suggestions: any[]) => void) {
+  if (!queryString || queryString.trim().length < 2) {
+    cb([])
+    return
+  }
+  try {
+    const data = await fetchSearch(queryString.trim())
+    // 透传后端原始顺序(腾讯 place API 默认按权重+距离综合排序)
+    cb(data.slice(0, 8))
+  } catch {
+    cb([])
+  }
+}
+
+// 点击联想项
+function onSelectSuggestion(item: any) {
+  applyPoi(item)
+}
+
+const lastClusterCity = ref<string | null>(null)
+
+async function fetchSearch(keyword: string, region?: string): Promise<any[]> {
+  const params = new URLSearchParams({ keyword })
+  if (region) params.set('region', region)
+  const url = `/dev-api/risk/map/search?${params.toString()}`
+  const res = await fetch(url, { credentials: 'include' })
+  const json: any = await res.json()
+  if (json?.status !== 0) {
+    throw new Error(json?.message || '搜索失败')
+  }
+  // 记录第一个 cluster 城市,供下次没结果时用
+  if (json.cluster?.[0]?.title) {
+    lastClusterCity.value = json.cluster[0].title
+  }
+  return json.data || []
+}
+
+function confirm() {
+  if (pickedLat.value == null || pickedLng.value == null) return
+  emit('pick', { lat: pickedLat.value, lng: pickedLng.value, address: pickedAddress.value })
+  visible.value = false
+}
+
+function onClose() {
+  // 不销毁 map 也没事,下次 onOpen 会重建
+  map = null
+  marker = null
+  TMap = null
+}
+</script>
+
+<style scoped lang="scss">
+.map-picker {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.map-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+.map-tip {
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
+.map-coord {
+  color: var(--el-color-primary);
+  font-size: 12px;
+  font-weight: 500;
+  margin-left: auto;
+}
+.map-locate-btn {
+  flex-shrink: 0;
+  &:hover {
+    color: var(--el-color-primary);
+    border-color: var(--el-color-primary);
+  }
+}
+.suggestion-item {
+  padding: 4px 0;
+  line-height: 1.3;
+}
+.suggestion-title {
+  font-size: 13px;
+  color: var(--el-text-color-primary);
+}
+.suggestion-addr {
+  font-size: 11px;
+  color: var(--el-text-color-secondary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.map-container {
+  width: 100%;
+  height: 480px;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 4px;
+}
+</style>

+ 48 - 4
src/layout/index.vue

@@ -1,5 +1,8 @@
 <template>
-  <div class="risk-layout">
+  <!-- 全屏路由直接占满浏览器视口(不显示 header/sidebar) -->
+  <router-view v-if="isFullscreen" :key="route.fullPath" />
+
+  <div v-else class="risk-layout">
     <!-- 顶部蓝色 banner -->
     <header class="risk-header">
       <div class="header-left">
@@ -39,7 +42,8 @@
             class="risk-menu"
             background-color="#fafafa"
             text-color="#333333"
-            active-text-color="#1890ff">
+            active-text-color="#1890ff"
+            @select="handleMenuSelect">
             <el-menu-item
               v-for="route in menuRoutes"
               :key="route.path"
@@ -90,6 +94,13 @@ const activeMenu = computed(() => {
   return path;
 });
 
+// 全屏模式:路由 meta.fullscreen === true 或路径为 /risk/bigScreen 时,不渲染 layout
+const isFullscreen = computed(() => {
+  if (route.meta?.fullscreen) return true;
+  // 兼容后端菜单动态加载的路由(meta 没有 fullscreen 字段),按路径判断
+  return route.path === '/risk/bigScreen' || route.path.endsWith('/risk/bigScreen');
+});
+
 // 提取顶级可访问菜单
 // 规则:展示平台首页 + 风控平台(/risk)的子菜单作为顶级项 + 关键框架子菜单
 const menuRoutes = computed(() => {
@@ -107,7 +118,14 @@ const menuRoutes = computed(() => {
   }
 
   // 2. 风控平台 /risk 下的所有子菜单提升为顶级
-  const riskRoute = permissionStore.sidebarRouters.find((r: any) => r.path === '/risk');
+  // 兼容:数据库 path 可能是 'risk' 或 '/risk';可能返回两个同名菜单,优先选有 C 子菜单的
+  const riskCandidates = permissionStore.sidebarRouters.filter(
+    (r: any) => r.path === '/risk' || r.path === 'risk'
+  );
+  const riskRoute = riskCandidates.find((r: any) =>
+      r.children && r.children.some((c: any) => !c.hidden)
+    ) || riskCandidates.find((r: any) => r.children && r.children.length)
+    || riskCandidates[0];
   if (riskRoute && riskRoute.children) {
     riskRoute.children
       .filter((c: any) => !c.hidden)
@@ -218,6 +236,31 @@ const handleUserCmd = async (cmd: string) => {
   }
 };
 
+// 菜单点击:命中 /risk/bigScreen 时在用户手势链内调用浏览器原生全屏
+// 该事件由 el-menu @select 触发,处于 click 用户手势栈内,可成功
+function handleMenuSelect(index: string) {
+  if (index === '/risk/bigScreen') {
+    const docEl = document.documentElement as any;
+    const req =
+      docEl.requestFullscreen ||
+      docEl.webkitRequestFullscreen ||
+      docEl.mozRequestFullScreen ||
+      docEl.msRequestFullscreen;
+    if (req) {
+      try {
+        const ret = req.call(docEl);
+        if (ret && typeof (ret as any).then === 'function') {
+          ret.catch(() => {
+            // 用户拒绝授权,仍保持 CSS 全屏(layout 自动隐藏 header/sidebar)
+          });
+        }
+      } catch {
+        // 忽略,保持 CSS 全屏
+      }
+    }
+  }
+}
+
 onMounted(() => {
   // SSE / WebSocket 可选启动
   try {
@@ -227,6 +270,7 @@ onMounted(() => {
 </script>
 
 <style lang="scss" scoped>
+@use '@/assets/styles/variables.module.scss' as *;
 .risk-layout {
   display: flex;
   flex-direction: column;
@@ -328,7 +372,7 @@ onMounted(() => {
 
 /* ====== 极简左侧菜单 ====== */
 .risk-sidebar {
-  flex: 0 0 220px;
+  flex: 0 0 $base-sidebar-width;
   background: #fafafa;
   border-right: 1px solid #ebeef5;
   overflow: hidden;

+ 4 - 2
src/permission.ts

@@ -33,9 +33,11 @@ router.beforeEach(async (to, from, next) => {
         // 判断当前用户是否已拉取完user_info信息
         const [err] = await tos(useUserStore().getInfo());
         if (err) {
-          await useUserStore().logout();
+          // 静默退出:token 失效时 /auth/logout 必然 401,会触发 axios 拦截器递归弹窗
+          await useUserStore().logout(true);
+          isRelogin.show = false;
           ElMessage.error(err);
-          next({ path: '/' });
+          next({ path: '/login' });
         } else {
           isRelogin.show = false;
           const accessRoutes = await usePermissionStore().generateRoutes();

+ 6 - 0
src/router/index.ts

@@ -331,6 +331,12 @@ export const dynamicRoutes: RouteRecordRaw[] = [
         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'], fullscreen: true }
       }
     ]
   }

+ 10 - 1
src/store/modules/user.ts

@@ -58,7 +58,16 @@ export const useUserStore = defineStore('user', () => {
   };
 
   // 注销
-  const logout = async (): Promise<void> => {
+  // silent = true 时不调后端 /auth/logout 接口(避免 token 过期时 401 递归触发"重新登录"弹窗),
+  // 由调用方决定是清本地状态走人,还是发请求让后端踢下线。
+  const logout = async (silent = false): Promise<void> => {
+    if (silent) {
+      token.value = '';
+      roles.value = [];
+      permissions.value = [];
+      removeToken();
+      return;
+    }
     try {
       await logoutApi();
     } catch (err) {

+ 34 - 9
src/utils/request.ts

@@ -1,6 +1,6 @@
 import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
 import { useUserStore } from '@/store/modules/user';
-import { getToken } from '@/utils/auth';
+import { getToken, removeToken } from '@/utils/auth';
 import { tansParams, blobValidate } from '@/utils/ruoyi';
 import cache from '@/plugins/cache';
 import { HttpStatus } from '@/enums/RespEnum';
@@ -135,18 +135,43 @@ service.interceptors.response.use(
         ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', {
           confirmButtonText: '重新登录',
           cancelButtonText: '取消',
-          type: 'warning'
+          type: 'warning',
+          // 防止点击外部/ESC 关闭时走 catch 而无法 reset 标记
+          closeOnPressEscape: false,
+          closeOnClickModal: false
         }).then(() => {
+          // ★ 关键:先重置标记 + 同步清本地状态,再跳转
+          // (避免 .then 嵌套 await 卡住,导致用户感觉"点了没反应")
           isRelogin.show = false;
-          useUserStore().logout().then(() => {
-            router.replace({
-              path: '/login',
-              query: {
-                redirect: encodeURIComponent(router.currentRoute.value.fullPath || '/')
+          // 同步清 token(不依赖 useUserStore,避免 axios 拦截器作用域 pinia 未就绪抛错)
+          try {
+            removeToken();
+          } catch (e) {
+            console.error('[relogin] removeToken 失败:', e);
+          }
+          // 异步清 store(失败不影响跳转)
+          try {
+            useUserStore().logout(true).catch(() => {});
+          } catch (e) {
+            console.error('[relogin] 清空 store 状态失败:', e);
+          }
+          // 强制跳转:router.push 偶发会被中断,加 try/catch + 兜底 location.href
+          const targetPath = '/login?redirect=' + encodeURIComponent(router.currentRoute.value.fullPath || '/');
+          try {
+            router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath || '/' } });
+            // 兜底:200ms 后如果路径没变,强制 location.href
+            setTimeout(() => {
+              if (window.location.pathname !== '/login') {
+                console.warn('[relogin] router.push 未生效,location.href 兜底');
+                window.location.href = window.location.origin + targetPath;
               }
-            })
-          });
+            }, 200);
+          } catch (e) {
+            console.error('[relogin] router.push 失败,改用 location.href 兜底:', e);
+            window.location.href = window.location.origin + targetPath;
+          }
         }).catch(() => {
+          // 用户点"取消"或弹窗被关闭 — 重置标记,允许下次 401 再次弹窗
           isRelogin.show = false;
         });
       }

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

@@ -0,0 +1,820 @@
+<!--
+  3.3 风控品控平台大屏
+  严格按参考图布局:
+   - 顶部: 6 张卡片一线
+   - 第二行: 2 饼图 + 1 双折线
+   - 第三行: 1 柱状图 + 1 走马灯
+-->
+<template>
+  <div class="big-screen">
+    <!-- 顶部标题栏 -->
+    <div class="bs-header">
+      <div class="bs-title-wrap">
+        <div class="bs-title-zh">企业安全风控管理平台</div>
+        <div class="bs-title-en">RISK & QUALITY CONTROL PLATFORM</div>
+      </div>
+      <div class="bs-clock">
+        <div class="bs-time">{{ currentTime }}</div>
+        <div class="bs-date">{{ currentDate }}</div>
+      </div>
+    </div>
+
+    <!-- 顶部: 6 张业务数据卡片 -->
+    <div class="bs-cards-row">
+      <div class="bs-card bs-card-1">
+        <div class="bs-card-head">
+          <el-icon class="bs-card-icon"><Warning /></el-icon>
+          <span class="bs-card-title">安全隐患</span>
+        </div>
+        <div class="bs-card-value">{{ fmtNum(card.dangerTotal) }}</div>
+        <div class="bs-card-stats">
+          <span class="stat-success">已处理 <em>{{ fmtNum(card.dangerHandled) }}</em></span>
+          <span class="stat-danger">未处理 <em>{{ fmtNum(card.dangerPending) }}</em></span>
+        </div>
+      </div>
+
+      <div class="bs-card bs-card-2">
+        <div class="bs-card-head">
+          <el-icon class="bs-card-icon"><Aim /></el-icon>
+          <span class="bs-card-title">危险源</span>
+        </div>
+        <div class="bs-card-value">{{ fmtNum(card.dangerSourceTotal) }}</div>
+        <div class="bs-card-stats">
+          <span class="stat-info">危险源总数</span>
+          <span class="stat-danger">本月新增 <em>{{ fmtNum(card.dangerSourceMonthNew) }}</em></span>
+        </div>
+      </div>
+
+      <div class="bs-card bs-card-3">
+        <div class="bs-card-head">
+          <el-icon class="bs-card-icon"><Document /></el-icon>
+          <span class="bs-card-title">作业上报</span>
+        </div>
+        <div class="bs-card-value">{{ fmtNum(card.workReportTotal) }}</div>
+        <div class="bs-card-stats">
+          <span class="stat-info">上报总数</span>
+          <span class="stat-primary">作业进行中 <em>{{ fmtNum(card.workReportOngoing) }}</em></span>
+        </div>
+      </div>
+
+      <div class="bs-card bs-card-4">
+        <div class="bs-card-head">
+          <el-icon class="bs-card-icon"><User /></el-icon>
+          <span class="bs-card-title">审计工作</span>
+        </div>
+        <div class="bs-card-value">{{ fmtNum(card.personnelChangeTotal) }}</div>
+        <div class="bs-card-stats">
+          <span class="stat-danger">离职人数 <em>{{ fmtNum(card.personnelLeave) }}</em></span>
+          <span class="stat-primary">调任人数 <em>{{ fmtNum(card.personnelTransfer) }}</em></span>
+        </div>
+      </div>
+
+      <div class="bs-card bs-card-5">
+        <div class="bs-card-head">
+          <el-icon class="bs-card-icon"><Tools /></el-icon>
+          <span class="bs-card-title">特种设备</span>
+        </div>
+        <div class="bs-card-value">{{ fmtNum(card.specialEquipmentTotal) }}</div>
+        <div class="bs-card-stats">
+          <span class="stat-success">运行数 <em>{{ fmtNum(card.specialEquipmentRunning) }}</em></span>
+          <span class="stat-warning">维护数 <em>{{ fmtNum(card.specialEquipmentMaintaining) }}</em></span>
+        </div>
+      </div>
+
+      <div class="bs-card bs-card-6">
+        <div class="bs-card-head">
+          <el-icon class="bs-card-icon"><Shop /></el-icon>
+          <span class="bs-card-title">访厂审核</span>
+        </div>
+        <div class="bs-card-value">{{ fmtNum(card.factoryVisitTotal) }}</div>
+        <div class="bs-card-stats">
+          <span class="stat-success">审核通过数 <em>{{ fmtNum(card.factoryVisitPassed) }}</em></span>
+          <span class="stat-danger">审核不通过 <em>{{ fmtNum(card.factoryVisitRejected) }}</em></span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 第二行: 2 饼图 + 1 双折线 -->
+    <div class="bs-row bs-row-charts">
+      <div class="bs-panel">
+        <div class="bs-panel-title">
+          <span class="bs-title-bar"></span>人员岗位占比
+        </div>
+        <div ref="personnelPieRef" class="bs-chart"></div>
+      </div>
+      <div class="bs-panel">
+        <div class="bs-panel-title">
+          <span class="bs-title-bar"></span>事故类型分布
+        </div>
+        <div ref="accidentPieRef" class="bs-chart"></div>
+      </div>
+      <div class="bs-panel bs-panel-wide">
+        <div class="bs-panel-title">
+          <span class="bs-title-bar"></span>近一月巡检/抽检趋势
+        </div>
+        <div ref="trendLineRef" class="bs-chart"></div>
+      </div>
+    </div>
+
+    <!-- 第三行: 1 柱状图 + 1 走马灯 -->
+    <div class="bs-row bs-row-bottom">
+      <div class="bs-panel bs-panel-wide">
+        <div class="bs-panel-title">
+          <span class="bs-title-bar"></span>近一年客诉数量统计
+        </div>
+        <div ref="complaintBarRef" class="bs-chart"></div>
+      </div>
+      <div class="bs-panel bs-panel-stream">
+        <div class="bs-panel-title">
+          <span class="bs-title-bar"></span>最新预警动态
+          <span class="bs-stream-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-stream-time">{{ w.releaseTime }}</span>
+                <span class="bs-dot" :style="{ background: dotColor(w.levelColor) }"></span>
+                <span class="bs-stream-content">{{ w.title }}</span>
+              </div>
+              <div v-if="!warningStream.length" class="bs-stream-empty">暂无预警</div>
+            </div>
+          </el-scrollbar>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+import * as echarts from 'echarts';
+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';
+};
+
+// ===== 浏览器原生全屏 =====
+// 入口由 layout 菜单点击(用户手势链)触发;退出由大屏页面 ESC 键监听触发
+// 页面不暴露任何按钮,避免顶栏冗余
+
+function exitFullscreen() {
+  const doc: any = document;
+  if (doc.exitFullscreen) doc.exitFullscreen();
+  else if (doc.webkitExitFullscreen) doc.webkitExitFullscreen();
+  else if (doc.mozCancelFullScreen) doc.mozCancelFullScreen();
+  else if (doc.msExitFullscreen) doc.msExitFullscreen();
+}
+
+// ===== 加载 =====
+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(20);
+    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',
+      backgroundColor: 'rgba(20,40,80,0.95)',
+      borderColor: '#3a8dff',
+      textStyle: { color: '#fff' },
+      formatter: '{b}: {c} ({d}%)'
+    },
+    legend: {
+      type: 'scroll',
+      bottom: 4,
+      left: 'center',
+      textStyle: { color: '#aac8ff', fontSize: 12 },
+      itemWidth: 10,
+      itemHeight: 10
+    },
+    color: PIE_COLORS,
+    series: [
+      {
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '45%'],
+        avoidLabelOverlap: true,
+        itemStyle: { borderColor: '#0a1a3a', borderWidth: 2 },
+        label: { show: false },
+        labelLine: { show: false },
+        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' },
+      formatter: '{b}: {c} ({d}%)'
+    },
+    legend: {
+      type: 'scroll',
+      bottom: 4,
+      left: 'center',
+      textStyle: { color: '#aac8ff', fontSize: 12 }
+    },
+    color: ['#e86452', '#f6bd16', '#5b8ff9', '#5ad8a6', '#ff9845'],
+    series: [
+      {
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '45%'],
+        avoidLabelOverlap: true,
+        itemStyle: { borderColor: '#0a1a3a', borderWidth: 2 },
+        label: { show: false },
+        labelLine: { show: false },
+        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,
+      right: 16,
+      textStyle: { color: '#aac8ff' }
+    },
+    grid: { left: 50, right: 28, top: 44, bottom: 36, containLabel: true },
+    xAxis: {
+      type: 'category',
+      data: dates,
+      boundaryGap: false,
+      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: 50, right: 20, top: 32, bottom: 36, containLabel: true },
+    xAxis: {
+      type: 'category',
+      data: months,
+      axisLine: { lineStyle: { color: '#3a8dff' } },
+      axisLabel: { color: '#aac8ff', fontSize: 11 }
+    },
+    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.15)' }
+          ]),
+          borderRadius: [4, 4, 0, 0]
+        }
+      }
+    ]
+  });
+}
+
+// ===== 生命周期 =====
+let ro: ResizeObserver | null = null;
+
+onBeforeUnmount(() => {
+  if (timer) {
+    clearInterval(timer);
+    timer = undefined;
+  }
+  if (streamTimer) {
+    clearInterval(streamTimer);
+    streamTimer = undefined;
+  }
+  if (resizeHandler) {
+    window.removeEventListener('resize', resizeHandler);
+    resizeHandler = undefined;
+  }
+  if (ro) {
+    ro.disconnect();
+    ro = null;
+  }
+  document.removeEventListener('fullscreenchange', onFullscreenChange);
+  document.removeEventListener('keydown', onKeyDown);
+  personnelPieChart?.dispose();
+  accidentPieChart?.dispose();
+  trendLineChart?.dispose();
+  complaintBarChart?.dispose();
+});
+
+// ===== 浏览器全屏监听 + ESC 退出 =====
+function onFullscreenChange() {
+  // ESC 退出全屏时全屏模式切换后立即 resize 所有 chart
+  setTimeout(() => {
+    personnelPieChart?.resize();
+    accidentPieChart?.resize();
+    trendLineChart?.resize();
+    complaintBarChart?.resize();
+  }, 100);
+}
+
+function onKeyDown(e: KeyboardEvent) {
+  if (e.key === 'Escape') {
+    exitFullscreen();
+  }
+  // F11 由浏览器原生处理
+}
+
+function resizeAllCharts() {
+  personnelPieChart?.resize();
+  accidentPieChart?.resize();
+  trendLineChart?.resize();
+  complaintBarChart?.resize();
+}
+
+onMounted(() => {
+  loadAll();
+  tickClock();
+  timer = window.setInterval(tickClock, 1000);
+  streamTimer = window.setInterval(loadStream, 30000);
+
+  resizeHandler = resizeAllCharts;
+  window.addEventListener('resize', resizeHandler);
+  document.addEventListener('fullscreenchange', onFullscreenChange);
+  document.addEventListener('keydown', onKeyDown);
+
+  // ResizeObserver: 监听容器尺寸自适应变化(视口/全屏切换时)
+  setTimeout(() => {
+    if (typeof ResizeObserver !== 'undefined') {
+      ro = new ResizeObserver(() => {
+        // 防止高频调用, 加 rAF 节流
+        requestAnimationFrame(resizeAllCharts);
+      });
+      if (personnelPieRef.value) ro.observe(personnelPieRef.value);
+      if (accidentPieRef.value) ro.observe(accidentPieRef.value);
+      if (trendLineRef.value) ro.observe(trendLineRef.value);
+      if (complaintBarRef.value) ro.observe(complaintBarRef.value);
+    }
+  }, 0);
+
+  // 连续多次 resize,确保 echarts 在自适应布局稳定后绘制正确
+  setTimeout(resizeAllCharts, 50);
+  setTimeout(resizeAllCharts, 200);
+  setTimeout(resizeAllCharts, 600);
+});
+</script>
+
+<style scoped lang="scss">
+// 100vh 全屏自适应: 大屏容器占满视口,内部分区用 calc 或 flex 自适应
+.big-screen {
+  width: 100vw;
+  height: 100vh;
+  background: radial-gradient(ellipse at top, #0a1a3a 0%, #050b1f 60%, #02060f 100%);
+  color: #e6f0ff;
+  display: flex;
+  flex-direction: column;
+  padding: 14px;
+  box-sizing: border-box;
+  overflow: hidden;
+  font-family: 'Microsoft YaHei', 'PingFang SC', Arial, sans-serif;
+}
+
+// ===== 顶部标题栏 =====
+.bs-header {
+  flex: 0 0 60px;
+  display: flex;
+  align-items: center;
+  margin-bottom: 14px;
+  padding: 0 16px;
+  position: relative;
+
+  .bs-title-wrap {
+    flex: 1;
+    text-align: center;
+  }
+
+  .bs-title-zh {
+    font-size: 28px;
+    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-clock {
+    flex: 0 0 auto;
+    text-align: right;
+    margin-right: 16px;
+  }
+
+  .bs-time {
+    font-size: 22px;
+    font-weight: 600;
+    color: #5ad8ff;
+    text-shadow: 0 0 10px rgba(90, 216, 255, 0.6);
+  }
+
+  .bs-date {
+    font-size: 12px;
+    color: #aac8ff;
+    margin-top: 2px;
+  }
+
+  .bs-header-actions {
+    flex: 0 0 auto;
+    display: flex;
+    gap: 8px;
+  }
+}
+
+// ===== 卡片行 =====
+.bs-cards-row {
+  flex: 0 0 auto;
+  display: grid;
+  grid-template-columns: repeat(6, 1fr);
+  gap: 12px;
+  margin-bottom: 14px;
+}
+
+.bs-card {
+  background: linear-gradient(135deg, rgba(20, 40, 80, 0.7), rgba(10, 26, 58, 0.5));
+  border: 1px solid rgba(58, 141, 255, 0.25);
+  border-radius: 6px;
+  padding: 12px 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  position: relative;
+  overflow: hidden;
+  min-height: 100px;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: 2px;
+    background: linear-gradient(90deg, transparent, #5ad8ff, transparent);
+    opacity: 0.6;
+  }
+}
+
+.bs-card-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #aac8ff;
+  font-size: 14px;
+}
+
+.bs-card-icon {
+  font-size: 18px;
+  color: #5ad8ff;
+}
+
+.bs-card-title {
+  font-weight: 500;
+}
+
+.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;
+  line-height: 1.1;
+  text-align: center;
+  margin: 4px 0;
+}
+
+.bs-card-stats {
+  display: flex;
+  justify-content: space-between;
+  font-size: 12px;
+
+  span em {
+    font-style: normal;
+    font-weight: 600;
+    margin-left: 2px;
+  }
+}
+
+.stat-success { color: #5ad8a6; em { color: #5ad8a6; } }
+.stat-danger  { color: #ff4d4f; em { color: #ff4d4f; } }
+.stat-warning { color: #faad14; em { color: #faad14; } }
+.stat-primary { color: #5ad8ff; em { color: #5ad8ff; } }
+.stat-info    { color: #aac8ff; }
+
+// ===== 行 =====
+.bs-row {
+  display: grid;
+  gap: 12px;
+  margin-bottom: 14px;
+  flex: 1 1 0;
+  min-height: 0;
+}
+
+.bs-row-charts {
+  grid-template-columns: 1fr 1fr 1.4fr;
+  min-height: 280px;
+}
+
+.bs-row-bottom {
+  grid-template-columns: 1.4fr 1fr;
+  min-height: 260px;
+}
+
+.bs-panel {
+  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: 10px 14px;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.bs-panel-title {
+  flex: 0 0 auto;
+  font-size: 15px;
+  font-weight: 600;
+  color: #aac8ff;
+  padding-bottom: 6px;
+  border-bottom: 1px solid rgba(58, 141, 255, 0.2);
+  display: flex;
+  align-items: center;
+  margin-bottom: 4px;
+}
+
+.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 1 0;
+  min-height: 0;
+  width: 100%;
+}
+
+// ===== 走马灯 =====
+.bs-stream {
+  flex: 1;
+  min-height: 0;
+  margin-top: 4px;
+  overflow: hidden;
+}
+
+.bs-stream-tag {
+  margin-left: auto;
+  font-size: 12px;
+  color: #5ad8a6;
+  font-weight: 400;
+  border: 1px solid #5ad8a6;
+  padding: 1px 8px;
+  border-radius: 10px;
+}
+
+.bs-stream-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.bs-stream-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 6px 10px;
+  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-stream-time {
+  color: #aac8ff;
+  font-family: 'DIN Alternate', monospace;
+  font-size: 12px;
+  min-width: 150px;
+}
+
+.bs-dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  box-shadow: 0 0 8px currentColor;
+  flex-shrink: 0;
+}
+
+.bs-stream-content {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.bs-stream-empty {
+  text-align: center;
+  color: #aac8ff;
+  padding: 20px;
+  font-size: 13px;
+}
+</style>

+ 40 - 40
src/views/risk/dangerSource/index.vue

@@ -52,7 +52,13 @@
       <el-table v-loading="loading" :data="dangerSourceList" border stripe>
         <el-table-column prop="sourceCode" label="危险源编号" align="center" width="140" />
         <el-table-column prop="sourceName" label="危险源名称" align="center" :show-overflow-tooltip="true" min-width="180" />
-        <el-table-column label="类型" align="center" prop="sourceType" width="130" />
+        <el-table-column label="类型" align="center" prop="sourceType" width="130">
+          <template #default="{ row }">
+            <el-tag :type="getSourceTypeTag(row.sourceType)" effect="light" round>
+              {{ getDictLabel(sourceTypeOptions, row.sourceType) }}
+            </el-tag>
+          </template>
+        </el-table-column>
         <el-table-column label="位置" align="center" prop="location" :show-overflow-tooltip="true" width="180" />
         <el-table-column label="可能危害" align="center" prop="sourceDesc" :show-overflow-tooltip="true" min-width="200" />
         <el-table-column label="防控方式" align="center" prop="controlMeasures" :show-overflow-tooltip="true" min-width="200" />
@@ -116,21 +122,6 @@
               </el-select>
             </el-form-item>
           </el-col>
-          <el-col :span="12">
-            <el-form-item label="风险等级" prop="riskLevel">
-              <el-select v-model="form.riskLevel" placeholder="请选择风险等级" style="width: 100%">
-                <el-option
-                  v-for="d in riskLevelOptions"
-                  :key="d.value"
-                  :label="d.label"
-                  :value="d.value"
-                />
-              </el-select>
-            </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="form.location" placeholder="请输入危险源位置" maxlength="100" show-word-limit />
@@ -164,8 +155,8 @@
           <el-date-picker
             v-model="form.reportTime"
             type="date"
-            placeholder="请选择上报时间"
-            value-format="YYYY-MM-DD HH:mm:ss"
+            placeholder="年/月/日"
+            value-format="YYYY-MM-DD"
             format="YYYY-MM-DD"
             :disabled-date="disabledFutureDate"
             style="width: 100%"
@@ -185,7 +176,7 @@
       <el-descriptions :column="1" border>
         <el-descriptions-item label="危险源编号">{{ viewForm.sourceCode }}</el-descriptions-item>
         <el-descriptions-item label="危险源名称">{{ viewForm.sourceName }}</el-descriptions-item>
-        <el-descriptions-item label="危险源类型">{{ viewForm.sourceType }}</el-descriptions-item>
+        <el-descriptions-item label="危险源类型">{{ getDictLabel(sourceTypeOptions, viewForm.sourceType) }}</el-descriptions-item>
         <el-descriptions-item label="位置">{{ viewForm.location }}</el-descriptions-item>
         <el-descriptions-item label="可能危害">
           <div class="multi-line">{{ viewForm.sourceDesc || '—' }}</div>
@@ -193,7 +184,7 @@
         <el-descriptions-item label="防控方式">
           <div class="multi-line">{{ viewForm.controlMeasures || '—' }}</div>
         </el-descriptions-item>
-        <el-descriptions-item label="上报时间">{{ formatDateTime(viewForm.reportTime) }}</el-descriptions-item>
+        <el-descriptions-item label="上报时间">{{ viewForm.reportTime || '—' }}</el-descriptions-item>
       </el-descriptions>
       <template #footer>
         <el-button @click="viewDialog.visible = false">关闭</el-button>
@@ -224,7 +215,6 @@ const dangerSourceList = ref<RiskDangerSourceVo[]>([]);
 const loading = ref(true);
 const total = ref(0);
 const sourceTypeOptions = ref<Array<{ label: string; value: string }>>([]);
-const riskLevelOptions = ref<Array<{ label: string; value: string }>>([]);
 
 const queryFormRef = ref<FormInstance>();
 const formRef = ref<FormInstance>();
@@ -235,7 +225,6 @@ const data = reactive<any>({
     sourceCode: '',
     sourceName: '',
     sourceType: '',
-    riskLevel: '',
     location: '',
     sourceDesc: '',
     controlMeasures: '',
@@ -262,7 +251,6 @@ const data = reactive<any>({
       }
     ],
     sourceType: [{ required: true, message: '请选择危险源类型', trigger: 'change' }],
-    riskLevel: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
     location: [{ required: true, message: '请输入危险源位置', trigger: 'blur' }],
     sourceDesc: [{ required: true, message: '请描述可能造成的危害', trigger: 'blur' }],
     controlMeasures: [{ required: true, message: '请描述防控方式', trigger: 'blur' }],
@@ -283,8 +271,14 @@ const viewDialog = reactive({
 
 const viewForm = ref<any>({});
 
-const mapDictData = (list: DictDataVO[]) => {
-  return (list || [])
+const mapDictData = (resOrList: any) => {
+  // 字典接口被 axios 拦截器自动解包,返回值为数组;兼容部分场景被再包一层 .data 的情况
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
+  return list
     .filter((d) => d.status === '0' || d.status === undefined)
     .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
     .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
@@ -292,24 +286,31 @@ const mapDictData = (list: DictDataVO[]) => {
 
 const loadDicts = async () => {
   try {
-    const [typeRes, levelRes] = await Promise.all([
-      getDicts('risk_source_type'),
-      getDicts('risk_danger_level').catch(() => ({ data: [] }))
-    ]);
-    sourceTypeOptions.value = mapDictData(typeRes.data || []);
-    // 风险等级字典不存在时使用默认三档
-    riskLevelOptions.value = (levelRes.data || []).length
-      ? mapDictData(levelRes.data)
-      : [
-          { label: '高', value: 'high' },
-          { label: '中', value: 'medium' },
-          { label: '低', value: 'low' }
-        ];
+    const typeRes = await getDicts('risk_source_type');
+    sourceTypeOptions.value = mapDictData(typeRes as any);
   } catch (e) {
     console.error('加载危险源字典失败', e);
   }
 };
 
+/** 危险源类型 tag 颜色(按字典 value 映射) */
+const getSourceTypeTag = (type: string) => {
+  const map: Record<string, string> = {
+    电气危险源: 'danger',
+    消防危险源: 'warning',
+    高空危险源: 'primary',
+    设备危险源: 'info',
+    食品危险源: 'success'
+  };
+  return map[type] || 'info';
+};
+
+/** 字典值 → 中文 label */
+const getDictLabel = (opts: Array<{ label: string; value: string }>, val: string) => {
+  if (!val) return '';
+  return opts.find((o) => o.value === val)?.label || val;
+};
+
 const getList = async () => {
   loading.value = true;
   const res = await listDangerSource(queryParams.value as RiskDangerSourceBo);
@@ -394,7 +395,6 @@ const reset = () => {
     sourceCode: '',
     sourceName: '',
     sourceType: '',
-    riskLevel: '',
     location: '',
     sourceDesc: '',
     controlMeasures: '',
@@ -427,7 +427,7 @@ onMounted(async () => {
 </script>
 
 <style scoped>
-  danger-source-page {
+.danger-source-page {
   padding: 16px;
   height: 100%;
   overflow-y: auto;

+ 618 - 174
src/views/risk/foodSafety/index.vue

@@ -1,18 +1,12 @@
 <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-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>
@@ -21,7 +15,7 @@
               </div>
             </div>
             <div class="stat-icon stat-icon-blue">
-              <el-icon><Calendar /></el-icon>
+              <el-icon><Microphone /></el-icon>
             </div>
           </div>
         </el-card>
@@ -32,8 +26,10 @@
             <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 class="stat-extra stat-extra-up" v-if="stats.participantsRate !== undefined">
+                <el-icon v-if="stats.participantsRate >= 0"><CaretTop /></el-icon>
+                <el-icon v-else><CaretBottom /></el-icon>
+                <span>{{ Math.abs(stats.participantsRate) }}% 本月</span>
               </div>
             </div>
             <div class="stat-icon stat-icon-green">
@@ -48,8 +44,10 @@
             <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 class="stat-extra stat-extra-up" v-if="stats.materialRate !== undefined">
+                <el-icon v-if="stats.materialRate >= 0"><CaretTop /></el-icon>
+                <el-icon v-else><CaretBottom /></el-icon>
+                <span>新增{{ Math.abs(stats.materialRate) }}份 本月</span>
               </div>
             </div>
             <div class="stat-icon stat-icon-purple">
@@ -65,7 +63,7 @@
               <div class="stat-title">即将进行</div>
               <div class="stat-value">{{ stats.notStartedCount || 0 }}</div>
               <div class="stat-extra stat-extra-warning">
-                <span>未开始状态活动</span>
+                <span>未来一周内</span>
               </div>
             </div>
             <div class="stat-icon stat-icon-orange">
@@ -77,109 +75,223 @@
     </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>
+    <el-card shadow="hover" class="list-card">
+      <!-- 工具栏:左侧按钮 / 右侧筛选 -->
+      <div class="list-toolbar">
+        <div class="toolbar-left">
+          <el-button type="primary" :icon="Plus" @click="handleAdd">新增宣传活动</el-button>
+          <el-button :icon="Download" @click="handleExport">导出数据</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="活动类型">
+        <div class="toolbar-right">
           <el-select
-            v-model="queryParams.campaignType"
-            placeholder="全部类型"
+            v-model="queryParams.status"
+            placeholder="全部状态"
             clearable
-            style="width: 200px"
+            style="width: 140px"
             @change="handleQuery"
           >
-            <el-option label="全部类型" value="" />
+            <el-option label="全部状态" value="" />
             <el-option
-              v-for="d in campaignTypeDict"
+              v-for="d in campaignStatusDict"
               :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="全部状态"
+            v-model="queryParams.campaignType"
+            placeholder="全部类型"
             clearable
-            style="width: 160px"
+            style="width: 200px"
             @change="handleQuery"
           >
-            <el-option label="全部状态" value="" />
+            <el-option label="全部类型" value="" />
             <el-option
-              v-for="d in campaignStatusDict"
+              v-for="d in campaignTypeDict"
               :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>
+        </div>
+      </div>
 
-      <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">
+      <el-table v-loading="loading" :data="campaignList">
+        <el-table-column label="宣传动名称" align="left" min-width="240" :show-overflow-tooltip="true">
           <template #default="scope">
-            {{ getDictLabel(campaignTypeDict, scope.row.campaignType) }}
+            <div class="campaign-name-cell">
+              <div class="campaign-name">{{ scope.row.campaignName }}</div>
+              <div class="campaign-id">#{{ scope.row.campaignCode }}</div>
+            </div>
           </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">
+        <el-table-column label="类型" align="center" prop="campaignType" width="140">
           <template #default="scope">
-            <span>实际 {{ scope.row.actualParticipants || 0 }} / 预计 {{ scope.row.expectedParticipants || 0 }}</span>
+            {{ getDictLabel(campaignTypeDict, scope.row.campaignType) }}
           </template>
         </el-table-column>
-        <el-table-column label="负责人" align="center" prop="principalName" width="100" />
+        <el-table-column label="开始时间" align="center" prop="startTime" width="120">
+          <template #default="scope">{{ formatDate(scope.row.startTime) }}</template>
+        </el-table-column>
+        <el-table-column label="结束时间" align="center" prop="endTime" width="120">
+          <template #default="scope">{{ formatDate(scope.row.endTime) }}</template>
+        </el-table-column>
+        <el-table-column label="参与人数" align="center" width="100" prop="actualParticipants" />
         <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">
+        <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="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
+              v-if="scope.row.status === '未开始'"
+              link
+              type="success"
+              @click="handleStart(scope.row)"
+            >活动开始</el-button>
+            <el-button
+              v-if="scope.row.status === '进行中'"
+              link
+              type="warning"
+              @click="openFinishDialog(scope.row)"
+            >活动结束</el-button>
             <el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
 
+      <div v-if="total > 0" class="pagination-wrapper">
+        <span class="total-text">共 {{ total }} 条记录</span>
+        <pagination
+          v-model:page="queryParams.pageNum"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          :layout="'prev, pager, next'"
+          @pagination="getList"
+        />
+      </div>
+    </el-card>
+
+    <!-- ②最新宣传资料 -->
+    <el-card shadow="hover" class="mb-2 mt-2">
+      <template #header>
+        <div class="section-header">
+          <span class="section-title">最新宣传资料</span>
+          <div class="section-actions">
+            <el-button type="primary" :icon="UploadFilled" @click="openUploadMaterialDialog">上传资料</el-button>
+            <el-button link type="primary" @click="openAllMaterials">查看全部 &gt;</el-button>
+          </div>
+        </div>
+      </template>
+
+      <div v-if="recentMaterials.length === 0" class="empty-tip">暂无宣传资料,请点击列表“上传资料”按钮添加</div>
+
+      <el-row v-else :gutter="20">
+        <el-col
+          v-for="mat in recentMaterials"
+          :key="mat.id"
+          :span="8"
+          class="material-col"
+        >
+          <div class="material-card" @click="handleOpenMaterial(mat)">
+            <div class="material-cover">
+              <el-image
+                v-if="mat.coverUrl"
+                :src="mat.coverUrl"
+                fit="cover"
+                lazy
+                :preview-src-list="[mat.coverUrl]"
+                hide-on-click-modal
+              >
+                <template #error>
+                  <div class="material-cover-fallback">无封面</div>
+                </template>
+              </el-image>
+              <div v-else class="material-cover-fallback">无封面</div>
+            </div>
+            <div class="material-info">
+              <div class="material-name" :title="mat.materialName">{{ mat.materialName || '未命名资料' }}</div>
+              <div class="material-summary" :title="mat.materialSummary">{{ mat.materialSummary || '暂无描述' }}</div>
+              <div class="material-meta">
+                <span>更新于 {{ formatDateTime(mat.createTime) }}</span>
+                <el-button
+                  link
+                  type="primary"
+                  size="small"
+                  :disabled="!mat.attachmentUrl"
+                  @click.stop="handleDownloadMaterial(mat)"
+                >下载</el-button>
+              </div>
+            </div>
+          </div>
+        </el-col>
+      </el-row>
+    </el-card>
+
+    <!-- ②② 全部宣传资料列表弹窗 -->
+    <el-dialog
+      v-model="allMaterialsDialog.visible"
+      title="全部宣传资料"
+      width="960px"
+      append-to-body
+      :close-on-click-modal="false"
+    >
+      <el-form ref="allMatQueryFormRef" :model="allMatQueryParams" :inline="true" class="filter-bar">
+        <el-form-item label="资料名称">
+          <el-input
+            v-model="allMatQueryParams.keyword"
+            placeholder="搜索资料名称/编号"
+            clearable
+            style="width: 240px"
+            @keyup.enter="loadAllMaterials"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="resetAllMatQuery">重置</el-button>
+          <el-button type="primary" @click="loadAllMaterials">搜索</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table v-loading="allMaterialsDialog.loading" :data="allMaterials" border stripe>
+        <el-table-column label="封面" align="center" width="100">
+          <template #default="scope">
+            <el-image
+              v-if="scope.row.coverUrl"
+              :src="scope.row.coverUrl"
+              fit="cover"
+              style="width: 60px; height: 40px; border-radius: 4px"
+              :preview-src-list="[scope.row.coverUrl]"
+              hide-on-click-modal
+            />
+            <span v-else class="muted">—</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="资料编号" align="center" prop="materialCode" width="180" :show-overflow-tooltip="true" />
+        <el-table-column label="资料名称" align="center" prop="materialName" min-width="180" :show-overflow-tooltip="true" />
+        <el-table-column label="类型" align="center" prop="materialType" width="120">
+          <template #default="scope">
+            {{ getDictLabel(materialTypeDict, scope.row.materialType) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="上传人" align="center" prop="uploaderName" width="100" />
+        <el-table-column label="上传时间" align="center" prop="createTime" width="170">
+          <template #default="scope">{{ formatDateTime(scope.row.createTime) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" align="center" width="160" fixed="right">
+          <template #default="scope">
+            <el-button link type="primary" @click="handleDownloadMaterial(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"
+        v-show="allMatTotal > 0"
+        v-model:page="allMatQueryParams.pageNum"
+        v-model:limit="allMatQueryParams.pageSize"
+        :total="allMatTotal"
+        @pagination="loadAllMaterials"
       />
-    </el-card>
+    </el-dialog>
 
     <!-- ③ 新增/编辑活动弹窗 -->
     <el-dialog
@@ -190,73 +302,52 @@
       :close-on-click-modal="false"
       @close="closeAddDialog"
     >
-      <el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
+      <el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="110px">
         <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="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-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-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-form-item label="预计参与人数" prop="expectedParticipants">
+          <el-input-number
+            v-model="addForm.expectedParticipants"
+            :min="0"
+            :max="99999"
+            placeholder="请输入预计参与人数"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="活动地点" prop="location">
+          <el-input v-model="addForm.location" placeholder="请输入活动地点" maxlength="200" />
+        </el-form-item>
         <el-form-item label="活动描述" prop="description">
           <el-input
             v-model="addForm.description"
@@ -267,8 +358,8 @@
             show-word-limit
           />
         </el-form-item>
-        <el-form-item label="备注" prop="remark">
-          <el-input v-model="addForm.remark" placeholder="备注(选填)" maxlength="500" />
+        <el-form-item label="负责人" prop="principalName">
+          <el-input v-model="addForm.principalName" placeholder="请输入负责人姓名" maxlength="50" />
         </el-form-item>
       </el-form>
       <template #footer>
@@ -283,11 +374,11 @@
     <el-dialog
       v-model="finishDialog.visible"
       title="活动结束"
-      width="560px"
+      width="640px"
       append-to-body
       :close-on-click-modal="false"
     >
-      <el-form ref="finishFormRef" :model="finishForm" :rules="finishRules" label-width="100px">
+      <el-form ref="finishFormRef" :model="finishForm" :rules="finishRules" label-width="110px">
         <el-form-item label="活动编号">
           <span class="readonly-text">{{ finishForm.campaignCode }}</span>
         </el-form-item>
@@ -317,7 +408,7 @@
       <template #footer>
         <div class="dialog-footer">
           <el-button @click="finishDialog.visible = false">取 消</el-button>
-          <el-button type="primary" @click="submitFinishForm">确  结束</el-button>
+          <el-button type="primary" @click="submitFinishForm">确定结束</el-button>
         </div>
       </template>
     </el-dialog>
@@ -332,9 +423,6 @@
       @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>
@@ -427,7 +515,8 @@ import {
   finishFoodSafetyCampaign,
   getFoodSafetyCampaignStats
 } from '@/api/risk/foodSafetyCampaign';
-import { addFoodSafetyMaterial } from '@/api/risk/foodSafetyMaterial';
+import { addFoodSafetyMaterial, listFoodSafetyMaterial } from '@/api/risk/foodSafetyMaterial';
+import type { RiskFoodSafetyMaterialVo } from '@/api/risk/types';
 import { uploadFile } from '@/api/risk/file';
 import { getDicts } from '@/api/system/dict/data';
 import type { DictDataVO } from '@/api/system/dict/data/types';
@@ -438,7 +527,7 @@ import type {
   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 { Calendar, User, Files, Bell, CaretTop, CaretBottom, Plus, UploadFilled, Download, VideoPlay, CircleClose, Microphone } from '@element-plus/icons-vue';
 import type { ComponentInternalInstance } from 'vue';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
@@ -467,8 +556,14 @@ 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 || [])
+const mapDictData = (resOrList: any) => {
+  // 字典接口被 axios 拦截器自动解包,返回值为数组;兼容部分场景被再包一层 .data 的情况
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
+  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 }));
@@ -481,9 +576,9 @@ const loadDicts = async () => {
       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 || []);
+    campaignTypeDict.value = mapDictData(typeRes as any);
+    campaignStatusDict.value = mapDictData(statusRes as any);
+    materialTypeDict.value = mapDictData(materialRes as any);
   } catch (e) {
     console.error('加载食品安全宣传字典失败', e);
   }
@@ -534,6 +629,7 @@ const addRules = {
   campaignType: [{ required: true, message: '请选择活动类型', trigger: 'change' }],
   startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
   endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
+  expectedParticipants: [{ required: true, type: 'number', message: '请输入预计参与人数', trigger: 'blur' }],
   principalName: [{ required: true, message: '请输入负责人姓名', trigger: 'blur' }]
 };
 
@@ -613,7 +709,6 @@ const handleQuery = () => {
 };
 
 const resetQuery = () => {
-  queryFormRef.value?.resetFields();
   queryParams.value.keyword = '';
   queryParams.value.campaignType = '';
   queryParams.value.status = '';
@@ -621,8 +716,137 @@ const resetQuery = () => {
   getList();
 };
 
+// ============ 导出数据 ============
+const handleExport = async () => {
+  try {
+    ElMessage.info('正在导出数据…');
+    const all = await listFoodSafetyCampaign({
+      pageNum: 1,
+      pageSize: 5000,
+      keyword: queryParams.value.keyword || undefined,
+      campaignType: queryParams.value.campaignType || undefined,
+      status: queryParams.value.status || undefined
+    } as any);
+    const rows = all?.rows || [];
+    if (rows.length === 0) {
+      ElMessage.warning('暂无可导出数据');
+      return;
+    }
+    const headers = ['活动编号', '活动名称', '活动类型', '开始时间', '结束时间', '参与人数', '负责人', '状态'];
+    const csv = [headers.join(',')];
+    for (const r of rows) {
+      const typeLabel = getDictLabel(campaignTypeDict.value, r.campaignType) || '';
+      const cells = [
+        r.campaignCode || '',
+        `"${(r.campaignName || '').replace(/"/g, '""')}"`,
+        typeLabel,
+        formatDateTime(r.startTime),
+        formatDateTime(r.endTime),
+        r.actualParticipants ?? 0,
+        `"${(r.principalName || '').replace(/"/g, '""')}"`,
+        r.status || ''
+      ];
+      csv.push(cells.join(','));
+    }
+    // 加 BOM 避免 Excel 打开中文乱码
+    const blob = new Blob(['\uFEFF' + csv.join('\n')], { type: 'text/csv;charset=utf-8;' });
+    const url = URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
+    link.href = url;
+    link.download = `宣传活动列表_${stamp}.csv`;
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+    URL.revokeObjectURL(url);
+    ElMessage.success(`已导出 ${rows.length} 条数据`);
+  } catch (e: any) {
+    ElMessage.error(e?.msg || '导出失败');
+  }
+};
+
 const loadAll = async () => {
-  await Promise.all([loadStats(), getList()]);
+  await Promise.all([loadStats(), getList(), loadRecentMaterials()]);
+};
+
+// ============ 最新宣传资料 ============
+const recentMaterials = ref<RiskFoodSafetyMaterialVo[]>([]);
+
+const loadRecentMaterials = async () => {
+  try {
+    const res: any = await listFoodSafetyMaterial({ pageNum: 1, pageSize: 3 } as any);
+    // list 接口被 axios 拦截器自动解包,rows 才是真正的表格数据
+    const rows: any[] = Array.isArray(res?.rows) ? res.rows : Array.isArray(res) ? res : [];
+    // 按 createTime 倒序补齐(后端默认按 id 倒序但需要保证一致)
+    recentMaterials.value = [...rows]
+      .sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime())
+      .slice(0, 3);
+  } catch (e) {
+    console.warn('加载最新宣传资料失败', e);
+    recentMaterials.value = [];
+  }
+};
+
+// 点击封面:预览大图
+const handleOpenMaterial = (mat: RiskFoodSafetyMaterialVo) => {
+  if (mat.attachmentUrl) {
+    handleDownloadMaterial(mat);
+  }
+};
+
+// 下载:在浏览器打开附件(OSS 链接可直接下载)
+const handleDownloadMaterial = async (mat: RiskFoodSafetyMaterialVo) => {
+  if (!mat.attachmentUrl) {
+    ElMessage.warning('该资料未上传附件');
+    return;
+  }
+  const a = document.createElement('a');
+  a.href = mat.attachmentUrl;
+  a.download = mat.attachmentName || (mat.materialName ? `${mat.materialName}` : 'material');
+  a.target = '_blank';
+  a.rel = 'noopener';
+  document.body.appendChild(a);
+  a.click();
+  document.body.removeChild(a);
+};
+
+// ============ 全部宣传资料列表弹窗 ============
+const allMaterialsDialog = reactive({ visible: false, loading: false });
+const allMatQueryFormRef = ref<FormInstance>();
+const allMatQueryParams = ref({
+  pageNum: 1,
+  pageSize: 10,
+  keyword: ''
+});
+const allMaterials = ref<RiskFoodSafetyMaterialVo[]>([]);
+const allMatTotal = ref(0);
+
+const loadAllMaterials = async () => {
+  allMaterialsDialog.loading = true;
+  try {
+    const res: any = await listFoodSafetyMaterial(allMatQueryParams.value as any);
+    const payload = res?.rows ? res : { rows: Array.isArray(res) ? res : [], total: 0 };
+    allMaterials.value = payload.rows || [];
+    allMatTotal.value = Number(payload.total) || 0;
+  } catch (e) {
+    ElMessage.error('加载宣传资料失败');
+    allMaterials.value = [];
+    allMatTotal.value = 0;
+  } finally {
+    allMaterialsDialog.loading = false;
+  }
+};
+
+const resetAllMatQuery = () => {
+  allMatQueryFormRef.value?.resetFields();
+  allMatQueryParams.value.keyword = '';
+  allMatQueryParams.value.pageNum = 1;
+  loadAllMaterials();
+};
+
+const openAllMaterials = () => {
+  allMaterialsDialog.visible = true;
+  loadAllMaterials();
 };
 
 // ============ 新增/编辑活动 ============
@@ -731,7 +955,10 @@ const submitFinishForm = () => {
   finishFormRef.value?.validate(async (valid: boolean) => {
     if (!valid) return;
     try {
-      await finishFoodSafetyCampaign(finishForm.id as number, { summary: finishForm.summary });
+      await finishFoodSafetyCampaign(finishForm.id as number, {
+        summary: finishForm.summary,
+        actualParticipants: Number(finishForm.actualParticipants) || 0
+      });
       ElMessage.success('活动已结束');
       finishDialog.visible = false;
       await loadAll();
@@ -787,6 +1014,24 @@ const handleUploadMaterial = (row: RiskFoodSafetyCampaignVo) => {
   materialDialog.visible = true;
 };
 
+// ============ 顶部入口:打开上传资料弹窗(不预选活动) ============
+const openUploadMaterialDialog = () => {
+  materialForm.value = {
+    campaignId: undefined,
+    materialName: '',
+    materialType: '',
+    materialSummary: '',
+    coverUrl: '',
+    coverName: '',
+    attachmentUrl: '',
+    attachmentName: '',
+    attachmentSize: 0
+  } as RiskFoodSafetyMaterialBo;
+  coverFileList.value = [];
+  attachmentFileList.value = [];
+  materialDialog.visible = true;
+};
+
 const closeMaterialDialog = () => {
   materialDialog.visible = false;
   resetMaterialForm();
@@ -796,21 +1041,24 @@ const submitMaterialForm = () => {
   materialFormRef.value?.validate(async (valid: boolean) => {
     if (!valid) return;
     try {
-      // 提取封面
+      // 提取封面(包装返回值 + 旧式 response.data 兼容)
       const cover: any = coverFileList.value.find((f: any) => f.status === 'success');
+      const coverPayload = cover?.response?.response || cover?.response || null;
       if (cover) {
-        materialForm.value.coverUrl = cover.url || cover.response?.data?.url || cover.response?.data?.ossId || '';
-        materialForm.value.coverName = cover.name || cover.response?.data?.originalName || '';
+        materialForm.value.coverUrl =
+          cover.url || coverPayload?.data?.url || coverPayload?.data?.ossId || '';
+        materialForm.value.coverName = cover.name || coverPayload?.data?.originalName || '';
       } else {
         materialForm.value.coverUrl = '';
         materialForm.value.coverName = '';
       }
       // 提取附件
       const attach: any = attachmentFileList.value.find((f: any) => f.status === 'success');
+      const attachPayload = attach?.response?.response || attach?.response || null;
       if (attach) {
         materialForm.value.attachmentUrl =
-          attach.url || attach.response?.data?.url || attach.response?.data?.ossId || '';
-        materialForm.value.attachmentName = attach.name || attach.response?.data?.originalName || '';
+          attach.url || attachPayload?.data?.url || attachPayload?.data?.ossId || '';
+        materialForm.value.attachmentName = attach.name || attachPayload?.data?.originalName || '';
       } else {
         materialForm.value.attachmentUrl = '';
         materialForm.value.attachmentName = '';
@@ -834,18 +1082,23 @@ const ATTACH_MAX_SIZE = 50 * 1024 * 1024; // 50MB
 
 /**
  * 自定义上传(走 axios baseURL,避免跨域/405)
+ *
+ * 注:只返回 success/false,el-upload 内部会根据返回值自动触发 on-success 或 on-error。
+ * 不要再手动调用 options.onSuccess / options.onError,否则会导致同一文件同时弹
+ * 成功+失败两条 toast(经典的双回调 bug)。
  */
 const customUpload = async (options: any) => {
   try {
     const res: any = await uploadFile(options.file);
-    if (res && res.code === 200 && res.data) {
+    if (res && res.code === 200 && res.data && res.data.url) {
       options.file.url = res.data.url;
-      options.onSuccess(res, options.file);
+      // 响应包装成 el-upload 期望的格式,on-success 里通过 response.data.url 取值
+      return { success: true, response: res, file: options.file };
     } else {
-      options.onError(new Error(res?.msg || '上传失败'));
+      return { success: false, error: new Error(res?.msg || '上传失败') };
     }
   } catch (err: any) {
-    options.onError(err);
+    return { success: false, error: err };
   }
 };
 
@@ -876,24 +1129,35 @@ const beforeAttachmentUpload = (file: any) => {
 };
 
 const handleCoverSuccess = (response: any, file: any) => {
-  if (response && response.code === 200) {
+  // customUpload 返回的是包装对象 {success, response, file},response.code 可能是 undefined
+  const realRes = response?.response || response;
+  if (realRes && realRes.code === 200) {
+    // 同步写入表单,避免 prop="coverUrl" 校验一直报红
+    materialForm.value.coverUrl = realRes.data?.url || realRes.data?.ossId || '';
+    materialForm.value.coverName = realRes.data?.originalName || file.name;
     ElMessage.success(`封面 ${file.name} 上传成功`);
   } else {
-    ElMessage.error(`封面 ${file.name} 上传失败: ${response?.msg || '未知错误'}`);
+    ElMessage.error(`封面 ${file.name} 上传失败: ${realRes?.msg || response?.error?.message || '未知错误'}`);
   }
 };
 
 const handleAttachmentSuccess = (response: any, file: any) => {
-  if (response && response.code === 200) {
+  const realRes = response?.response || response;
+  if (realRes && realRes.code === 200) {
+    // 同步写入表单,避免 prop="attachmentUrl" 校验一直报红
+    materialForm.value.attachmentUrl = realRes.data?.url || realRes.data?.ossId || '';
+    materialForm.value.attachmentName = realRes.data?.originalName || file.name;
     ElMessage.success(`附件 ${file.name} 上传成功`);
   } else {
-    ElMessage.error(`附件 ${file.name} 上传失败: ${response?.msg || '未知错误'}`);
+    ElMessage.error(`附件 ${file.name} 上传失败: ${realRes?.msg || response?.error?.message || '未知错误'}`);
   }
 };
 
 const handleUploadError = (error: any) => {
-  console.error('文件上传失败', error);
-  ElMessage.error(`文件上传失败: ${error?.message || '网络异常'}`);
+  // 关键:这里的 error 可能是包装对象 {success:false, error},也可能是 Error
+  const realErr = error?.error || error;
+  console.error('文件上传失败', realErr);
+  ElMessage.error(`文件上传失败: ${realErr?.message || '网络异常'}`);
 };
 
 const handleCoverExceed = () => {
@@ -917,9 +1181,9 @@ const handleAttachmentRemove = () => {
 // ============ 工具 ============
 const getStatusTag = (status: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
   const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
-    未开始: 'info',
-    进行中: 'warning',
-    已结束: 'success'
+    未开始: 'warning',
+    进行中: 'primary',
+    已结束: 'info'
   };
   return map[status] || 'info';
 };
@@ -932,6 +1196,14 @@ const formatDateTime = (val: any): string => {
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
 };
 
+const formatDate = (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())}`;
+};
+
 // ============ 生命周期 ============
 onMounted(async () => {
   await loadDicts();
@@ -973,7 +1245,71 @@ onMounted(async () => {
   font-weight: 600;
   color: #303133;
 }
+.section-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
 
+/* 列表工具栏 */
+.list-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-bottom: 16px;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+.toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.toolbar-right {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+/* 表格活动名称单元格 */
+.campaign-name-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  padding: 6px 0;
+  line-height: 1.4;
+}
+.campaign-name {
+  font-size: 14px;
+  color: #303133;
+  font-weight: 500;
+}
+.campaign-id {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+}
+
+/* 分页区 */
+.pagination-wrapper {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-top: 16px;
+}
+.total-text {
+  font-size: 13px;
+  color: #606266;
+}
+
+/* 弹窗底部左右布局 */
+.dialog-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
 .filter-bar {
   margin-bottom: 16px;
 }
@@ -1078,4 +1414,112 @@ onMounted(async () => {
   line-height: 1.5;
   margin-top: 4px;
 }
+
+/* 最新宣传资料 3 张卡片 */
+.material-col {
+  margin-bottom: 12px;
+}
+.material-card {
+  display: flex;
+  flex-direction: column;
+  border-radius: 8px;
+  border: 1px solid #ebeef5;
+  background: #fff;
+  overflow: hidden;
+  cursor: pointer;
+  transition: box-shadow 0.2s ease, transform 0.2s ease;
+  height: 100%;
+}
+.material-card:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  transform: translateY(-2px);
+}
+.material-cover {
+  width: 100%;
+  height: 160px;
+  background: #f5f7fa;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+.material-cover :deep(.el-image) {
+  width: 100%;
+  height: 100%;
+}
+.material-cover-fallback {
+  color: #c0c4cc;
+  font-size: 14px;
+}
+.material-info {
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+.material-name {
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.material-summary {
+  font-size: 12px;
+  color: #606266;
+  line-height: 1.5;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  min-height: 36px;
+}
+.material-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 12px;
+  color: #909399;
+  border-top: 1px dashed #ebeef5;
+  padding-top: 6px;
+  margin-top: 2px;
+}
+.empty-tip {
+  color: #909399;
+  font-size: 14px;
+  text-align: center;
+  padding: 24px 0;
+}
+.muted {
+  color: #c0c4cc;
+}
+
+/* 表格卡片化 */
+.list-card {
+  border-radius: 8px;
+  border: none;
+  :deep(.el-card__body) {
+    padding: 16px 20px;
+  }
+}
+
+/* 表格表头浅色背景 + 行悬停高亮 */
+:deep(.el-table) {
+  border-radius: 4px;
+  overflow: hidden;
+}
+:deep(.el-table__header-wrapper th.el-table__cell) {
+  background-color: #f5f7fa !important;
+  color: #303133;
+  font-weight: 600;
+  border-bottom: 1px solid #ebeef5;
+  height: 44px;
+}
+:deep(.el-table__row) {
+  transition: background-color 0.2s;
+}
+:deep(.el-table__row:hover > td.el-table__cell) {
+  background-color: #f5f9ff !important;
+}
 </style>

+ 39 - 6
src/views/risk/hiddenDanger/index.vue

@@ -36,9 +36,12 @@
             @change="handleQuery"
           >
             <el-option label="全部类型" value="" />
-            <el-option label="门店安全隐患" value="门店安全隐患" />
-            <el-option label="物流安全隐患" value="物流安全隐患" />
-            <el-option label="市场安全隐患" value="市场安全隐患" />
+            <el-option
+              v-for="d in dangerTypeOptions"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
           </el-select>
         </el-form-item>
         <el-form-item label="处理状态">
@@ -156,9 +159,12 @@
           <el-col :span="12">
             <el-form-item label="隐患类型" prop="dangerType">
               <el-select v-model="form.dangerType" placeholder="请选择隐患类型" style="width: 100%">
-                <el-option label="门店安全隐患" value="门店安全隐患" />
-                <el-option label="物流安全隐患" value="物流安全隐患" />
-                <el-option label="市场安全隐患" value="市场安全隐患" />
+                <el-option
+                  v-for="d in dangerTypeOptions"
+                  :key="d.value"
+                  :label="d.label"
+                  :value="d.value"
+                />
               </el-select>
             </el-form-item>
           </el-col>
@@ -382,6 +388,7 @@ import {
   getHiddenDangerStats
 } from '@/api/risk/hiddenDanger';
 import { uploadFile } from '@/api/risk/file';
+import { getDicts } from '@/api/system/dict/data';
 import type { RiskHiddenDangerBo, RiskHiddenDangerVo } from '@/api/risk/types';
 import { ElMessage, FormInstance } from 'element-plus';
 import { Plus, Warning, Clock, CircleCheck, CircleCloseFilled } from '@element-plus/icons-vue';
@@ -730,6 +737,31 @@ const handleResultOptions = ref<Array<{ label: string; value: string }>>([
   { label: '隐患误报', value: '隐患误报' }
 ]);
 
+// ============ 字典(隐患类型 risk_danger_type: 门店/物流/市场) ============
+const dangerTypeOptions = ref<Array<{ label: string; value: string }>>([]);
+
+const mapDictData = (resOrList: any) => {
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
+  return list
+    .filter((d) => 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 res = await getDicts('risk_danger_type');
+    dangerTypeOptions.value = mapDictData(res);
+  } catch (e) {
+    console.error('加载隐患类型字典失败', e);
+    dangerTypeOptions.value = [];
+  }
+};
+
 // ============ 照片上传(自管理文件 + 预览) ============
 const parsePhotoList = (val: any): string[] => {
   if (!val) return [];
@@ -812,6 +844,7 @@ const applyRouteQuery = () => {
 
 onMounted(() => {
   applyRouteQuery();
+  loadDicts();
   getList();
 });
 </script>

+ 351 - 28
src/views/risk/inspectionTask/index.vue

@@ -69,8 +69,17 @@
                 <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 label="门店" prop="storeId">
+              <el-select
+                v-model="queryParams.storeId"
+                placeholder="全部"
+                clearable
+                filterable
+                style="width: 200px"
+                @change="handleQuery"
+              >
+                <el-option v-for="d in storeOptions" :key="d.value" :label="d.label" :value="d.value" />
+              </el-select>
             </el-form-item>
             <el-form-item label="打卡状态" prop="recordStatus">
               <el-select v-model="queryParams.recordStatus" placeholder="全部" clearable style="width: 180px">
@@ -119,7 +128,7 @@
         </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>
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)">{{ getResultLabel(scope.row.checkResult) }}</el-tag>
             <span v-else>-</span>
           </template>
         </el-table-column>
@@ -154,8 +163,17 @@
         </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 label="门店" prop="storeId">
+              <el-select
+                v-model="form.storeId"
+                placeholder="请选择总部/门店"
+                style="width: 100%"
+                filterable
+                clearable
+                @change="onStoreChange"
+              >
+                <el-option v-for="d in storeOptions" :key="d.value" :label="d.label" :value="d.value" />
+              </el-select>
             </el-form-item>
           </el-col>
           <el-col :span="12">
@@ -177,16 +195,82 @@
               <el-switch v-model="form.scanEnabled" active-text="是" inactive-text="否" @change="onScanChange" />
             </el-form-item>
           </el-col>
+          <el-col v-if="form.scanEnabled" :span="12">
+            <el-form-item label="打卡设备编码" prop="punchDeviceCode">
+              <el-input
+                v-model="form.punchDeviceCode"
+                placeholder="请输入巡检点设备编码(与现场二维码一致)"
+                maxlength="200"
+                show-word-limit
+              />
+            </el-form-item>
+          </el-col>
+          <el-col v-if="form.scanEnabled" :span="12">
+            <el-form-item label="扫码设备名称" prop="punchDeviceName">
+              <el-input
+                v-model="form.punchDeviceName"
+                placeholder="请输入扫码设备名称(如 03号19层强电井)"
+                maxlength="100"
+                show-word-limit
+              />
+            </el-form-item>
+          </el-col>
+          <el-col v-if="form.scanEnabled" :span="12">
+            <el-form-item label="扫码开启时间" prop="scanStartTime">
+              <el-date-picker
+                v-model="form.scanStartTime"
+                type="datetime"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                placeholder="请选择二维码开始生效时间"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col v-if="form.scanEnabled" :span="12">
+            <el-form-item label="扫码间隔(s)" prop="scanIntervalSec">
+              <el-input-number
+                v-model="form.scanIntervalSec"
+                :min="60"
+                :max="86400"
+                :step="60"
+                controls-position="right"
+                style="width: 100%"
+                placeholder="默认 1800=30 分钟"
+              />
+            </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 prop="lat">
+              <template #label>
+                <div class="label-with-btn">
+                  <el-tooltip content="在地图上选点自动填经纬度" placement="top">
+                    <el-button
+                      class="map-pick-btn"
+                      type="primary"
+                      :icon="Location"
+                      circle
+                      @click="openMapPicker"
+                    />
+                  </el-tooltip>
+                  <span>纬度</span>
+                </div>
+              </template>
+              <el-input-number
+                v-model="form.lat"
+                :precision="6"
+                :min="-90"
+                :max="90"
+                controls-position="right"
+                style="width: 100%"
+                placeholder="纬度"
+              />
             </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-input-number v-model="form.lng" :precision="6" :min="-180" :max="180" controls-position="right" style="width: 100%" placeholder="经度" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -198,6 +282,10 @@
               {{ t }}
             </el-tag>
             <el-button size="small" type="primary" plain @click="openAddTimeSlot">添加时间段</el-button>
+            <div v-if="timeSlotList.length > 0" class="time-slot-hint">
+              <el-icon><InfoFilled /></el-icon>
+              <span>扫码窗口已自动同步:<b>{{ form.scanStartTime || '-' }}</b> 起 <b>{{ formatDuration(form.scanIntervalSec) }}</b> 内有效(覆盖全部巡检时段)</span>
+            </div>
           </div>
           <div v-else-if="form.cycle === '每周'">
             <el-checkbox-group v-model="weekDayList">
@@ -253,7 +341,7 @@
           </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-select v-model="scope.row.itemCategory" 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>
@@ -319,7 +407,7 @@
         </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>
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)">{{ getResultLabel(scope.row.checkResult) }}</el-tag>
             <span v-else>-</span>
           </template>
         </el-table-column>
@@ -357,7 +445,7 @@
           <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>
+          <el-tag v-if="detailForm.checkResult" :type="getResultTag(detailForm.checkResult)">{{ getResultLabel(detailForm.checkResult) }}</el-tag>
           <span v-else>-</span>
         </el-descriptions-item>
         <el-descriptions-item label="打卡地点">{{ detailForm.checkInLocation }}</el-descriptions-item>
@@ -379,13 +467,24 @@
         <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>
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)" size="small">{{ getResultLabel(scope.row.checkResult) }}</el-tag>
             <span v-else>-</span>
           </template>
         </el-table-column>
-        <el-table-column label="照片" width="80">
+        <el-table-column label="照片" min-width="160">
           <template #default="scope">
-            <span v-if="scope.row.checkPhotos">{{ scope.row.checkPhotos }}</span>
+            <template v-if="scope.row.checkPhotos">
+              <el-image
+                v-for="(url, i) in String(scope.row.checkPhotos).split(',').filter((u) => u)"
+                :key="i"
+                :src="url.trim()"
+                :preview-src-list="String(scope.row.checkPhotos).split(',').map((u) => u.trim()).filter((u) => u)"
+                :initial-index="i"
+                :preview-teleported="true"
+                fit="cover"
+                style="width: 60px; height: 60px; margin-right: 6px; border-radius: 4px;"
+              />
+            </template>
             <span v-else>-</span>
           </template>
         </el-table-column>
@@ -439,11 +538,19 @@
       </template>
     </el-dialog>
 
+    <!-- 地图选点弹框 -->
+    <MapPickerDialog
+      v-model="mapPickerVisible"
+      :init-lat="form.lat"
+      :init-lng="form.lng"
+      @pick="onMapPick"
+    />
+
   </div>
 </template>
 
 <script setup name="InspectionTask" lang="ts">
-import { ref, reactive, toRefs, onMounted, getCurrentInstance } from 'vue';
+import { ref, reactive, toRefs, onMounted, getCurrentInstance, watch } from 'vue';
 import {
   listInspectionTask,
   getInspectionTask,
@@ -454,26 +561,29 @@ import {
   listInspectionRecordByTask
 } from '@/api/risk/inspectionTask';
 import { listInspectionItemByTask } from '@/api/risk/inspectionItem';
-import { auditInspectionRecord } from '@/api/risk/inspectionRecord';
+import { auditInspectionRecord, listInspectionRecordItemByRecord } from '@/api/risk/inspectionRecord';
 import type {
   RiskInspectionTaskBo,
   RiskInspectionTaskVo,
   RiskInspectionItemVo,
   RiskInspectionRecordVo,
   RiskInspectionRecordBo,
+  RiskInspectionRecordItemVo,
   InspectionStatsVo
 } from '@/api/risk/types';
 import { listByCurrentDeptChainUsers } from '@/api/system/user';
 import type { UserVO } from '@/api/system/user/types';
+import { listDept } from '@/api/system/dept';
 import { FormInstance } from 'element-plus';
 import type { ComponentInternalInstance } from 'vue';
-import { Calendar, Check, Warning, Clock } from '@element-plus/icons-vue';
+import { Calendar, Check, Warning, Clock, Location, InfoFilled } from '@element-plus/icons-vue';
+import MapPickerDialog from '@/components/MapPickerDialog/index.vue';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
 interface InspectionItemForm {
   id?: number;
-  itemType: string;
+  itemCategory: string;
   itemContent: string;
   itemStandard: string;
   needPhoto: boolean;
@@ -489,6 +599,9 @@ const recordList = ref<RiskInspectionRecordVo[]>([]);
 const recordLoading = ref(false);
 // 责任人/审核人选择器数据(当前登录用户所在部门及所有下级部门用户)
 const deptChainUserOptions = ref<UserVO[]>([]);
+// 门店下拉(集团总部 + 65 个门店,dept_category='门店')
+// value 用 string 与后端返回的 storeId (JSON 大数防精度丢失) 严格匹配
+const storeOptions = ref<Array<{ label: string; value: string }>>([]);
 // 字典
 const {
   risk_inspection_type: inspection_type_dict,
@@ -516,6 +629,20 @@ const stats = ref<InspectionStatsVo>({
   unchecked: 0
 });
 
+// 地图选点弹框
+const mapPickerVisible = ref(false);
+function openMapPicker() {
+  mapPickerVisible.value = true;
+}
+function onMapPick(payload: { lat: number; lng: number; address?: string }) {
+  form.value.lat = payload.lat;
+  form.value.lng = payload.lng;
+  // 选完顺便把地址带回位置区域(只有空时才填,不覆盖已有)
+  if (payload.address && !form.value.location) {
+    form.value.location = payload.address;
+  }
+}
+
 // 时间配置
 const timeSlotList = ref<string[]>([]);
 const weekDayList = ref<string[]>([]);
@@ -538,11 +665,16 @@ const data = reactive<any>({
     id: undefined,
     taskName: '',
     taskType: '',
+    storeId: undefined as string | undefined,
     storeName: '',
     location: '',
     cycle: '每日',
     scanEnabled: false,
     checkMethod: '常规',
+    punchDeviceCode: '',
+    punchDeviceName: '',
+    scanStartTime: undefined,
+    scanIntervalSec: 1800,
     lng: undefined,
     lat: undefined,
     inspectorName: '',
@@ -557,7 +689,7 @@ const data = reactive<any>({
     pageSize: 10,
     taskName: '',
     taskType: '',
-    storeName: '',
+    storeId: undefined as string | undefined,
     recordStatus: '',
     checkResult: ''
   },
@@ -565,7 +697,7 @@ const data = reactive<any>({
     taskName: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
     taskType: [{ required: true, message: '请选择巡检类型', trigger: 'change' }],
     cycle: [{ required: true, message: '请选择巡检周期', trigger: 'change' }],
-    storeName: [{ required: true, message: '请输入门店', trigger: 'blur' }],
+    storeId: [{ required: true, message: '请选择门店/总部', trigger: 'change' }],
     location: [{ required: true, message: '请输入位置区域', trigger: 'blur' }],
     inspectorId: [{ required: true, message: '请选择责任人', trigger: 'change' }],
     auditorId: [{ required: true, message: '请选择审核人', trigger: 'change' }]
@@ -610,6 +742,7 @@ const handleQuery = () => {
 const resetQuery = () => {
   queryFormRef.value?.resetFields();
   queryParams.value.pageNum = 1;
+  queryParams.value.storeId = undefined as number | undefined;
   handleQuery();
 };
 
@@ -618,11 +751,16 @@ const resetForm = () => {
     id: undefined,
     taskName: '',
     taskType: '',
+    storeId: undefined as string | undefined,
     storeName: '',
     location: '',
     cycle: '每日',
     scanEnabled: false,
     checkMethod: '常规',
+    punchDeviceCode: '',
+    punchDeviceName: '',
+    scanStartTime: undefined,
+    scanIntervalSec: 1800,
     lng: undefined,
     lat: undefined,
     inspectorId: undefined,
@@ -676,6 +814,18 @@ const onAuditorChange = (val: string | number | undefined) => {
   form.value.auditorName = user?.nickName || user?.userName || '';
 };
 
+/**
+ * 选择门店后,同步回填 storeName 字段
+ */
+const onStoreChange = (val: string | number | undefined) => {
+  if (val === undefined || val === null || val === '') {
+    form.value.storeName = '';
+    return;
+  }
+  const opt = storeOptions.value.find((s) => String(s.value) === String(val));
+  form.value.storeName = opt?.label || '';
+};
+
 const onCycleChange = (val: string) => {
   timeSlotList.value = [];
   weekDayList.value = [];
@@ -705,9 +855,74 @@ const removeTimeSlot = (idx: number) => {
   timeSlotList.value.splice(idx, 1);
 };
 
+// ============ 扫码时间自动同步(从 timeSlotList 推导 scanStartTime + scanIntervalSec) ============
+/**
+ * 解析 "HH:mm-HH:mm" 字符串 → { start: 'HH:mm', end: 'HH:mm' } 或 null
+ * 兼容 "16:10-17:00" / "8:5-9:30"(自动补 0)
+ */
+const parseSlot = (s: string): { start: string; end: string } | null => {
+  if (!s || typeof s !== 'string') return null;
+  const m = s.match(/^\s*(\d{1,2}):(\d{1,2})\s*-\s*(\d{1,2}):(\d{1,2})\s*$/);
+  if (!m) return null;
+  const sh = +m[1], sm = +m[2], eh = +m[3], em = +m[4];
+  if ([sh, sm, eh, em].some((n) => Number.isNaN(n))) return null;
+  if (sh > 23 || sm > 59 || eh > 23 || em > 59) return null;
+  if (timeToSec(`${eh}:${em}`) <= timeToSec(`${sh}:${sm}`)) return null; // 结束必须 > 开始
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return { start: `${pad(sh)}:${pad(sm)}`, end: `${pad(eh)}:${pad(em)}` };
+};
+
+/** "HH:mm" → 当天 0 点起的秒数 */
+const timeToSec = (t: string): number => {
+  const [h, m] = t.split(':').map(Number);
+  return h * 3600 + m * 60;
+};
+
+/** 今天 YYYY-MM-DD(本地时区) */
+const todayStr = (): string => {
+  const d = new Date();
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+};
+
+/** 秒数 → "X 小时 Y 分" / "Y 分 Z 秒" 人类可读 */
+const formatDuration = (sec: number | undefined | null): string => {
+  if (!sec || sec <= 0) return '0 秒';
+  const h = Math.floor(sec / 3600);
+  const m = Math.floor((sec % 3600) / 60);
+  const s = sec % 60;
+  if (h > 0) return `${h} 小时 ${m} 分`;
+  if (m > 0) return s > 0 ? `${m} 分 ${s} 秒` : `${m} 分`;
+  return `${s} 秒`;
+};
+
+/**
+ * 根据 timeSlotList 自动同步 scanStartTime + scanIntervalSec
+ * - 仅 cycle=每日 时生效(其他周期用 dayOfWeek/dayOfMonth,timeSlot 留空)
+ * - 首个 slot 的开始 = 扫码开启时间
+ * - 末个 slot 的结束 - 首个 slot 的开始 = 扫码窗口长度(秒)
+ *   覆盖整个巡检窗口期,任意 slot 期间扫码都有效
+ * - 没有 slot 时不动,保留默认/手动值
+ */
+const syncScanTime = () => {
+  if (form.value.cycle !== '每日') return;
+  const slots = timeSlotList.value
+    .map(parseSlot)
+    .filter((s): s is { start: string; end: string } => !!s);
+  if (slots.length === 0) return;
+  const first = slots[0];
+  const last = slots[slots.length - 1];
+  form.value.scanStartTime = `${todayStr()} ${first.start}:00`;
+  form.value.scanIntervalSec = Math.max(60, timeToSec(last.end) - timeToSec(first.start));
+};
+
+watch(timeSlotList, () => {
+  syncScanTime();
+}, { deep: true });
+
 const addInspectionItem = () => {
   form.value.itemList.push({
-    itemType: '',
+    itemCategory: '',
     itemContent: '',
     itemStandard: '',
     needPhoto: false
@@ -730,8 +945,23 @@ const handleUpdate = async (row: RiskInspectionTaskVo) => {
   if (deptChainUserOptions.value.length === 0) {
     await loadDeptChainUsers();
   }
+  if (storeOptions.value.length === 0) {
+    await loadStores();
+  }
   const res = await getInspectionTask(row.id);
   Object.assign(form.value, res.data);
+  // storeId 统一转 string 与 storeOptions.value 严格匹配
+  if (form.value.storeId !== undefined && form.value.storeId !== null) {
+    form.value.storeId = String(form.value.storeId);
+    const opt = storeOptions.value.find((s) => s.value === form.value.storeId);
+    if (opt && !form.value.storeName) {
+      form.value.storeName = opt.label;
+    }
+  }
+  // scanEnabled 后端返 '1'/'0' 字符串,el-switch 严格 boolean,统一转
+  if (form.value.scanEnabled !== undefined && form.value.scanEnabled !== null) {
+    form.value.scanEnabled = form.value.scanEnabled === '1' || form.value.scanEnabled === true;
+  }
   // 恢复时间配置
   if (res.data?.timeSlot) {
     timeSlotList.value = res.data.timeSlot.split(',').filter((s: string) => s);
@@ -747,10 +977,10 @@ const handleUpdate = async (row: RiskInspectionTaskVo) => {
     const itemRes = await listInspectionItemByTask(row.id);
     form.value.itemList = (itemRes.data || []).map((it: RiskInspectionItemVo) => ({
       id: it.id,
-      itemType: it.itemType,
+      itemCategory: it.itemCategory || it.itemType || '',
       itemContent: it.itemContent,
       itemStandard: it.itemStandard,
-      needPhoto: false
+      needPhoto: it.needPhoto === '1' || it.needPhoto === true
     }));
   } catch (e) {
     form.value.itemList = [];
@@ -770,9 +1000,23 @@ const handleViewRecords = async (row: RiskInspectionTaskVo) => {
   recordDialog.visible = true;
 };
 
-const handleViewDetail = (row: RiskInspectionRecordVo) => {
+const handleViewDetail = async (row: RiskInspectionRecordVo) => {
   detailForm.value = { ...row, itemList: [] };
   detailDialog.visible = true;
+  try {
+    const res = await listInspectionRecordItemByRecord(row.id);
+    detailForm.value.itemList = (res.data || []).map((it: RiskInspectionRecordItemVo) => ({
+      itemType: it.itemType || it.itemCategory,
+      itemContent: it.itemContent,
+      itemStandard: it.itemStandard,
+      checkResult: it.checkResult,
+      remark: it.remark,
+      checkPhotos: it.photos || it.checkPhotos,
+      problemCount: it.problemCount
+    }));
+  } catch (e) {
+    detailForm.value.itemList = [];
+  }
 };
 
 // ============ 审核相关 ============
@@ -884,13 +1128,25 @@ const submitForm = () => {
       form.value.timeSlot = '';
       form.value.dayOfWeek = '';
     }
-    form.value.scanEnabled = form.value.scanEnabled ? '1' : '0';
+    // 构造提交 payload(把 boolean scanEnabled 转 '1'/'0',但不改 form.value 本身,
+    // 避免下次打开弹窗时状态不一致)
+    const payload: any = {
+      ...form.value,
+      scanEnabled: form.value.scanEnabled ? '1' : '0',
+      itemList: (form.value.itemList || []).map((it: any) => ({
+        ...it,
+        // 后端 entity 字段是 itemCategory
+        itemCategory: it.itemCategory || it.itemType || '',
+        // needPhoto 归一化:boolean true → '1', false → '0'
+        needPhoto: it.needPhoto ? '1' : '0'
+      }))
+    };
 
     if (form.value.id) {
-      await updateInspectionTask(form.value as RiskInspectionTaskBo);
+      await updateInspectionTask(payload as RiskInspectionTaskBo);
       proxy?.$modal.msgSuccess('修改成功');
     } else {
-      await addInspectionTask(form.value as RiskInspectionTaskBo);
+      await addInspectionTask(payload as RiskInspectionTaskBo);
       proxy?.$modal.msgSuccess('新增成功');
     }
     dialog.visible = false;
@@ -928,15 +1184,49 @@ const getStatusTag = (s: string): 'primary' | 'success' | 'warning' | 'info' | '
 const getResultTag = (r: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
   const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
     正常: 'success',
-    异常: 'danger'
+    异常: 'danger',
+    '0': 'success', // 0=正常
+    '1': 'danger'  // 1=异常
   };
   return (map[r] || 'info') as any;
 };
 
+/**
+ * 巡检结果码 → 中文标签
+ * - 后端存储:0=正常 / 1=异常(也兼容直接存"正常"/"异常"的老数据)
+ */
+const getResultLabel = (r: string): string => {
+  const map: Record<string, string> = {
+    '0': '正常',
+    '1': '异常',
+    正常: '正常',
+    异常: '异常'
+  };
+  return map[r] || r || '-';
+};
+
 onMounted(async () => {
   await loadAll();
   await loadDeptChainUsers();
+  await loadStores();
 });
+
+/** 加载门店下拉(集团总部 + dept_category='门店' 的所有门店) */
+const loadStores = async () => {
+  try {
+    const res: any = await listDept({});
+    const list: any[] = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
+    // 总部(dept_id=100 '家得福集团总部') + 所有 dept_category='门店' 的门店
+    storeOptions.value = list
+      .filter((d: any) => d.deptCategory === '门店' || d.deptId === 100)
+      .filter((d: any) => d.status === '0' || d.status === undefined || d.status === null)
+      .map((d: any) => ({ label: d.deptName, value: String(d.deptId) }))
+      .sort((a, b) => Number(a.value) - Number(b.value));
+  } catch (e) {
+    console.error('加载门店列表失败', e);
+    storeOptions.value = [];
+  }
+};
 </script>
 
 <style scoped lang="scss">
@@ -946,6 +1236,26 @@ onMounted(async () => {
   overflow-y: auto;
   box-sizing: border-box;
 }
+.lat-input-wrap {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+}
+.label-with-btn {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  white-space: nowrap;
+}
+.map-pick-btn {
+  flex-shrink: 0;
+  box-shadow: 0 2px 6px rgba(64, 158, 255, 0.25);
+  &:hover {
+    transform: scale(1.05);
+    transition: transform 0.15s ease;
+  }
+}
 .stat-card {
   border-radius: 8px;
   :deep(.el-card__body) { padding: 20px; }
@@ -1007,4 +1317,17 @@ onMounted(async () => {
   display: flex;
   align-items: center;
 }
+.time-slot-hint {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #909399;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  line-height: 1.4;
+  b {
+    color: #67c23a;
+    font-weight: 600;
+  }
+}
 </style>

+ 290 - 67
src/views/risk/personnel/index.vue

@@ -29,14 +29,22 @@
             />
           </el-select>
         </el-form-item>
-        <el-form-item label="工作门店" prop="storeName">
-          <el-input
-            v-model="queryParams.storeName"
-            placeholder="请输入门店名称"
-            style="width: 220px"
+        <el-form-item label="工作门店" prop="storeId">
+          <el-select
+            v-model="queryParams.storeId"
+            placeholder="请选择工作门店"
             clearable
-            @keyup.enter="handleQuery"
-          />
+            filterable
+            style="width: 220px"
+            @change="handleQuery"
+          >
+            <el-option
+              v-for="d in storeOptions"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
+          </el-select>
         </el-form-item>
         <el-form-item>
           <el-button type="primary" @click="handleQuery">搜索</el-button>
@@ -56,7 +64,9 @@
         <el-table-column type="index" label="序号" align="center" width="60" />
         <el-table-column label="类型" align="center" prop="jobType" width="100">
           <template #default="scope">
-            <el-tag :type="getJobTypeTag(scope.row.jobType)">{{ scope.row.jobType || '-' }}</el-tag>
+            <el-tag :type="getJobTypeTag(scope.row.jobType)">
+              {{ getDictLabel(personTypeOptions, scope.row.jobType) || scope.row.jobType || '-' }}
+            </el-tag>
           </template>
         </el-table-column>
         <el-table-column label="姓名" align="center" prop="personnelName" width="100" />
@@ -66,18 +76,14 @@
             <span>{{ maskIdCard(scope.row.idCard) }}</span>
           </template>
         </el-table-column>
-        <el-table-column label="岗位证书" align="center" min-width="200">
+        <el-table-column label="岗位证书" align="center" min-width="120">
           <template #default="scope">
-            <el-tag
-              v-for="(cert, idx) in getPersonnelCerts(scope.row.id)"
-              :key="idx"
-              type="success"
-              size="small"
-              style="margin-right: 4px; margin-bottom: 4px"
-            >
-              {{ cert.certName || cert.certType }}
-            </el-tag>
-            <span v-if="getPersonnelCerts(scope.row.id).length === 0" class="text-placeholder">暂无证书</span>
+            <template v-if="getPersonnelCerts(scope.row.id).length > 0">
+              <el-tag type="primary" size="small" effect="plain">
+                {{ getPersonnelCerts(scope.row.id).length }} 个证书
+              </el-tag>
+            </template>
+            <span v-else class="text-placeholder">暂无证书</span>
           </template>
         </el-table-column>
         <el-table-column label="工作门店" align="center" prop="storeName" :show-overflow-tooltip="true" width="160" />
@@ -108,7 +114,7 @@
     <el-dialog
       v-model="dialog.visible"
       :title="dialog.title"
-      width="820px"
+      width="960px"
       append-to-body
       :close-on-click-modal="false"
       @close="closeDialog"
@@ -161,8 +167,21 @@
 
         <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 label="工作门店" prop="storeId">
+              <el-select
+                v-model="form.storeId"
+                placeholder="请选择工作门店"
+                filterable
+                style="width: 100%"
+                @change="handleStoreChange"
+              >
+                <el-option
+                  v-for="d in storeOptions"
+                  :key="d.value"
+                  :label="d.label"
+                  :value="d.value"
+                />
+              </el-select>
             </el-form-item>
           </el-col>
           <el-col :span="12">
@@ -223,7 +242,7 @@
               <el-select
                 v-model="cert.certType"
                 placeholder="请选择证书类型"
-                style="width: 220px"
+                style="width: 180px"
               >
                 <el-option
                   v-for="d in certTypeOptions"
@@ -232,6 +251,17 @@
                   :value="d.value"
                 />
               </el-select>
+              <el-date-picker
+                v-model="cert.validPeriod"
+                type="daterange"
+                range-separator="至"
+                start-placeholder="生效日期"
+                end-placeholder="到期日期"
+                value-format="YYYY-MM-DD"
+                format="YYYY-MM-DD"
+                style="width: 240px"
+                unlink-panels
+              />
               <el-upload
                 v-model:file-list="cert.fileList"
                 :http-request="customCertUpload"
@@ -267,20 +297,44 @@
     </el-dialog>
 
     <!-- 查看证书弹窗 -->
-    <el-dialog v-model="certDialog.visible" title="岗位证书" width="640px" append-to-body>
-      <el-descriptions v-if="currentCerts.length > 0" :column="1" border>
+    <el-dialog v-model="certDialog.visible" title="岗位证书" width="720px" append-to-body>
+      <el-descriptions v-if="currentCerts.length > 0" :column="2" border>
         <el-descriptions-item
           v-for="(cert, idx) in currentCerts"
           :key="idx"
           :label="cert.certName || cert.certType"
+          :span="2"
         >
-          <div v-if="cert.certFile" class="cert-view-box">
-            <el-icon :size="22" class="cert-view-icon"><Document /></el-icon>
-            <el-link :href="cert.certFile" target="_blank" type="primary" style="margin-left: 6px">
-              查看证书文件
-            </el-link>
+          <div class="cert-view-row">
+            <span class="cert-view-meta">
+              <span class="meta-label">有效期:</span>
+              <span class="meta-value">
+                {{ formatCertPeriod(cert.issueDate, cert.expireDate) }}
+              </span>
+            </span>
+            <span class="cert-view-meta">
+              <el-tag
+                :type="getCertStatusTag(cert.certStatus, cert.expireDate)"
+                size="small"
+              >
+                {{ getCertStatusLabel(cert.certStatus, cert.expireDate) }}
+              </el-tag>
+            </span>
+            <span class="cert-view-meta">
+              <template v-if="cert.certFile">
+                <el-icon :size="20" class="cert-view-icon"><Document /></el-icon>
+                <el-link
+                  :href="cert.certFile"
+                  target="_blank"
+                  type="primary"
+                  style="margin-left: 6px"
+                >
+                  查看证书文件
+                </el-link>
+              </template>
+              <span v-else class="text-placeholder">未上传证书文件</span>
+            </span>
           </div>
-          <span v-else class="text-placeholder">未上传证书文件</span>
         </el-descriptions-item>
       </el-descriptions>
       <div v-else class="empty-tip">该人员暂无岗位证书</div>
@@ -302,6 +356,7 @@ import {
 import { listPersonnelCertByPersonnel } from '@/api/risk/personnelCert';
 import { uploadFile } from '@/api/risk/file';
 import { getDicts } from '@/api/system/dict/data';
+import { listDept } from '@/api/system/dept';
 import type { DictDataVO } from '@/api/system/dict/data/types';
 import type { RiskPersonnelBo, RiskPersonnelVo, RiskPersonnelCertVo } from '@/api/risk/types';
 import { Plus, Document } from '@element-plus/icons-vue';
@@ -316,6 +371,7 @@ interface CertItem {
   certType: string;
   certName?: string;
   certFile?: string;
+  validPeriod?: string[]; // [生效日期, 到期日期]
   fileList: UploadUserFile[];
 }
 
@@ -330,6 +386,9 @@ const personTypeOptions = ref<Array<{ label: string; value: string }>>([]);
 const certTypeOptions = ref<Array<{ label: string; value: string }>>([]);
 const insuranceStatusOptions = ref<Array<{ label: string; value: string }>>([]);
 
+// 门店列表(从 sys_dept 拉 dept_category='门店' 的)
+const storeOptions = ref<Array<{ label: string; value: number }>>([]);
+
 const queryFormRef = ref<FormInstance>();
 const formRef = ref<FormInstance>();
 
@@ -341,21 +400,41 @@ const loadDicts = async () => {
       getDicts('certificate_type'),
       getDicts('insurance_status')
     ]);
-    personTypeOptions.value = mapDictData(personType.data || []);
-    certTypeOptions.value = mapDictData(certType.data || []);
-    insuranceStatusOptions.value = mapDictData(insuranceStatus.data || []);
+    personTypeOptions.value = mapDictData(personType);
+    certTypeOptions.value = mapDictData(certType);
+    insuranceStatusOptions.value = mapDictData(insuranceStatus);
   } catch (e) {
     console.error('加载字典失败', e);
   }
 };
 
-const mapDictData = (list: DictDataVO[]) => {
+const mapDictData = (resOrList: any) => {
+  // 字典接口被 axios 拦截器自动解包,返回值为数组;兼容部分场景被再包一层 .data 的情况
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
   return list
     .filter((d) => d.status === '0' || d.status === undefined)
     .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
     .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
 };
 
+/** 加载门店列表(从 sys_dept 过滤 dept_category='门店') */
+const loadStores = async () => {
+  try {
+    const res: any = await listDept({});
+    const list: any[] = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
+    storeOptions.value = list
+      .filter((d: any) => d.deptCategory === '门店' && (d.status === '0' || d.status === undefined))
+      .map((d: any) => ({ label: d.deptName, value: d.deptId }));
+  } catch (e) {
+    console.error('加载门店列表失败', e);
+    storeOptions.value = [];
+  }
+};
+
 const validateIdCard = (_rule: any, value: any, callback: any) => {
   if (!value) {
     callback(new Error('请输入身份证号'));
@@ -401,13 +480,13 @@ const data = reactive<any>({
     pageSize: 10,
     personnelName: '',
     jobType: '',
-    storeName: ''
+    storeId: undefined
   },
   rules: {
     jobType: [{ required: true, message: '请选择人员类型', trigger: 'change' }],
     personnelName: [{ required: true, message: '姓名不能为空', trigger: 'blur' }],
     idCard: [{ required: true, validator: validateIdCard, trigger: 'blur' }],
-    storeName: [{ required: true, message: '请输入工作门店', trigger: 'blur' }],
+    storeId: [{ required: true, message: '请选择工作门店', trigger: 'change' }],
     insuranceStatus: [{ required: true, message: '请选择保险状态', trigger: 'change' }],
     insuranceStartDate: [
       {
@@ -484,6 +563,12 @@ const handleInsuranceStatusChange = (val: string) => {
   }
 };
 
+/** 工作门店选择变化:自动回填 storeName */
+const handleStoreChange = (deptId: number | string) => {
+  const found = storeOptions.value.find((s) => s.value === deptId);
+  form.value.storeName = found?.label || '';
+};
+
 /** 列表中保险状态标签 */
 const getInsuranceLabel = (row: any) => {
   if (row.insuranceStatus === '未投保') return '未投保';
@@ -509,15 +594,76 @@ const getInsuranceTag = (row: any) => {
   return map[label] || 'info';
 };
 
+// 岗位证书 tag 颜色(按字典 value 映射)
+const getCertTypeTag = (type: string) => {
+  const map: Record<string, string> = {
+    防损资格证: 'primary',    // 蓝
+    电工操作证: 'warning',    // 橙
+    焊工操作证: 'danger',     // 红
+    高处作业证: 'success'     // 绿
+  };
+  return map[type] || 'info';
+};
+
+/** 计算证书有效状态:已过期/即将到期(≤30天)/有效 */
+const calcCertStatus = (expireDate?: string): { label: string; tag: string } => {
+  if (!expireDate) return { label: '未知', tag: 'info' };
+  const expire = new Date(expireDate).getTime();
+  if (Number.isNaN(expire)) return { label: '未知', tag: 'info' };
+  const now = Date.now();
+  if (expire < now) return { label: '已过期', tag: 'danger' };
+  const diffDays = Math.floor((expire - now) / (1000 * 60 * 60 * 24));
+  if (diffDays <= 30) return { label: '即将到期', tag: 'warning' };
+  return { label: '有效', tag: 'success' };
+};
+
+/** 查看证书弹窗用:获取证书状态(优先用后端 certStatus,否则按 expireDate 实时计算) */
+const getCertStatusLabel = (certStatus?: string, expireDate?: string) => {
+  if (certStatus && certStatus !== '有效') return certStatus;
+  return calcCertStatus(expireDate).label;
+};
+
+const getCertStatusTag = (certStatus?: string, expireDate?: string): any => {
+  if (certStatus && certStatus !== '有效') {
+    const map: Record<string, string> = {
+      已过期: 'danger',
+      即将到期: 'warning',
+      有效: 'success',
+      无效: 'danger'
+    };
+    return map[certStatus] || 'info';
+  }
+  return calcCertStatus(expireDate).tag;
+};
+
+/** 格式化证书有效期区间显示 */
+const formatCertPeriod = (issueDate?: string, expireDate?: string) => {
+  const fmt = (d?: string) => (d ? d.substring(0, 10) : '—');
+  return `${fmt(issueDate)} ~ ${fmt(expireDate)}`;
+};
+
+// 人员类型 tag 颜色(按字典 value 映射,与设计稿一致)
 const getJobTypeTag = (type: string) => {
   const map: Record<string, string> = {
-    '1': 'primary',
-    '2': 'warning',
-    '3': 'danger'
+    防损人员: 'primary',             // 蓝色
+    施工人员: 'warning',             // 橙色
+    特岗人员: 'danger',              // 红色
+    食品相关岗位人员: 'success',     // 绿色
+    检测员: 'info',                  // 灰色
+    兼职品控人员: 'primary'          // 蓝色
   };
   return map[type] || 'info';
 };
 
+// 字典值 -> 中文 label(用于列表渲染)
+const getDictLabel = (
+  opts: Array<{ label: string; value: string }>,
+  val: string
+) => {
+  if (!val) return ''
+  return opts.find((o) => o.value === val)?.label || val
+}
+
 const getList = async () => {
   loading.value = true;
   const res = await listPersonnel(queryParams.value as RiskPersonnelBo);
@@ -558,7 +704,7 @@ const resetQuery = () => {
   queryParams.value.pageNum = 1;
   queryParams.value.personnelName = '';
   queryParams.value.jobType = '';
-  queryParams.value.storeName = '';
+  queryParams.value.storeId = undefined;
   handleQuery();
 };
 
@@ -575,26 +721,49 @@ const handleUpdate = async (row: RiskPersonnelVo) => {
   if (form.value.idCard) {
     form.value.age = calculateAge(form.value.idCard);
   }
+  // 兜底:如果当前 storeId 不在 storeOptions 里(脏数据 / 门店被删/改了分类),
+  //       临时塞一条 {label: storeName, value: storeId} 进 storeOptions,
+  //       保证 el-select 能显示中文名而不是裸 ID
+  if (
+    form.value.storeId != null &&
+    !storeOptions.value.find((s) => s.value === form.value.storeId)
+  ) {
+    storeOptions.value = [
+      ...storeOptions.value,
+      { label: form.value.storeName || `门店#${form.value.storeId}`, value: form.value.storeId }
+    ];
+  }
   try {
     const certRes = await listPersonnelCertByPersonnel(row.id);
     const certs = certRes.data || [];
-    form.value.certList = certs.map((c) => ({
-      uid: -Math.floor(Math.random() * 100000),
-      id: c.id,
-      certType: c.certType,
-      certName: c.certName,
-      certFile: c.certFile,
-      fileList: c.certFile
-        ? [
-            {
-              uid: -Math.floor(Math.random() * 100000),
-              name: c.certName || c.certType || '证书',
-              status: 'success',
-              url: c.certFile
-            }
-          ]
-        : []
-    }));
+    form.value.certList = certs.map((c) => {
+      // 后端 issueDate/expireDate → 前端 validPeriod 日期范围
+      let validPeriod: string[] | undefined;
+      if (c.issueDate || c.expireDate) {
+        validPeriod = [
+          c.issueDate ? c.issueDate.substring(0, 10) : '',
+          c.expireDate ? c.expireDate.substring(0, 10) : ''
+        ];
+      }
+      return {
+        uid: -Math.floor(Math.random() * 100000),
+        id: c.id,
+        certType: c.certType,
+        certName: c.certName,
+        certFile: c.certFile,
+        validPeriod,
+        fileList: c.certFile
+          ? [
+              {
+                uid: -Math.floor(Math.random() * 100000),
+                name: c.certName || c.certType || '证书',
+                status: 'success',
+                url: c.certFile
+              }
+            ]
+          : []
+      };
+    });
   } catch (e) {
     form.value.certList = [];
   }
@@ -694,13 +863,39 @@ const submitForm = () => {
     if (valid) {
       const certBos = (form.value.certList || [])
         .filter((c: CertItem) => c.certType)
-        .map((c: CertItem) => ({
-          id: c.id,
-          certType: c.certType,
-          certName: c.certType,
-          certFile: c.certFile || '',
-          certStatus: '有效'
-        }));
+        .map((c: CertItem) => {
+          // 证书有效期 validPeriod → issueDate / expireDate
+          const issueDate =
+            c.validPeriod && c.validPeriod[0]
+              ? `${c.validPeriod[0]} 00:00:00`
+              : '';
+          const expireDate =
+            c.validPeriod && c.validPeriod[1]
+              ? `${c.validPeriod[1]} 23:59:59`
+              : '';
+
+          // 根据到期日期自动判断证书状态
+          let certStatus = '有效';
+          if (expireDate) {
+            const expire = new Date(expireDate).getTime();
+            if (expire < Date.now()) {
+              certStatus = '已过期';
+            } else {
+              const diffDays = Math.floor((expire - Date.now()) / (1000 * 60 * 60 * 24));
+              if (diffDays <= 30) certStatus = '即将到期';
+            }
+          }
+
+          return {
+            // 不传 id:后端走物理删+insert 模式(AUTO_INCREMENT 分配新 id,避免主键冲突)
+            certType: c.certType,
+            certName: c.certType,
+            certFile: c.certFile || '',
+            issueDate,
+            expireDate,
+            certStatus
+          };
+        });
 
       const personnel = {
         id: form.value.id,
@@ -754,12 +949,13 @@ const closeDialog = () => {
 
 onMounted(async () => {
   await loadDicts();
+  await loadStores();
   await getList();
 });
 </script>
 
 <style scoped>
-  personnel-page {
+.personnel-page {
   padding: 16px;
   height: 100%;
   overflow-y: auto;
@@ -812,8 +1008,16 @@ onMounted(async () => {
 .cert-item {
   display: flex;
   align-items: center;
-  gap: 12px;
+  flex-wrap: wrap;
+  gap: 10px;
   margin-bottom: 12px;
+  padding: 10px 12px;
+  background: #fafbfc;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+}
+.cert-item:hover {
+  border-color: #c0c4cc;
 }
 .cert-upload-trigger {
   display: inline-flex;
@@ -835,4 +1039,23 @@ onMounted(async () => {
 .cert-view-icon {
   color: #e6a23c;
 }
+.cert-view-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 18px;
+}
+.cert-view-meta {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 13px;
+}
+.cert-view-row .meta-label {
+  color: #909399;
+}
+.cert-view-row .meta-value {
+  color: #303133;
+  font-weight: 500;
+}
 </style>

+ 91 - 23
src/views/risk/personnelChange/index.vue

@@ -3,7 +3,7 @@
     <!-- 页面标题 -->
     <div class="page-header">
       <h2 class="page-title">审计工作管理</h2>
-      <p class="page-desc">管理部门人员调动与离职信息,审计工作量统计、审计重点操作</p>
+      <p class="page-desc">管理部门人员变动、调动信息,审计重点操作</p>
     </div>
 
     <!-- 统计卡片 -->
@@ -95,10 +95,10 @@
         </div>
       </div>
 
-      <el-table v-loading="loading" :data="changeList" border stripe>
+      <el-table v-loading="loading" :data="changeList" border stripe class="change-table">
         <el-table-column prop="personnelName" label="姓名" align="center" width="100" />
-        <el-table-column prop="beforeDept" label="部门" align="center" :show-overflow-tooltip="true" width="140" />
-        <el-table-column prop="beforePosition" label="原岗位" align="center" :show-overflow-tooltip="true" width="160" />
+        <el-table-column prop="beforeDept" label="部门" align="center" :show-overflow-tooltip="true" min-width="160" />
+        <el-table-column prop="beforePosition" label="原岗位" align="center" :show-overflow-tooltip="true" min-width="180" />
         <el-table-column label="变动类型" align="center" prop="changeType" width="100">
           <template #default="{ row }">
             <el-tag :type="getChangeTypeTag(row.changeType)" effect="light">
@@ -106,7 +106,7 @@
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="新岗位" align="center" prop="afterPosition" width="160">
+        <el-table-column label="新岗位" align="center" prop="afterPosition" min-width="180">
           <template #default="{ row }">
             {{ row.changeType === '离职' ? '-' : (row.afterPosition || '-') }}
           </template>
@@ -127,12 +127,17 @@
         </el-table-column>
       </el-table>
 
-      <pagination
+      <el-pagination
         v-show="total > 0"
-        v-model:page="queryParams.pageNum"
-        v-model:limit="queryParams.pageSize"
+        v-model:current-page="queryParams.pageNum"
+        v-model:page-size="queryParams.pageSize"
         :total="total"
-        @pagination="getList"
+        :page-sizes="[10, 20, 50]"
+        layout="prev, pager, next"
+        background
+        class="change-pagination"
+        @current-change="getList"
+        @size-change="getList"
       />
     </el-card>
 
@@ -146,8 +151,8 @@
       </template>
       <div v-loading="filesLoading" class="files-grid">
         <div v-for="f in auditFiles" :key="f.id" class="file-card">
-          <div class="file-icon-box">
-            <el-icon class="file-icon" :size="32">
+          <div class="file-icon-box" :style="{ background: getFileIconColor(f.fileName || f.fileUrl) }">
+            <el-icon class="file-icon" :size="20" color="#fff">
               <component :is="getFileIcon(f.fileName || f.fileUrl)" />
             </el-icon>
           </div>
@@ -537,8 +542,14 @@ const fileRules = {
   fileType: [{ required: true, message: '请选择文件类型', trigger: 'change' }]
 };
 
-const mapDictData = (list: DictDataVO[]) => {
-  return (list || [])
+const mapDictData = (resOrList: any) => {
+  // 字典接口被 axios 拦截器自动解包,返回值为数组;兼容部分场景被再包一层 .data 的情况
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
+  return list
     .filter((d) => d.status === '0' || d.status === undefined)
     .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
     .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
@@ -547,7 +558,7 @@ const mapDictData = (list: DictDataVO[]) => {
 const loadDicts = async () => {
   try {
     const res = await getDicts('audit_change_type');
-    changeTypeOptions.value = mapDictData(res.data || []);
+    changeTypeOptions.value = mapDictData(res);
   } catch (e) {
     console.error('加载变动类型字典失败', e);
   }
@@ -604,6 +615,18 @@ const getFileIcon = (name: string) => {
   return Document;
 };
 
+/** 按文件类型返回圆形 icon 背景色(与设计稿一致) */
+const getFileIconColor = (name: string): string => {
+  if (!name) return '#909399';
+  const lower = name.toLowerCase();
+  if (lower.endsWith('.pdf')) return '#409EFF';           // 蓝
+  if (lower.match(/\.(xls|xlsx|csv)$/)) return '#67C23A'; // 绿
+  if (lower.match(/\.(doc|docx)$/)) return '#722ED1';     // 紫
+  if (lower.match(/\.(jpg|jpeg|png|gif|bmp|webp)$/)) return '#E6A23C'; // 橙
+  if (lower.match(/\.(zip|rar|7z|tar|gz)$/)) return '#F56C6C';          // 红
+  return '#909399';                                        // 灰
+};
+
 const getFileNameFromUrl = (url: string): string => {
   if (!url) return '';
   return url.split('/').pop() || '文件';
@@ -1017,6 +1040,47 @@ onMounted(async () => {
 .list-card {
   margin-bottom: 16px;
 }
+
+/* 表格按 min-width 自适应,避免右侧大片空白 */
+:deep(.change-table.el-table) {
+  table-layout: fixed;
+  width: 100%;
+}
+:deep(.change-table .el-table__inner-wrapper) {
+  width: 100%;
+}
+
+/* 分页器:简洁风"上一页 [1] 2 3 下一页" */
+.change-pagination {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+.change-pagination :deep(.btn-prev),
+.change-pagination :deep(.btn-next),
+.change-pagination :deep(.number) {
+  background: #fff;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  color: #409eff;
+  min-width: 32px;
+  height: 32px;
+  line-height: 30px;
+  font-weight: 500;
+  margin: 0 4px 0 0;
+}
+.change-pagination :deep(.number):hover {
+  color: #409eff;
+}
+.change-pagination :deep(.number.is-active) {
+  background: #409eff;
+  border-color: #409eff;
+  color: #fff;
+}
+.change-pagination :deep(.btn-prev),
+.change-pagination :deep(.btn-next) {
+  padding: 0 12px;
+}
 .filter-form {
   margin-bottom: 4px;
 }
@@ -1048,17 +1112,18 @@ onMounted(async () => {
 }
 .files-grid {
   display: grid;
-  grid-template-columns: repeat(2, 1fr);
+  grid-template-columns: repeat(3, 1fr);
   gap: 12px;
   min-height: 80px;
 }
 .file-card {
   display: flex;
+  align-items: center;
   gap: 12px;
-  padding: 12px;
+  padding: 12px 14px;
   border: 1px solid #ebeef5;
   border-radius: 6px;
-  background: #fafbfc;
+  background: #fff;
   transition: all 0.2s;
 }
 .file-card:hover {
@@ -1066,19 +1131,18 @@ onMounted(async () => {
   border-color: #409eff;
 }
 .file-icon-box {
-  width: 44px;
-  height: 44px;
-  border-radius: 6px;
-  background: #e6a23c;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
   display: flex;
   align-items: center;
   justify-content: center;
   flex-shrink: 0;
-  color: #fff;
 }
 .file-meta {
   flex: 1;
   min-width: 0;
+  overflow: hidden;
 }
 .file-name {
   font-size: 13px;
@@ -1087,13 +1151,17 @@ onMounted(async () => {
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  margin-bottom: 2px;
 }
 .file-sub {
   font-size: 12px;
   color: #909399;
-  margin: 2px 0;
   display: flex;
   gap: 4px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 4px;
 }
 .file-actions {
   font-size: 12px;

+ 180 - 23
src/views/risk/qualityInspectionTask/index.vue

@@ -69,8 +69,17 @@
                 <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 label="门店" prop="storeId">
+              <el-select
+                v-model="queryParams.storeId"
+                placeholder="全部"
+                clearable
+                filterable
+                style="width: 200px"
+                @change="handleQuery"
+              >
+                <el-option v-for="d in storeOptions" :key="d.value" :label="d.label" :value="d.value" />
+              </el-select>
             </el-form-item>
             <el-form-item label="打卡状态" prop="recordStatus">
               <el-select v-model="queryParams.recordStatus" placeholder="全部" clearable style="width: 180px">
@@ -120,7 +129,7 @@
         </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>
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)">{{ getResultLabel(scope.row.checkResult) }}</el-tag>
             <span v-else>-</span>
           </template>
         </el-table-column>
@@ -155,8 +164,17 @@
         </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 label="门店" prop="storeId">
+              <el-select
+                v-model="form.storeId"
+                placeholder="请选择总部/门店"
+                style="width: 100%"
+                filterable
+                clearable
+                @change="onStoreChange"
+              >
+                <el-option v-for="d in storeOptions" :key="d.value" :label="d.label" :value="d.value" />
+              </el-select>
             </el-form-item>
           </el-col>
           <el-col :span="12">
@@ -178,6 +196,50 @@
               <el-switch v-model="form.scanEnabled" active-text="是" inactive-text="否" @change="onScanChange" />
             </el-form-item>
           </el-col>
+          <el-col v-if="form.scanEnabled" :span="12">
+            <el-form-item label="打卡设备编码" prop="punchDeviceCode">
+              <el-input
+                v-model="form.punchDeviceCode"
+                placeholder="请输入巡检点设备编码(与现场二维码一致)"
+                maxlength="200"
+                show-word-limit
+              />
+            </el-form-item>
+          </el-col>
+          <el-col v-if="form.scanEnabled" :span="12">
+            <el-form-item label="扫码设备名称" prop="punchDeviceName">
+              <el-input
+                v-model="form.punchDeviceName"
+                placeholder="请输入扫码设备名称(如 03号19层强电井)"
+                maxlength="100"
+                show-word-limit
+              />
+            </el-form-item>
+          </el-col>
+          <el-col v-if="form.scanEnabled" :span="12">
+            <el-form-item label="扫码开启时间" prop="scanStartTime">
+              <el-date-picker
+                v-model="form.scanStartTime"
+                type="datetime"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                placeholder="请选择二维码开始生效时间"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col v-if="form.scanEnabled" :span="12">
+            <el-form-item label="扫码间隔(s)" prop="scanIntervalSec">
+              <el-input-number
+                v-model="form.scanIntervalSec"
+                :min="60"
+                :max="86400"
+                :step="60"
+                controls-position="right"
+                style="width: 100%"
+                placeholder="默认 1800=30 分钟"
+              />
+            </el-form-item>
+          </el-col>
         </el-row>
         <el-row :gutter="20">
           <el-col :span="12">
@@ -254,7 +316,7 @@
           </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-select v-model="scope.row.itemCategory" 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>
@@ -320,7 +382,7 @@
         </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>
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)">{{ getResultLabel(scope.row.checkResult) }}</el-tag>
             <span v-else>-</span>
           </template>
         </el-table-column>
@@ -358,7 +420,7 @@
           <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>
+          <el-tag v-if="detailForm.checkResult" :type="getResultTag(detailForm.checkResult)">{{ getResultLabel(detailForm.checkResult) }}</el-tag>
           <span v-else>-</span>
         </el-descriptions-item>
         <el-descriptions-item label="打卡地点">{{ detailForm.checkInLocation }}</el-descriptions-item>
@@ -380,13 +442,24 @@
         <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>
+            <el-tag v-if="scope.row.checkResult" :type="getResultTag(scope.row.checkResult)" size="small">{{ getResultLabel(scope.row.checkResult) }}</el-tag>
             <span v-else>-</span>
           </template>
         </el-table-column>
-        <el-table-column label="照片" width="80">
+        <el-table-column label="照片" min-width="160">
           <template #default="scope">
-            <span v-if="scope.row.checkPhotos">{{ scope.row.checkPhotos }}</span>
+            <template v-if="scope.row.checkPhotos">
+              <el-image
+                v-for="(url, i) in String(scope.row.checkPhotos).split(',').filter((u) => u)"
+                :key="i"
+                :src="url.trim()"
+                :preview-src-list="String(scope.row.checkPhotos).split(',').map((u) => u.trim()).filter((u) => u)"
+                :initial-index="i"
+                :preview-teleported="true"
+                fit="cover"
+                style="width: 60px; height: 60px; margin-right: 6px; border-radius: 4px;"
+              />
+            </template>
             <span v-else>-</span>
           </template>
         </el-table-column>
@@ -468,6 +541,7 @@ import type {
 } from '@/api/risk/types';
 import { listByCurrentDeptChainUsers } from '@/api/system/user';
 import type { UserVO } from '@/api/system/user/types';
+import { listDept } from '@/api/system/dept';
 import { FormInstance } from 'element-plus';
 import type { ComponentInternalInstance } from 'vue';
 import { Calendar, Check, Warning, Clock } from '@element-plus/icons-vue';
@@ -476,7 +550,7 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
 interface InspectionItemForm {
   id?: number;
-  itemType: string;
+  itemCategory: string;
   itemContent: string;
   itemStandard: string;
   needPhoto: boolean;
@@ -492,6 +566,8 @@ const recordList = ref<RiskInspectionRecordVo[]>([]);
 const recordLoading = ref(false);
 // 责任人/审核人选择器数据(当前登录用户所在部门及所有下级部门用户)
 const deptChainUserOptions = ref<UserVO[]>([]);
+// 门店下拉(集团总部 + 65 个门店)
+const storeOptions = ref<Array<{ label: string; value: number }>>([]);
 // 字典(品控专用字典 quality_inspection_type / quality_project_category,
 //  通用字典 inspection_status / inspection_result / inspection_cycle 与风控共用)
 const {
@@ -540,11 +616,16 @@ const data = reactive<any>({
     id: undefined,
     taskName: '',
     taskType: '',
+    storeId: undefined as number | undefined,
     storeName: '',
     location: '',
     cycle: '每日',
     scanEnabled: false,
     checkMethod: '常规',
+    punchDeviceCode: '',
+    punchDeviceName: '',
+    scanStartTime: undefined,
+    scanIntervalSec: 1800,
     lng: undefined,
     lat: undefined,
     inspectorName: '',
@@ -559,7 +640,7 @@ const data = reactive<any>({
     pageSize: 10,
     taskName: '',
     taskType: '',
-    storeName: '',
+    storeId: undefined as number | undefined,
     recordStatus: '',
     checkResult: ''
   },
@@ -567,7 +648,7 @@ const data = reactive<any>({
     taskName: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
     taskType: [{ required: true, message: '请选择巡检类型', trigger: 'change' }],
     cycle: [{ required: true, message: '请选择巡检周期', trigger: 'change' }],
-    storeName: [{ required: true, message: '请输入门店', trigger: 'blur' }],
+    storeId: [{ required: true, message: '请选择门店/总部', trigger: 'change' }],
     location: [{ required: true, message: '请输入位置区域', trigger: 'blur' }],
     inspectorId: [{ required: true, message: '请选择责任人', trigger: 'change' }],
     auditorId: [{ required: true, message: '请选择审核人', trigger: 'change' }]
@@ -586,8 +667,9 @@ const getList = async () => {
   loading.value = true;
   try {
     const res = await listQualityInspectionTask(queryParams.value);
-    taskList.value = res.rows || [];
-    total.value = res.total || 0;
+    // RuoYi-Vue-Plus 返回 { code, msg, data: { rows, total } }
+    taskList.value = (res as any).data?.rows ?? [];
+    total.value = (res as any).data?.total ?? 0;
   } finally {
     loading.value = false;
   }
@@ -612,6 +694,7 @@ const handleQuery = () => {
 const resetQuery = () => {
   queryFormRef.value?.resetFields();
   queryParams.value.pageNum = 1;
+  queryParams.value.storeId = undefined as number | undefined;
   handleQuery();
 };
 
@@ -620,11 +703,16 @@ const resetForm = () => {
     id: undefined,
     taskName: '',
     taskType: '',
+    storeId: undefined as number | undefined,
     storeName: '',
     location: '',
     cycle: '每日',
     scanEnabled: false,
     checkMethod: '常规',
+    punchDeviceCode: '',
+    punchDeviceName: '',
+    scanStartTime: undefined,
+    scanIntervalSec: 1800,
     lng: undefined,
     lat: undefined,
     inspectorId: undefined,
@@ -678,6 +766,18 @@ const onAuditorChange = (val: string | number | undefined) => {
   form.value.auditorName = user?.nickName || user?.userName || '';
 };
 
+/**
+ * 选择门店后,同步回填 storeName 字段
+ */
+const onStoreChange = (val: string | number | undefined) => {
+  if (val === undefined || val === null || val === '') {
+    form.value.storeName = '';
+    return;
+  }
+  const opt = storeOptions.value.find((s) => s.value === val);
+  form.value.storeName = opt?.label || '';
+};
+
 const onCycleChange = (val: string) => {
   timeSlotList.value = [];
   weekDayList.value = [];
@@ -709,7 +809,7 @@ const removeTimeSlot = (idx: number) => {
 
 const addInspectionItem = () => {
   form.value.itemList.push({
-    itemType: '',
+    itemCategory: '',
     itemContent: '',
     itemStandard: '',
     needPhoto: false
@@ -732,8 +832,23 @@ const handleUpdate = async (row: RiskInspectionTaskVo) => {
   if (deptChainUserOptions.value.length === 0) {
     await loadDeptChainUsers();
   }
+  if (storeOptions.value.length === 0) {
+    await loadStores();
+  }
   const res = await getQualityInspectionTask(row.id);
   Object.assign(form.value, res.data);
+  // 后端返回的 storeId 可能是 string(JSON 序列化大数防精度丢失),统一转 string 匹配 storeOptions
+  if (form.value.storeId !== undefined && form.value.storeId !== null) {
+    form.value.storeId = String(form.value.storeId);
+    const opt = storeOptions.value.find((s) => s.value === form.value.storeId);
+    if (opt && !form.value.storeName) {
+      form.value.storeName = opt.label;
+    }
+  }
+  // scanEnabled 后端返 '1'/'0' 字符串,el-switch 严格 boolean,统一转
+  if (form.value.scanEnabled !== undefined && form.value.scanEnabled !== null) {
+    form.value.scanEnabled = form.value.scanEnabled === '1' || form.value.scanEnabled === true;
+  }
   // 恢复时间配置
   if (res.data?.timeSlot) {
     timeSlotList.value = res.data.timeSlot.split(',').filter((s: string) => s);
@@ -749,10 +864,10 @@ const handleUpdate = async (row: RiskInspectionTaskVo) => {
     const itemRes = await listInspectionItemByTask(row.id);
     form.value.itemList = (itemRes.data || []).map((it: RiskInspectionItemVo) => ({
       id: it.id,
-      itemType: it.itemType,
+      itemCategory: it.itemCategory || it.itemType || '',
       itemContent: it.itemContent,
       itemStandard: it.itemStandard,
-      needPhoto: false
+      needPhoto: it.needPhoto === '1' || it.needPhoto === true
     }));
   } catch (e) {
     form.value.itemList = [];
@@ -886,13 +1001,22 @@ const submitForm = () => {
       form.value.timeSlot = '';
       form.value.dayOfWeek = '';
     }
-    form.value.scanEnabled = form.value.scanEnabled ? '1' : '0';
+    // 构造提交 payload(不破坏 form.value 本身,避免下次打开状态不一致)
+    const payload: any = {
+      ...form.value,
+      scanEnabled: form.value.scanEnabled ? '1' : '0',
+      itemList: (form.value.itemList || []).map((it: any) => ({
+        ...it,
+        itemCategory: it.itemCategory || it.itemType || '',
+        needPhoto: it.needPhoto ? '1' : '0'
+      }))
+    };
 
     if (form.value.id) {
-      await updateQualityInspectionTask(form.value as RiskInspectionTaskBo);
+      await updateQualityInspectionTask(payload as RiskInspectionTaskBo);
       proxy?.$modal.msgSuccess('修改成功');
     } else {
-      await addQualityInspectionTask(form.value as RiskInspectionTaskBo);
+      await addQualityInspectionTask(payload as RiskInspectionTaskBo);
       proxy?.$modal.msgSuccess('新增成功');
     }
     dialog.visible = false;
@@ -930,15 +1054,48 @@ const getStatusTag = (s: string): 'primary' | 'success' | 'warning' | 'info' | '
 const getResultTag = (r: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
   const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
     正常: 'success',
-    异常: 'danger'
+    异常: 'danger',
+    '0': 'success', // 0=正常
+    '1': 'danger'  // 1=异常
   };
   return (map[r] || 'info') as any;
 };
 
+/**
+ * 巡检结果码 → 中文标签
+ * - 后端存储:0=正常 / 1=异常(也兼容直接存"正常"/"异常"的老数据)
+ */
+const getResultLabel = (r: string): string => {
+  const map: Record<string, string> = {
+    '0': '正常',
+    '1': '异常',
+    正常: '正常',
+    异常: '异常'
+  };
+  return map[r] || r || '-';
+};
+
 onMounted(async () => {
   await loadAll();
   await loadDeptChainUsers();
+  await loadStores();
 });
+
+/** 加载门店下拉(集团总部 + dept_category='门店' 的所有门店) */
+const loadStores = async () => {
+  try {
+    const res: any = await listDept({});
+    const list: any[] = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : [];
+    storeOptions.value = list
+      .filter((d: any) => d.deptCategory === '门店' || d.deptId === 100)
+      .filter((d: any) => d.status === '0' || d.status === undefined || d.status === null)
+      .map((d: any) => ({ label: d.deptName, value: String(d.deptId) }))
+      .sort((a, b) => Number(a.value) - Number(b.value));
+  } catch (e) {
+    console.error('加载门店列表失败', e);
+    storeOptions.value = [];
+  }
+};
 </script>
 
 <style scoped lang="scss">

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

@@ -574,8 +574,14 @@ const recordList = ref<RiskEquipmentMaintenanceVo[]>([]);
 const recordLoading = ref(false);
 const recordEquipmentName = ref('');
 
-const mapDictData = (list: DictDataVO[]) => {
-  return (list || [])
+const mapDictData = (resOrList: any) => {
+  // 字典接口被 axios 拦截器自动解包,返回值为数组;兼容部分场景被再包一层 .data 的情况
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
+  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 }));
@@ -587,8 +593,8 @@ const loadDicts = async () => {
       getDicts('equipment_type'),
       getDicts('maintenance_status')
     ]);
-    equipmentTypeOptions.value = mapDictData(eqType.data || []);
-    maintenanceStatusOptions.value = mapDictData(mStatus.data || []);
+    equipmentTypeOptions.value = mapDictData(eqType);
+    maintenanceStatusOptions.value = mapDictData(mStatus);
   } catch (e) {
     console.error('加载字典失败', e);
   }

ファイルの差分が大きいため隠しています
+ 644 - 274
src/views/risk/supervision/index.vue


+ 128 - 200
src/views/risk/warning/index.vue

@@ -38,7 +38,7 @@
             <el-option
               v-for="d in warningLevelOptions"
               :key="d.value"
-              :label="getWarningLevelDisplay(d.value).label"
+              :label="d.label"
               :value="d.value"
             />
           </el-select>
@@ -74,11 +74,12 @@
     <!-- 列表 -->
     <el-card shadow="hover" class="list-card">
       <div class="list-toolbar">
-        <span class="list-total">共 {{ total }} 条警示信息</span>
+        <span class="list-total">共 {{ total }} 条风险警示信息</span>
         <el-button type="primary" :icon="Plus" @click="handleAdd">发布警示</el-button>
       </div>
 
       <el-table v-loading="loading" :data="warningList" border stripe>
+        <el-table-column type="index" label="序号" align="center" width="60" />
         <el-table-column prop="warningTitle" label="警示标题" align="center" :show-overflow-tooltip="true" min-width="220" />
         <el-table-column label="警示类型" align="center" prop="warningType" width="130">
           <template #default="{ row }">{{ getDictLabel(warningTypeOptions, row.warningType) }}</template>
@@ -86,7 +87,7 @@
         <el-table-column label="警示等级" align="center" width="120">
           <template #default="{ row }">
             <el-tag :type="getWarningLevelTag(row.warningLevel)" effect="light" round>
-              {{ getWarningLevelDisplay(row.warningLevel).label }}
+              {{ getDictLabel(warningLevelOptions, row.warningLevel) }}
             </el-tag>
           </template>
         </el-table-column>
@@ -146,7 +147,7 @@
             <el-option
               v-for="d in warningLevelOptions"
               :key="d.value"
-              :label="getWarningLevelDisplay(d.value).label"
+              :label="d.label"
               :value="d.value"
             />
           </el-select>
@@ -174,32 +175,20 @@
           />
         </el-form-item>
 
+        <!-- 附件上传:基于 MinIO -->
         <el-form-item label="附件上传">
           <el-upload
             v-model:file-list="attachmentFileList"
-            :http-request="customUpload"
+            :http-request="customAttachmentUpload"
             :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"
+            list-type="text"
           >
-            <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>
+            <el-button type="primary" plain>选择文件</el-button>
+            <template #tip>
+              <div class="el-upload__tip">支持 PDF/Word/Excel/图片格式,单个文件不超过 10MB</div>
             </template>
           </el-upload>
         </el-form-item>
@@ -219,7 +208,7 @@
         <el-descriptions-item label="警示类型">{{ getDictLabel(warningTypeOptions, viewForm.warningType) }}</el-descriptions-item>
         <el-descriptions-item label="警示等级">
           <el-tag :type="getWarningLevelTag(viewForm.warningLevel)">
-            {{ getWarningLevelDisplay(viewForm.warningLevel).label }}
+            {{ getDictLabel(warningLevelOptions, viewForm.warningLevel) }}
           </el-tag>
         </el-descriptions-item>
         <el-descriptions-item label="发布人">{{ viewForm.releaserName }}</el-descriptions-item>
@@ -227,19 +216,17 @@
         <el-descriptions-item label="警示内容">
           <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"
+        <el-descriptions-item label="附件" v-if="viewForm.attachmentUrl">
+          <el-link
             :href="viewForm.attachmentUrl"
             target="_blank"
-            rel="noopener noreferrer"
-            @click.prevent="handleDownloadAttachment(viewForm.attachmentUrl)"
+            type="primary"
           >
-            <el-icon><Document /></el-icon>
-            <span>{{ viewForm.attachmentName || '下载附件' }}</span>
-          </a>
+            <el-icon style="margin-right: 4px"><Document /></el-icon>
+            {{ viewForm.attachmentName || '查看附件' }}
+          </el-link>
         </el-descriptions-item>
+        <el-descriptions-item label="备注">{{ viewForm.remark || '—' }}</el-descriptions-item>
       </el-descriptions>
       <template #footer>
         <el-button @click="viewDialog.visible = false">关闭</el-button>
@@ -252,12 +239,11 @@
 import { ref, reactive, toRefs, onMounted, getCurrentInstance } from 'vue';
 import { useUserStore } from '@/store/modules/user';
 import { listWarning, getWarning, addWarning, delWarning } from '@/api/risk/warning';
+import { uploadFile } from '@/api/risk/file';
 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 { uploadFile } from '@/api/risk/file';
-import { ElMessage, FormInstance, UploadUserFile } from 'element-plus';
-import { Document, Plus, UploadFilled } from '@element-plus/icons-vue';
+import { FormInstance, ElMessage, UploadFile, UploadRawFile, UploadUserFile } from 'element-plus';
+import { Plus, Document } from '@element-plus/icons-vue';
 import type { ComponentInternalInstance } from 'vue';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
@@ -269,29 +255,26 @@ 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[]>([]);
+// 标记当前上传请求是否已处理完毕(防止双回调:http-request 内部 onSuccess + el-upload 内部 onError 同时触发)
+const attachmentUploadHandled = ref(false);
 
 const queryFormRef = ref<FormInstance>();
 const formRef = ref<FormInstance>();
 
 /**
- * 等级字典值 → 需求显示文字 + el-tag 颜色
- * 字典值 urgent / important / normal
- * 需求显示:重大风险(red) / 较大风险(yellow) / 一般风险(blue)
+ * 警示等级中文 value → el-tag 颜色
+ * 字典 value 与设计稿对应:重大风险(红) / 较大风险(黄) / 一般风险(蓝)
  */
-const LEVEL_DISPLAY: Record<string, { label: string; tag: 'danger' | 'warning' | 'primary' }> = {
-  urgent: { label: '重大风险', tag: 'danger' },
-  important: { label: '较大风险', tag: 'warning' },
-  normal: { label: '一般风险', tag: 'primary' }
+const LEVEL_TAG: Record<string, 'danger' | 'warning' | 'primary'> = {
+  '重大风险': 'danger',
+  '较大风险': 'warning',
+  '一般风险': 'primary'
 };
 
-const getWarningLevelDisplay = (val: string) => {
-  return LEVEL_DISPLAY[val] || { label: val || '-', tag: 'info' };
-};
-
-const getWarningLevelTag = (val: string): any => {
-  return LEVEL_DISPLAY[val]?.tag || 'info';
-};
+const getWarningLevelTag = (val: string) => LEVEL_TAG[val] || 'info';
 
 const getDictLabel = (opts: Array<{ label: string; value: string }>, val: string) => {
   if (!val) return '';
@@ -299,8 +282,14 @@ const getDictLabel = (opts: Array<{ label: string; value: string }>, val: string
   return found?.label || val;
 };
 
-const mapDictData = (list: DictDataVO[]) => {
-  return (list || [])
+const mapDictData = (resOrList: any) => {
+  // 字典接口被 axios 拦截器自动解包,返回值为数组;兼容部分场景被再包一层 .data 的情况
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
+  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 }));
@@ -312,8 +301,8 @@ const loadDicts = async () => {
       getDicts('risk_warning_type'),
       getDicts('risk_warning_level')
     ]);
-    warningTypeOptions.value = mapDictData(typeRes.data || []);
-    warningLevelOptions.value = mapDictData(levelRes.data || []);
+    warningTypeOptions.value = mapDictData(typeRes);
+    warningLevelOptions.value = mapDictData(levelRes);
   } catch (e) {
     console.error('加载风险警示字典失败', e);
   }
@@ -332,9 +321,7 @@ const data = reactive<any>({
     expireTime: '',
     releaserName: '',
     publishScope: '全员',
-    status: '已发布',
-    attachmentUrl: '',
-    attachmentName: ''
+    status: '已发布'
   } as any,
   queryParams: {
     pageNum: 1,
@@ -436,19 +423,9 @@ const handleDelete = async (row: RiskWarningVo) => {
 const submitForm = () => {
   formRef.value?.validate(async (valid: boolean) => {
     if (valid) {
-      // 自动填充发布人
-      form.value.releaserName = form.value.releaserName || userStore.nickname || 'admin';
+      // 自动填充发布人(从当前登录用户取)
+      form.value.releaserName = form.value.releaserName || userStore.nickname || userStore.name || '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;
@@ -480,6 +457,7 @@ const reset = () => {
     attachmentName: ''
   } as any;
   attachmentFileList.value = [];
+  attachmentUploadHandled.value = false;
   formRef.value?.resetFields();
 };
 
@@ -488,97 +466,101 @@ 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 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())}`;
 };
 
-/**
- * 上传前校验扩展名 + 大小
- */
-const beforeAttachmentUpload = (file: any) => {
-  const ext = (file.name.split('.').pop() || '').toLowerCase();
-  if (!ALLOWED_EXTS.includes(ext)) {
-    ElMessage.warning(`不支持的文件类型 .${ext},仅允许: ${ALLOWED_EXTS.join('/')}`);
+// ============ 附件上传(走 axios baseURL,避免依赖 :action) ============
+const beforeAttachmentUpload = (file: UploadRawFile): boolean => {
+  // 类型/大小校验,失败直接拒绝(让 el-upload 不进入上传流程)
+  const validTypes = [
+    'application/pdf',
+    'application/msword',
+    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    'application/vnd.ms-excel',
+    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    'image/jpeg',
+    'image/png',
+    'image/jpg'
+  ];
+  // 部分浏览器可能识别不了 docx 的 mime,用扩展名兜底
+  const validExts = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.jpg', '.jpeg', '.png'];
+  const fileName = (file.name || '').toLowerCase();
+  const extOk = validExts.some((ext) => fileName.endsWith(ext));
+  if (!validTypes.includes(file.type) && !extOk) {
+    ElMessage.error('只支持 PDF/Word/Excel/图片 格式!');
     return false;
   }
-  if (file.size > MAX_FILE_SIZE) {
-    ElMessage.warning(`文件 ${file.name} 超过 10MB 上限`);
+  if (file.size / 1024 / 1024 >= 10) {
+    ElMessage.error('附件大小不能超过 10MB!');
     return false;
   }
+  // 开始新一轮上传,重置处理标记
+  attachmentUploadHandled.value = false;
   return true;
 };
 
 /**
- * 单个文件上传成功
+ * 自定义上传:走 axios baseURL(避免 el-upload 兜底 fetch :action 失败又触发 on-error)
+ * 关键:只通过 options.onSuccess / options.onError 二选一通知结果,
+ *      且无论成功/失败都设置 attachmentUploadHandled, 防止上层 on-success/on-error 重复 toast
  */
-const handleAttachmentSuccess = (response: any, file: any) => {
-  if (response && response.code === 200) {
-    ElMessage.success(`附件 ${file.name} 上传成功`);
-  } else {
-    ElMessage.error(`附件 ${file.name} 上传失败: ${response?.msg || '未知错误'}`);
+const customAttachmentUpload = async (options: any): Promise<void> => {
+  try {
+    const res: any = await uploadFile(options.file);
+    if (res && res.code === 200 && res.data?.url) {
+      // 1. 把后端返回的 url 挂到 file 对象(供提交时读取)
+      const url = res.data.url;
+      const originalName = res.data.originalName || options.file.name;
+      if (options.file) options.file.url = url;
+      // 2. 同步到 file-list 中,确保刷新弹窗后还能取到
+      const idx = attachmentFileList.value.findIndex((f) => f.uid === options.file.uid);
+      const newFile: any = {
+        ...(attachmentFileList.value[idx] || {}),
+        uid: options.file.uid,
+        name: originalName,
+        status: 'success',
+        url,
+        response: res
+      };
+      if (idx >= 0) {
+        attachmentFileList.value.splice(idx, 1, newFile);
+      } else {
+        attachmentFileList.value.push(newFile);
+      }
+      // 3. 回填到表单字段(后端 Bo 字段 attachmentUrl / attachmentName)
+      form.value.attachmentUrl = url;
+      form.value.attachmentName = originalName;
+      // 4. 标记已处理,通知 el-upload
+      attachmentUploadHandled.value = true;
+      options.onSuccess(res, options.file);
+      ElMessage.success(`附件 ${originalName} 上传成功`);
+    } else {
+      // 业务返回非 200(被 axios 拦截器解包后 res.data.code 才是 200,但这里再判断一次兜底)
+      attachmentUploadHandled.value = true;
+      const msg = res?.msg || '上传失败';
+      options.onError(new Error(msg));
+      ElMessage.error(`附件上传失败: ${msg}`);
+    }
+  } catch (err: any) {
+    // 这里只触发一次,不会双重 toast
+    if (attachmentUploadHandled.value) return;
+    attachmentUploadHandled.value = true;
+    const msg = err?.message || '未知错误';
+    options.onError(err);
+    ElMessage.error(`附件上传失败: ${msg}`);
   }
 };
 
-/**
- * 上传失败
- */
-const handleUploadError = (error: any) => {
-  console.error('附件上传失败', error);
-  ElMessage.error(`附件上传失败: ${error?.message || '网络异常'}`);
-};
-
-/**
- * 超过上传数量限制
- */
-const handleAttachmentExceed = () => {
-  ElMessage.warning('最多只能上传 1 个附件,如需更换请先删除已有附件');
-};
-
-/**
- * 移除附件
- */
-const handleAttachmentRemove = () => {
+const handleAttachmentRemove = (file: UploadFile) => {
+  // 用户主动移除附件 → 清空表单字段
   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);
-  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())}`;
+  attachmentUploadHandled.value = false;
 };
 
 onMounted(async () => {
@@ -588,7 +570,7 @@ onMounted(async () => {
 </script>
 
 <style scoped>
-  warning-page {
+.warning-page {
   padding: 16px;
   height: 100%;
   overflow-y: auto;
@@ -629,58 +611,4 @@ 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>
+</style>

+ 166 - 145
src/views/risk/workReport/index.vue

@@ -12,8 +12,12 @@
         <el-form-item label="作业类型" prop="workType">
           <el-select v-model="queryParams.workType" placeholder="全部类型" style="width: 200px" @change="handleQuery">
             <el-option label="全部类型" value="" />
-            <el-option label="临时施工上报" value="临时施工上报" />
-            <el-option label="改造工程上报" value="改造工程上报" />
+            <el-option
+              v-for="d in workTypeOptions"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
           </el-select>
         </el-form-item>
         <el-form-item label="项目名称" prop="workTitle">
@@ -28,9 +32,12 @@
         <el-form-item label="状态" prop="auditStatus">
           <el-select v-model="queryParams.auditStatus" placeholder="全部状态" style="width: 200px" @change="handleQuery">
             <el-option label="全部状态" value="" />
-            <el-option label="审核通过" value="1" />
-            <el-option label="待审核" value="0" />
-            <el-option label="审核不通过" value="2" />
+            <el-option
+              v-for="d in auditStatusOptions"
+              :key="d.value"
+              :label="d.label"
+              :value="d.value"
+            />
           </el-select>
         </el-form-item>
         <el-form-item>
@@ -51,7 +58,7 @@
         <el-table-column type="index" label="序号" align="center" width="60" />
         <el-table-column label="作业类型" align="center" prop="workType" width="140">
           <template #default="scope">
-            <el-link :underline="false" type="primary">{{ scope.row.workType }}</el-link>
+            <el-link :underline="false" type="primary">{{ getDictLabel(workTypeOptions, scope.row.workType) }}</el-link>
           </template>
         </el-table-column>
         <el-table-column
@@ -88,7 +95,7 @@
         <el-table-column label="状态" align="center" prop="auditStatus" width="110">
           <template #default="scope">
             <el-tag :type="getAuditStatusTag(scope.row.auditStatus)">
-              {{ getAuditStatusLabel(scope.row.auditStatus) }}
+              {{ getDictLabel(auditStatusOptions, scope.row.auditStatus) }}
             </el-tag>
           </template>
         </el-table-column>
@@ -96,7 +103,7 @@
           <template #default="scope">
             <el-button link type="primary" @click="handleView(scope.row)">查看详情</el-button>
             <el-button
-              v-if="scope.row.auditStatus === '0' || scope.row.auditStatus === '待审核'"
+              v-if="scope.row.auditStatus === '待审核'"
               v-hasPermi="['risk:workReport:review']"
               link
               type="primary"
@@ -120,13 +127,14 @@
     <el-dialog
       v-model="dialog.visible"
       :title="dialog.title"
-      width="780px"
+      width="860px"
       append-to-body
       :close-on-click-modal="false"
+      class="work-report-dialog"
       @close="closeDialog"
     >
-      <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
-        <el-row :gutter="20">
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+        <el-row :gutter="24">
           <el-col :span="12">
             <el-form-item label="作业类型" prop="workType">
               <el-select v-model="form.workType" placeholder="请选择作业类型" style="width: 100%">
@@ -142,31 +150,23 @@
           </el-col>
         </el-row>
 
-        <el-row :gutter="20">
+        <el-row :gutter="24">
           <el-col :span="12">
-            <el-form-item label="工期" prop="durationDays">
-              <el-input-number
+            <el-form-item label="工期(天)" prop="durationDays">
+              <el-input
                 v-model="form.durationDays"
-                :min="1"
-                :max="3650"
-                :step="1"
-                placeholder="请输入工期,如:3天"
-                controls-position="right"
-                style="width: 100%"
-                @change="handleDurationChange"
+                placeholder="如:3"
+                clearable
+                @input="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">
+            <el-form-item label="开始时间" prop="workStartTime">
               <el-date-picker
                 v-model="form.workStartTime"
                 type="datetime"
-                placeholder="请选择施工开始时间"
+                placeholder="请选择"
                 value-format="YYYY-MM-DD HH:mm:ss"
                 format="YYYY-MM-DD HH:mm"
                 style="width: 100%"
@@ -174,12 +174,15 @@
               />
             </el-form-item>
           </el-col>
+        </el-row>
+
+        <el-row :gutter="24">
           <el-col :span="12">
-            <el-form-item label="施工结束时间" prop="workEndTime">
+            <el-form-item label="结束时间" prop="workEndTime">
               <el-date-picker
                 v-model="form.workEndTime"
                 type="datetime"
-                placeholder="请选择施工结束时间"
+                placeholder="请选择"
                 value-format="YYYY-MM-DD HH:mm:ss"
                 format="YYYY-MM-DD HH:mm"
                 style="width: 100%"
@@ -187,22 +190,25 @@
               />
             </el-form-item>
           </el-col>
-        </el-row>
-
-        <el-row :gutter="20">
           <el-col :span="12">
             <el-form-item label="现场安全员" prop="reporterName">
-              <el-input v-model="form.reporterName" placeholder="请输入现场安全员姓名" maxlength="50" />
+              <el-input v-model="form.reporterName" placeholder="请输入姓名" maxlength="50" />
             </el-form-item>
           </el-col>
+        </el-row>
+
+        <el-row :gutter="24">
           <el-col :span="12">
             <el-form-item label="联系电话" prop="contactPhone">
               <el-input v-model="form.contactPhone" placeholder="请输入联系电话" maxlength="20" />
             </el-form-item>
           </el-col>
+          <el-col :span="12">
+            <!-- 占位列,保持两列布局视觉平衡 -->
+            <div class="form-placeholder"></div>
+          </el-col>
         </el-row>
 
-        <!-- 现场施工图片 -->
         <el-form-item label="现场施工图片">
           <el-upload
             v-model:file-list="photoFileList"
@@ -214,26 +220,17 @@
             :on-error="handleUploadError"
             :on-exceed="handlePhotoExceed"
             :on-remove="handlePhotoRemove"
+            drag
+            multiple
           >
-            <template #trigger>
-              <div class="upload-trigger-area">
-                <div class="upload-action-row">
-                  <el-button type="primary" plain>选择文件</el-button>
-                  <span class="upload-placeholder">{{ photoFileList.length > 0 ? `已选择 ${photoFileList.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">支持JPG、PNG格式,单张不超过5MB,最多5张</div>
-                </div>
-              </div>
+            <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
+            <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
+            <template #tip>
+              <div class="el-upload__tip">支持 JPG/PNG 格式,单张不超过 5MB,最多 5 张</div>
             </template>
           </el-upload>
         </el-form-item>
 
-        <!-- 施工证件 -->
         <el-form-item label="施工证件">
           <el-upload
             v-model:file-list="certFileList"
@@ -245,21 +242,12 @@
             :on-error="handleUploadError"
             :on-exceed="handleCertExceed"
             :on-remove="handleCertRemove"
+            drag
           >
-            <template #trigger>
-              <div class="upload-trigger-area">
-                <div class="upload-action-row">
-                  <el-button type="primary" plain>选择文件</el-button>
-                  <span class="upload-placeholder">{{ certFileList.length > 0 ? '已选择 1 个文件' : '未选择任何文件' }}</span>
-                </div>
-                <div class="upload-tip-box">
-                  <div class="upload-tip-icon">
-                    <el-icon><Document /></el-icon>
-                  </div>
-                  <div class="upload-tip-text">点击上传施工证件</div>
-                  <div class="upload-tip-desc">支持PDF、JPG、PNG格式,单个不超过10MB</div>
-                </div>
-              </div>
+            <el-icon class="el-icon--upload"><Document /></el-icon>
+            <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
+            <template #tip>
+              <div class="el-upload__tip">支持 PDF/JPG/PNG 格式,单个不超过 10MB</div>
             </template>
           </el-upload>
         </el-form-item>
@@ -268,7 +256,7 @@
           <el-input
             v-model="form.workContent"
             type="textarea"
-            :rows="4"
+            :rows="3"
             placeholder="请输入施工说明(0-500字)"
             maxlength="500"
             show-word-limit
@@ -287,7 +275,7 @@
     <el-dialog v-model="viewDialog.visible" title="作业上报详情" width="780px" append-to-body>
       <el-descriptions :column="2" border>
         <el-descriptions-item label="作业类型">
-          {{ viewForm.workType }}
+          {{ getDictLabel(workTypeOptions, viewForm.workType) }}
         </el-descriptions-item>
         <el-descriptions-item label="项目名称">
           {{ viewForm.workTitle }}
@@ -312,7 +300,7 @@
         </el-descriptions-item>
         <el-descriptions-item label="审核状态">
           <el-tag :type="getAuditStatusTag(viewForm.auditStatus)">
-            {{ getAuditStatusLabel(viewForm.auditStatus) }}
+            {{ getDictLabel(auditStatusOptions, viewForm.auditStatus) }}
           </el-tag>
         </el-descriptions-item>
         <el-descriptions-item label="施工说明" :span="2">
@@ -338,7 +326,7 @@
           </div>
         </el-descriptions-item>
         <el-descriptions-item v-if="viewForm.auditOpinion" label="审核意见" :span="2">
-          <span :class="['audit-opinion', viewForm.auditStatus === '2' ? 'reject' : 'pass']">
+          <span :class="['audit-opinion', viewForm.auditStatus === '审核不通过' ? 'reject' : 'pass']">
             {{ viewForm.auditOpinion }}
           </span>
         </el-descriptions-item>
@@ -366,11 +354,11 @@
         </el-form-item>
         <el-form-item label="审核结果" prop="auditStatus">
           <el-radio-group v-model="auditForm.auditStatus">
-            <el-radio value="1">通过</el-radio>
-            <el-radio value="2">不通过</el-radio>
+            <el-radio value="审核通过">通过</el-radio>
+            <el-radio value="审核不通过">不通过</el-radio>
           </el-radio-group>
         </el-form-item>
-        <el-form-item v-if="auditForm.auditStatus === '2'" label="不通过原因" prop="auditOpinion">
+        <el-form-item v-if="auditForm.auditStatus === '审核不通过'" label="不通过原因" prop="auditOpinion">
           <el-input
             v-model="auditForm.auditOpinion"
             type="textarea"
@@ -403,6 +391,7 @@ import {
 } from '@/api/risk/workReport';
 import type { RiskWorkReportBo, RiskWorkReportVo } from '@/api/risk/types';
 import { uploadFile } from '@/api/risk/file';
+import { getDicts } from '@/api/system/dict/data';
 import { Plus, UploadFilled, Document, Picture as IconPicture } from '@element-plus/icons-vue';
 import { ElMessage, FormInstance, UploadFile, UploadRawFile, UploadUserFile } from 'element-plus';
 import type { ComponentInternalInstance } from 'vue';
@@ -416,6 +405,10 @@ const photoFileList = ref<UploadUserFile[]>([]);
 const certFileList = ref<UploadUserFile[]>([]);
 const viewPhotoList = ref<string[]>([]);
 
+// 字典
+const workTypeOptions = ref<Array<{ label: string; value: string }>>([]);
+const auditStatusOptions = ref<Array<{ label: string; value: string }>>([]);
+
 const queryFormRef = ref<FormInstance>();
 const formRef = ref<FormInstance>();
 const auditFormRef = ref<FormInstance>();
@@ -450,7 +443,7 @@ const data = reactive<any>({
     certFile: '',
     certName: '',
     reportTime: '',
-    auditStatus: '0'
+    auditStatus: '待审核'
   } as any,
   queryParams: {
     pageNum: 1,
@@ -459,6 +452,7 @@ const data = reactive<any>({
     workTitle: '',
     auditStatus: ''
   },
+  auditStatus: '待审核',
   rules: {
     workType: [{ required: true, message: '请选择作业类型', trigger: 'change' }],
     workTitle: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
@@ -515,7 +509,7 @@ const auditData = reactive({
     id: undefined as string | number | undefined,
     workTitle: '',
     reporterName: '',
-    auditStatus: '1',
+    auditStatus: '审核通过',
     auditOpinion: ''
   },
   auditRules: {
@@ -523,7 +517,7 @@ const auditData = reactive({
     auditOpinion: [
       {
         validator: (_rule: any, value: any, callback: any) => {
-          if (auditData.auditForm.auditStatus === '2' && !value) {
+          if (auditData.auditForm.auditStatus === '审核不通过' && !value) {
             callback(new Error('审核不通过时必须填写原因'));
           } else {
             callback();
@@ -604,7 +598,7 @@ const handleAudit = (row: RiskWorkReportVo) => {
     id: row.id,
     workTitle: row.workTitle,
     reporterName: row.reporterName,
-    auditStatus: '1',
+    auditStatus: '审核通过',
     auditOpinion: ''
   };
   auditDialog.visible = true;
@@ -703,7 +697,7 @@ const reset = () => {
     certFile: '',
     certName: '',
     reportTime: '',
-    auditStatus: '0'
+    auditStatus: '待审核'
   } as any;
   photoFileList.value = [];
   certFileList.value = [];
@@ -758,10 +752,11 @@ const computeDurationByRange = (startTime: string, endTime: string): number => {
 };
 
 /** 工期变化 → 若有开始时间,自动算出结束时间 */
-const handleDurationChange = (val: number | undefined) => {
-  if (!val || val <= 0) return;
+const handleDurationChange = (val: string | number | undefined) => {
+  const num = Number(val);
+  if (!num || num <= 0) return;
   if (form.value.workStartTime) {
-    form.value.workEndTime = computeEndTimeByDuration(form.value.workStartTime, val);
+    form.value.workEndTime = computeEndTimeByDuration(form.value.workStartTime, num);
   }
 };
 
@@ -861,11 +856,43 @@ const handleCertRemove = () => {
 
 // ============ 工具方法 ============
 
+const mapDictData = (resOrList: any) => {
+  const list: any[] = Array.isArray(resOrList)
+    ? resOrList
+    : Array.isArray(resOrList?.data)
+      ? resOrList.data
+      : [];
+  return list
+    .filter((d) => 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] = await Promise.all([
+      getDicts('risk_work_type'),
+      getDicts('risk_work_audit_status')
+    ]);
+    workTypeOptions.value = mapDictData(typeRes);
+    auditStatusOptions.value = mapDictData(statusRes);
+  } catch (e) {
+    console.error('加载作业上报字典失败', e);
+  }
+};
+
+/** 字典 value → 中文 label */
+const getDictLabel = (
+  opts: Array<{ label: string; value: string }>,
+  val: string
+) => {
+  if (!val) return '';
+  return opts.find((o) => o.value === val)?.label || val;
+};
+
+/** 审核状态 tag 颜色(按字典 list_class 映射) */
 const getAuditStatusTag = (status: string): any => {
   const map: Record<string, string> = {
-    '0': 'warning',
-    '1': 'success',
-    '2': 'danger',
     待审核: 'warning',
     审核通过: 'success',
     审核不通过: 'danger'
@@ -873,15 +900,6 @@ const getAuditStatusTag = (status: string): any => {
   return map[status] || 'info';
 };
 
-const getAuditStatusLabel = (status: string) => {
-  const map: Record<string, string> = {
-    '0': '待审核',
-    '1': '审核通过',
-    '2': '审核不通过'
-  };
-  return map[status] || status || '';
-};
-
 const getCertIcon = (url: string) => {
   if (!url) return Document;
   const lower = url.toLowerCase();
@@ -898,18 +916,73 @@ const openFile = (url: string) => {
   window.open(url, '_blank');
 };
 
-onMounted(() => {
-  getList();
+onMounted(async () => {
+  await loadDicts();
+  await getList();
 });
 </script>
 
 <style scoped>
-  work-report-page {
+.work-report-page {
   padding: 16px;
   height: 100%;
   overflow-y: auto;
   box-sizing: border-box;
 }
+
+/* 弹窗视觉优化 */
+:deep(.work-report-dialog .el-dialog) {
+  border-radius: 8px;
+  overflow: hidden;
+}
+:deep(.work-report-dialog .el-dialog__title) {
+  font-size: 16px;
+  font-weight: 600;
+  color: #1f2937;
+}
+:deep(.work-report-dialog .el-dialog__header) {
+  padding: 18px 24px;
+  margin-right: 0;
+  border-bottom: 1px solid #f3f4f6;
+}
+:deep(.work-report-dialog .el-dialog__body) {
+  padding: 20px 28px 8px;
+}
+:deep(.work-report-dialog .el-dialog__footer) {
+  padding: 12px 24px 18px;
+  border-top: 1px solid #f3f4f6;
+}
+:deep(.work-report-dialog .el-form-item) {
+  margin-bottom: 18px;
+}
+:deep(.work-report-dialog .el-form-item__label) {
+  font-size: 13px;
+  color: #4b5563;
+  font-weight: 500;
+  white-space: nowrap;
+}
+:deep(.work-report-dialog .el-input__wrapper),
+:deep(.work-report-dialog .el-select__wrapper),
+:deep(.work-report-dialog .el-date-editor.el-input__wrapper),
+:deep(.work-report-dialog .el-textarea__inner) {
+  border-radius: 4px;
+}
+:deep(.work-report-dialog .el-upload) {
+  width: 100%;
+}
+:deep(.work-report-dialog .el-upload-dragger) {
+  padding: 14px 0;
+  border-radius: 4px;
+}
+:deep(.work-report-dialog .el-upload__tip) {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+}
+.form-placeholder {
+  height: 100%;
+  min-height: 32px;
+}
 .page-header {
   margin-bottom: 16px;
 }
@@ -941,58 +1014,6 @@ onMounted(() => {
   color: #606266;
 }
 
-.upload-trigger-area {
-  display: block;
-  cursor: pointer;
-}
-.upload-action-row {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-.upload-placeholder {
-  font-size: 13px;
-  color: #606266;
-}
-.upload-tip-box {
-  margin-top: 8px;
-  padding: 16px;
-  background: #f5f7fa;
-  border-radius: 4px;
-  border: 1px dashed #dcdfe6;
-  text-align: center;
-  transition: all 0.2s;
-}
-.upload-trigger-area:hover .upload-tip-box {
-  background: #ecf5ff;
-  border-color: #409eff;
-}
-.upload-tip-icon {
-  text-align: center;
-  color: #409eff;
-  font-size: 32px;
-  line-height: 1;
-}
-.upload-tip-text {
-  text-align: center;
-  font-size: 13px;
-  color: #409eff;
-  margin-top: 6px;
-}
-.upload-tip-desc {
-  text-align: center;
-  font-size: 12px;
-  color: #909399;
-  margin-top: 4px;
-}
-:deep(.el-upload) {
-  display: block;
-  width: 100%;
-}
-:deep(.el-upload-dragger) {
-  display: none;
-}
-
 .work-content-text {
   white-space: pre-wrap;
   word-break: break-all;

+ 27 - 6
src/views/system/dept/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="p-2">
+  <div class="p-2 dept-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">
@@ -7,8 +7,10 @@
             <el-form-item label="部门名称" prop="deptName">
               <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
             </el-form-item>
-            <el-form-item label="类别编码" prop="deptCategory">
-              <el-input v-model="queryParams.deptCategory" placeholder="请输入类别编码" clearable style="width: 240px" @keyup.enter="handleQuery" />
+            <el-form-item label="类别" prop="deptCategory">
+              <el-select v-model="queryParams.deptCategory" placeholder="请选择类别" clearable style="width: 240px" @change="handleQuery">
+                <el-option v-for="d in deptCategoryOptions" :key="d.value" :label="d.label" :value="d.value" />
+              </el-select>
             </el-form-item>
             <el-form-item label="状态" prop="status">
               <el-select v-model="queryParams.status" placeholder="部门状态" clearable>
@@ -47,7 +49,7 @@
         :default-expand-all="isExpandAll"
       >
         <el-table-column prop="deptName" label="部门名称" width="260"></el-table-column>
-        <el-table-column prop="deptCategory" align="center" label="类别编码" width="200"></el-table-column>
+        <el-table-column prop="deptCategory" align="center" label="类别" width="200"></el-table-column>
         <el-table-column prop="orderNum" align="center" label="排序" width="200"></el-table-column>
         <el-table-column prop="status" align="center" label="状态" width="100">
           <template #default="scope">
@@ -96,8 +98,10 @@
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="类别编码" prop="deptCategory">
-              <el-input v-model="form.deptCategory" placeholder="请输入类别编码" />
+            <el-form-item label="类别" prop="deptCategory">
+              <el-select v-model="form.deptCategory" placeholder="请选择类别" style="width: 100%">
+                <el-option v-for="d in deptCategoryOptions" :key="d.value" :label="d.label" :value="d.value" />
+              </el-select>
             </el-form-item>
           </el-col>
           <el-col :span="12">
@@ -156,6 +160,15 @@ interface DeptOptionsType {
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 const { sys_normal_disable } = toRefs<any>(proxy?.useDict('sys_normal_disable'));
 
+/** 部门类别固定选项:职能、门店、仓库、物流、市场 */
+const deptCategoryOptions = [
+  { value: '职能', label: '职能' },
+  { value: '门店', label: '门店' },
+  { value: '仓库', label: '仓库' },
+  { value: '物流', label: '物流' },
+  { value: '市场', label: '市场' }
+];
+
 const deptList = ref<DeptVO[]>([]);
 const loading = ref(true);
 const showSearch = ref(true);
@@ -318,3 +331,11 @@ onMounted(() => {
   getList();
 });
 </script>
+
+<style scoped>
+.dept-page {
+  height: 100%;
+  overflow-y: auto;
+  box-sizing: border-box;
+}
+</style>

+ 31 - 2
src/views/system/dict/index.vue

@@ -54,6 +54,7 @@
               ref="typeTableRef"
               v-loading="typeLoading"
               border
+              height="100%"
               :data="typeList"
               highlight-current-row
               @row-click="handleTypeRowClick"
@@ -155,7 +156,7 @@
           </div>
 
           <div class="dict-table-wrap">
-            <el-table v-loading="dataLoading" border :data="dataList" @selection-change="handleDataSelectionChange">
+            <el-table v-loading="dataLoading" border height="100%" :data="dataList" @selection-change="handleDataSelectionChange">
               <el-table-column type="selection" width="55" align="center" />
               <el-table-column v-if="false" label="字典编码" align="center" prop="dictCode" />
               <el-table-column label="字典标签" align="center" prop="dictLabel" width="80">
@@ -612,8 +613,34 @@ onMounted(() => {
 </script>
 
 <style lang="scss" scoped>
+.dict-page {
+  /* 让两列卡片撑满视口高度,内部表格独立滚动(不依赖浏览器整体滚动) */
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
 .dict-grid {
   row-gap: 16px;
+  height: 100%;
+}
+
+.dict-grid :deep(.el-col) {
+  height: 100%;
+}
+
+.dict-card {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.dict-card :deep(.el-card__body) {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
 }
 
 .dict-card__header {
@@ -671,6 +698,8 @@ onMounted(() => {
 }
 
 .dict-table-wrap {
-  overflow-x: auto;
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
 }
 </style>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません