Bladeren bron

feat: 监督抽检/平台客诉页面升级

1. 监督抽检页面:5 节点名称(风险控制/异议复检/调查取证/调查报告/闭环管理)
2. 客诉列表 5 列(客诉编号/投诉内容/涉事门店/当前处理节点/移交时间)
3. 案件/客诉 节点 1 风险控制:处理人改下拉(从用户列表)
4. 闭环管理保存 → 归档(display 层统一)
5. 页面 CSS 修复:supervision-page 高度 + 内部滚动条
6. isStoreUser 改用 dept_category 字段判断
7. 抽检/客诉弹窗 13/12 字段对齐需求

🤖 Generated with [Claude Code](https://claude.com/claude-code)
yangjingjing 3 dagen geleden
bovenliggende
commit
25c3b0fa96

+ 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

+ 10 - 2
src/layout/index.vue

@@ -118,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)
@@ -263,6 +270,7 @@ onMounted(() => {
 </script>
 
 <style lang="scss" scoped>
+@use '@/assets/styles/variables.module.scss' as *;
 .risk-layout {
   display: flex;
   flex-direction: column;
@@ -364,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;

+ 12 - 6
src/views/risk/dangerSource/index.vue

@@ -283,8 +283,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 }));
@@ -294,12 +300,12 @@ const loadDicts = async () => {
   try {
     const [typeRes, levelRes] = await Promise.all([
       getDicts('risk_source_type'),
-      getDicts('risk_danger_level').catch(() => ({ data: [] }))
+      getDicts('risk_danger_level').catch(() => [])
     ]);
-    sourceTypeOptions.value = mapDictData(typeRes.data || []);
+    sourceTypeOptions.value = mapDictData(typeRes as any);
     // 风险等级字典不存在时使用默认三档
-    riskLevelOptions.value = (levelRes.data || []).length
-      ? mapDictData(levelRes.data)
+    riskLevelOptions.value = (levelRes as any[]).length
+      ? mapDictData(levelRes as any)
       : [
           { label: '高', value: 'high' },
           { label: '中', value: 'medium' },

+ 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>

+ 17 - 2
src/views/risk/inspectionTask/index.vue

@@ -454,13 +454,14 @@ 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';
@@ -770,9 +771,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 = [];
+  }
 };
 
 // ============ 审核相关 ============

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

@@ -341,15 +341,21 @@ 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))

+ 9 - 3
src/views/risk/personnelChange/index.vue

@@ -537,8 +537,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 +553,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);
   }

+ 3 - 2
src/views/risk/qualityInspectionTask/index.vue

@@ -586,8 +586,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;
   }

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

File diff suppressed because it is too large
+ 644 - 274
src/views/risk/supervision/index.vue


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

@@ -299,8 +299,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 +318,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);
   }

+ 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>

Some files were not shown because too many files changed in this diff