Bläddra i källkod

feat(食品安全宣传): 前端 types/API/主页(4看板+列表+3弹窗)

yangjingjing 2 dagar sedan
förälder
incheckning
627e8e70b8

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

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

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

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

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

@@ -954,3 +954,142 @@ export interface RiskFactoryVisitReportBo {
   visitDate?: string;
 }
 
+// ==================== 食品安全宣传(活动) ====================
+export interface RiskFoodSafetyCampaignBo {
+  id?: string | number;
+  /** 活动编号(后端自动生成 CF+yyyyMMdd+0001) */
+  campaignCode?: string;
+  /** 活动名称 */
+  campaignName?: string;
+  /** 活动类型(字典:risk_food_safety_campaign_type) */
+  campaignType?: string;
+  /** 开始时间 yyyy-MM-dd HH:mm:ss */
+  startTime?: string;
+  /** 结束时间 yyyy-MM-dd HH:mm:ss */
+  endTime?: string;
+  /** 预计参与人数 */
+  expectedParticipants?: number;
+  /** 实际参与人数(活动结束录入或统计) */
+  actualParticipants?: number;
+  /** 活动地点 */
+  location?: string;
+  /** 活动描述 */
+  description?: string;
+  /** 负责人ID */
+  principalId?: string | number;
+  /** 负责人姓名 */
+  principalName?: string;
+  /** 状态(未开始/进行中/已结束) */
+  status?: string;
+  /** 实际开始时间 */
+  startRealTime?: string;
+  /** 实际结束时间 */
+  endRealTime?: string;
+  /** 活动总结(活动结束必填) */
+  summary?: string;
+  /** 备注 */
+  remark?: string;
+  /** 关键词(搜索活动名称/编号/负责人) */
+  keyword?: string;
+  params?: any;
+}
+
+export interface RiskFoodSafetyCampaignVo {
+  id: string | number;
+  campaignCode: string;
+  campaignName: string;
+  campaignType: string;
+  startTime: string;
+  endTime: string;
+  expectedParticipants: number;
+  actualParticipants: number;
+  location: string;
+  description: string;
+  principalId: string | number;
+  principalName: string;
+  /** 状态:未开始/进行中/已结束 */
+  status: string;
+  startRealTime: string;
+  endRealTime: string;
+  summary: string;
+  remark: string;
+  createBy: string;
+  createTime: string;
+  updateBy: string;
+  updateTime: string;
+}
+
+/** 食品安全宣传看板统计 VO */
+export interface FoodSafetyCampaignStatsVo {
+  /** 总宣传活动数 */
+  totalCount: number;
+  /** 总宣传活动 较上月变化率(%) */
+  totalRate: number;
+  /** 参与人次(累计) */
+  participantsTotal: number;
+  /** 参与人次 较上月变化率(%) */
+  participantsRate: number;
+  /** 宣传资料数量 */
+  materialTotal: number;
+  /** 宣传资料 较上月变化率(%) */
+  materialRate: number;
+  /** 即将进行(未开始数) */
+  notStartedCount: number;
+  /** 即将进行 较上月变化率(%) */
+  notStartedRate: number;
+  /** 上月总活动数 */
+  lastMonthTotal: number;
+  /** 上月参与人次 */
+  lastMonthParticipants: number;
+  /** 上月资料总数 */
+  lastMonthMaterial: number;
+  /** 上月未开始数 */
+  lastMonthNotStarted: number;
+}
+
+// ==================== 食品安全宣传(资料) ====================
+export interface RiskFoodSafetyMaterialBo {
+  id?: string | number;
+  /** 资料编号(后端自动生成 MT+yyyyMMdd+0001) */
+  materialCode?: string;
+  /** 资料名称 */
+  materialName?: string;
+  /** 资料类型(字典:risk_food_safety_material_type:图文海报类/培训手册/视频音频类) */
+  materialType?: string;
+  /** 资料概述 */
+  materialSummary?: string;
+  /** 资料封面URL(MinIO) */
+  coverUrl?: string;
+  /** 资料封面原始文件名 */
+  coverName?: string;
+  /** 资料附件URL(MinIO) */
+  attachmentUrl?: string;
+  /** 资料附件原始文件名 */
+  attachmentName?: string;
+  /** 关联活动ID(可空) */
+  campaignId?: string | number;
+  /** 上传人ID */
+  uploaderId?: string | number;
+  /** 上传人姓名 */
+  uploaderName?: string;
+  /** 关键词搜索 */
+  keyword?: string;
+  params?: any;
+}
+
+export interface RiskFoodSafetyMaterialVo {
+  id: string | number;
+  materialCode: string;
+  materialName: string;
+  materialType: string;
+  materialSummary: string;
+  coverUrl: string;
+  coverName: string;
+  attachmentUrl: string;
+  attachmentName: string;
+  campaignId: string | number;
+  uploaderId: string | number;
+  uploaderName: string;
+  createTime: string;
+}
+

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

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