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