餐饮油烟智能监测与监管一体化平台
riku
2026-03-17 b1a0d701cf898c8b7812e66a808a1c91f2bae6cc
src/views/analysis/huanxincode/HuanxinCodeManage.vue
@@ -1 +1,1024 @@
<template>环信码管理</template>
<template>
  <div class="huanxin-code-manage">
    <FYSearchBar @search="onSearch">
      <template #options>
        <!-- 区县 -->
        <FYOptionLocation
          :initValue="false"
          :allOption="false"
          :level="3"
          :checkStrictly="false"
          v-model:value="formSearch.locations"
        ></FYOptionLocation>
        <!-- 场景类型 -->
        <FYOptionScene
          :initValue="false"
          :allOption="false"
          :type="1"
          v-model:value="formSearch.scenetype"
        ></FYOptionScene>
        <!-- 时间 -->
        <FYOptionTime
          :initValue="false"
          type="month"
          v-model:value="formSearch.time"
        ></FYOptionTime>
      </template>
      <template #buttons v-if="$slots.buttons"> </template>
    </FYSearchBar>
    <!-- 顶部宏观看板区 -->
    <el-row :gutter="20" class="dashboard">
      <el-col :span="8">
        <el-card shadow="hover" class="dashboard-card green-card" @click="filterByCode('green')">
          <div class="card-content">
            <div class="card-title">绿码店铺数</div>
            <div class="card-value">{{ statistics.greenCount }}</div>
            <div class="card-percentage">{{ statistics.greenPercentage }}%</div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="8">
        <el-card shadow="hover" class="dashboard-card yellow-card" @click="filterByCode('yellow')">
          <div class="card-content">
            <div class="card-title">黄码店铺数</div>
            <div class="card-value">{{ statistics.yellowCount }}</div>
            <div class="card-percentage">{{ statistics.yellowPercentage }}%</div>
          </div>
        </el-card>
      </el-col>
      <el-col :span="8">
        <el-card shadow="hover" class="dashboard-card red-card" @click="filterByCode('red')">
          <div class="card-content">
            <div class="card-title">红码店铺数</div>
            <div class="card-value">{{ statistics.redCount }}</div>
            <div class="card-percentage">{{ statistics.redPercentage }}%</div>
          </div>
        </el-card>
      </el-col>
    </el-row>
    <!-- 评分模型配置按钮 -->
    <!-- <div class="model-config" v-if="isAdmin">
      <el-button type="primary" @click="openModelConfig">
        <el-icon><Setting /></el-icon>
        <span>评分模型配置</span>
      </el-button>
    </div> -->
    <!-- 店铺列表 -->
    <div class="shop-list">
      <el-table :data="pagedShopList" style="width: 100%">
        <el-table-column prop="shopName" label="店铺名称" />
        <el-table-column prop="district" label="所在区县" width="120" />
        <el-table-column prop="town" label="所在街镇" width="150" />
        <el-table-column label="环信码" width="100">
          <template #default="scope">
            <el-tag :type="getCodeType(scope.row.code)">{{ getCodeText(scope.row.code) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="score" label="当前评分" width="120" sortable />
        <el-table-column label="评分变化趋势" width="150">
          <template #default="scope">
            <div class="trend">
              <el-icon :class="['trend-icon', scope.row.trend > 0 ? 'up' : 'down']">
                <ArrowUp v-if="scope.row.trend > 0" />
                <ArrowDown v-else />
              </el-icon>
              <span :class="scope.row.trend > 0 ? 'up' : 'down'">
                {{ scope.row.trend > 0 ? '+' : '' }}{{ scope.row.trend }}分
              </span>
            </div>
          </template>
        </el-table-column>
        <el-table-column prop="lastUpdate" label="上次更新时间" width="180" />
        <el-table-column label="操作" width="100" fixed="right">
          <template #default="scope">
            <el-button size="small" @click="viewDetails(scope.row)">查看详情</el-button>
            <!-- <el-button size="small" type="warning" @click="viewRiskWarnings(scope.row)"
              >风险预警记录</el-button
            > -->
          </template>
        </el-table-column>
      </el-table>
      <!-- 分页组件 -->
      <div class="pagination">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="[10, 20, 50, 100]"
          layout="total, sizes, prev, pager, next, jumper"
          :total="filteredShopList.length"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>
    <!-- 详情抽屉 -->
    <el-drawer
      v-model="drawerVisible"
      :title="selectedShop?.shopName || '店铺详情'"
      direction="rtl"
      size="60%"
    >
      <div v-if="selectedShop" class="shop-details">
        <el-row justify="space-between" style="flex-wrap: nowrap">
          <!-- 环信码大图标及当前评分 -->
          <div class="code-header">
            <div class="score-info">
              <div class="score-label">当前评分</div>
              <div class="score-value">{{ selectedShop.score }}</div>
            </div>
            <div class="code-icon">
              <el-image
                class="image"
                :src="codeImageUrl"
                :preview-src-list="[codeImageUrl]"
                :initial-index="0"
                fit="cover"
                lazy
              />
            </div>
          </div>
          <!-- 评分维度雷达图 -->
          <div class="chart-section">
            <h3>评分维度分析</h3>
            <div class="radar-chart">
              <canvas ref="radarChart" width="500" height="400"></canvas>
            </div>
          </div>
        </el-row>
        <!-- 评分历史趋势图 -->
        <div class="chart-section">
          <h3>评分历史趋势</h3>
          <div class="trend-chart">
            <canvas ref="trendChart" width="800" height="350"></canvas>
          </div>
        </div>
        <!-- 风险预警记录 -->
        <div class="warning-section">
          <h3>风险预警记录</h3>
          <el-table :data="selectedShop.warnings" style="width: 100%">
            <el-table-column prop="time" label="预警时间" width="180" />
            <el-table-column prop="content" label="预警内容" />
            <el-table-column prop="score" label="当时评分" width="120" />
            <el-table-column label="处理状态" width="120">
              <template #default="scope">
                <el-tag :type="scope.row.handled ? 'success' : 'danger'">
                  {{ scope.row.handled ? '已处理' : '未处理' }}
                </el-tag>
              </template>
            </el-table-column>
          </el-table>
        </div>
      </div>
    </el-drawer>
    <!-- 评分模型配置弹窗 -->
    <el-dialog v-model="modelConfigVisible" title="评分模型配置" width="80%">
      <div class="model-config-content">
        <!-- 这里应该集成真实的配置表单 -->
        <el-empty description="配置表单加载中..." />
      </div>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="modelConfigVisible = false">取消</el-button>
          <el-button type="primary" @click="saveModelConfig">保存配置</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import dayjs from 'dayjs'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { Setting, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import userApi from '@/api/fytz/userApi'
import creditApi from '@/api/fytz/creditApi'
// 搜索表单
const formSearch = ref({
  locations: {
    aCode: null,
    aName: null,
    cCode: '3100',
    cName: '上海市',
    dCode: '310104',
    dName: '徐汇区',
    mCode: null,
    mName: null,
    pCode: '31',
    pName: '上海市',
    tCode: null,
    tName: null,
  },
  scenetype: {
    label: '餐饮',
    value: '1',
  },
  time: dayjs('2023-08-01').date(1).toDate(),
})
// 状态
const drawerVisible = ref(false)
const modelConfigVisible = ref(false)
const selectedShop = ref(null)
const isAdmin = ref(true) // 模拟管理员权限
const filterCode = ref('all')
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
// 环信码图片URL
const codeImageUrl = ref('')
// 统计数据
const statistics = reactive({
  greenCount: 125,
  greenPercentage: 65.8,
  yellowCount: 45,
  yellowPercentage: 23.7,
  redCount: 20,
  redPercentage: 10.5,
})
// 店铺名称列表
const shopNames = [
  '付小姐在成都',
  '吉刻联盟',
  '家在塔啦',
  '狼来了',
  '乐凯撒星游店',
  '馨远美食小镇(哈尼美食广场)',
  '棒约翰',
  '弄堂咪道',
  '杨记齐齐哈尔烤肉',
  '上海稔传餐饮管理有限公司(人生一串)',
  '缘家',
  '泉盛餐饮(上海)有限公司(食其家)',
  '丰茂烤串',
  '上海泰煌餐饮管理有限公司(泰煌鸡)',
  '徐汇区辰熙餐馆(小铁君串烧居酒屋)',
]
// 徐汇区街镇列表
const xuhuiTowns = [
  '天平路街道',
  '湖南路街道',
  '斜土路街道',
  '枫林路街道',
  '长桥街道',
  '田林街道',
  '虹梅路街道',
  '康健新村街道',
  '徐家汇街道',
  '凌云路街道',
  '龙华街道',
  '漕河泾街道',
  '华泾镇',
]
function onSearch() {
  const f = formSearch.value
  const area = {}
  // 行政区划
  area.provinceCode = f.locations.pCode
  area.provinceName = f.locations.pName
  if (area.provinceCode == null) {
    area.provinceCode = null
    area.provinceName = null
  }
  area.cityCode = f.locations.cCode
  area.cityName = f.locations.cName
  area.districtCode = f.locations.dCode
  area.districtName = f.locations.dName
  area.townCode = f.locations.tCode
  area.townName = f.locations.tName
  // 场景类型
  area.sceneTypes = []
  f.scenetype.value == null ? (area.sceneTypes = []) : (area.sceneTypes = [f.scenetype.value])
  // 上下线状态
  area.online = true
  // 关键字
  area.searchText = ''
  userApi.fetchUser(currentPage.value, pageSize.value, area).then((res) => {
    if (res) {
      res.data
      res.head.totalCount
      shopList.value = res.data.map((item, index) => {
        const { score, code } = generateRandomScore()
        return {
          id: index + 1,
          guid: item.biGuid,
          shopName: item.biName,
          district: item.biDistrictName,
          town: item.biTownName,
          code: code,
          score: score,
          trend: generateRandomTrend(),
          lastUpdate: generateRandomDate(),
          warnings: [
            {
              time: generateRandomDate(),
              content: '净化器运行时长不足',
              score: 90,
              handled: true,
            },
          ],
        }
      })
    }
  })
}
// 生成2023年8月内的随机时间
function generateRandomDate() {
  const year = 2023
  const month = 7 // 0-11,8月是7
  const day = Math.floor(Math.random() * 31) + 1 // 1-31
  const hour = Math.floor(Math.random() * 24) // 0-23
  const minute = Math.floor(Math.random() * 60) // 0-59
  const date = new Date(year, month, day, hour, minute)
  return date.toISOString().slice(0, 16).replace('T', ' ')
}
// 随机选择数组元素
function getRandomElement(array) {
  return array[Math.floor(Math.random() * array.length)]
}
// 生成随机评分和对应环信码等级
function generateRandomScore() {
  const score = Math.floor(Math.random() * 101) // 0-100
  let code
  if (score >= 90) {
    code = 'green'
  } else if (score >= 60) {
    code = 'yellow'
  } else {
    code = 'red'
  }
  return {
    score,
    code,
  }
}
// 生成随机评分趋势
function generateRandomTrend() {
  return Math.floor(Math.random() * 11) - 5 // -5 到 5
}
// 店铺数据
const shopList = ref([
  {
    id: 1,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'green',
    score: 90,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [
      {
        time: generateRandomDate(),
        content: '净化器运行时长不足',
        score: 90,
        handled: true,
      },
    ],
  },
  {
    id: 2,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'yellow',
    score: 75,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [
      {
        time: generateRandomDate(),
        content: '投诉次数较多',
        score: 80,
        handled: false,
      },
    ],
  },
  {
    id: 3,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'red',
    score: 60,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [
      {
        time: generateRandomDate(),
        content: '排放浓度超标',
        score: 65,
        handled: false,
      },
      {
        time: generateRandomDate(),
        content: '清洗频次不足',
        score: 62,
        handled: false,
      },
    ],
  },
  {
    id: 4,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'green',
    score: 92,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [],
  },
  {
    id: 5,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'yellow',
    score: 78,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [
      {
        time: generateRandomDate(),
        content: '风机联动率低',
        score: 75,
        handled: true,
      },
    ],
  },
  {
    id: 6,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'green',
    score: 90,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [],
  },
  {
    id: 7,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'red',
    score: 55,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [
      {
        time: generateRandomDate(),
        content: '未安装油烟净化设备',
        score: 60,
        handled: false,
      },
    ],
  },
  {
    id: 8,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'yellow',
    score: 72,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [
      {
        time: generateRandomDate(),
        content: '净化器清洗不及时',
        score: 75,
        handled: true,
      },
    ],
  },
  {
    id: 9,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'green',
    score: 93,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [],
  },
  {
    id: 10,
    shopName: getRandomElement(shopNames),
    district: '徐汇区',
    town: getRandomElement(xuhuiTowns),
    code: 'yellow',
    score: 76,
    trend: generateRandomTrend(),
    lastUpdate: generateRandomDate(),
    warnings: [
      {
        time: generateRandomDate(),
        content: '排放浓度接近标准限值',
        score: 78,
        handled: true,
      },
    ],
  },
])
// 过滤后的店铺列表
const filteredShopList = computed(() => {
  if (filterCode.value === 'all') {
    return shopList.value
  }
  return shopList.value.filter((shop) => shop.code === filterCode.value)
})
// 分页后的店铺列表
const pagedShopList = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return filteredShopList.value.slice(start, end)
})
// 生命周期
onMounted(() => {
  // 这里可以从API获取数据
  console.log('环信码管理页面加载')
  onSearch()
})
// 方法
function onBack() {
  // 回退逻辑
  console.log('回退')
}
function filterByCode(code) {
  filterCode.value = code === filterCode.value ? 'all' : code
  currentPage.value = 1 // 重置到第一页
}
// 分页方法
function handleSizeChange(size) {
  pageSize.value = size
  currentPage.value = 1
}
function handleCurrentChange(current) {
  currentPage.value = current
}
function getCodeType(code) {
  switch (code) {
    case 'green':
      return 'success'
    case 'yellow':
      return 'warning'
    case 'red':
      return 'danger'
    default:
      return ''
  }
}
function getCodeText(code) {
  switch (code) {
    case 'green':
      return '绿码'
    case 'yellow':
      return '黄码'
    case 'red':
      return '红码'
    default:
      return ''
  }
}
// 雷达图和趋势图引用
const radarChart = ref(null)
const trendChart = ref(null)
let radarChartInstance = null
let trendChartInstance = null
function viewDetails(shop) {
  selectedShop.value = shop
  drawerVisible.value = true
  // 获取环信码图片
  if (shop.guid && shop.shopName) {
    creditApi.fetchCodeUrl(shop.guid, shop.shopName).then((res) => {
      if (res && res.url) {
        codeImageUrl.value = res.url
      }
    })
  }
  // 延迟绘制图表,确保DOM已更新
  setTimeout(() => {
    drawRadarChart()
    drawTrendChart()
  }, 100)
}
// 绘制雷达图
function drawRadarChart() {
  if (!radarChart.value) return
  // 销毁旧实例
  if (radarChartInstance) {
    radarChartInstance.dispose()
  }
  // 初始化echarts实例
  radarChartInstance = echarts.init(radarChart.value)
  // 雷达图数据
  const labels = [
    '在线监测设备',
    '净化设施设备',
    '在线监测设备维护',
    '净化设施设备维护',
    '在线监测数据量级',
    '空调和风机噪声',
    '台站管理',
    '信用承诺自评',
  ]
  // 生成随机评分数据(实际项目中应从API获取)
  const data = labels.map(() => Math.floor(Math.random() * 40) + 60) // 60-100分
  // 配置项
  const option = {
    radar: {
      indicator: labels.map((label) => ({
        name: label,
        max: 100,
      })),
      radius: '70%',
    },
    series: [
      {
        type: 'radar',
        data: [
          {
            value: data,
            name: '评分维度',
            areaStyle: {
              color: 'rgba(103, 194, 58, 0.2)',
            },
            lineStyle: {
              color: '#67c23a',
            },
            itemStyle: {
              color: '#67c23a',
            },
          },
        ],
      },
    ],
  }
  // 渲染图表
  radarChartInstance.setOption(option)
  // 监听窗口大小变化
  window.addEventListener('resize', () => {
    radarChartInstance.resize()
  })
}
// 绘制趋势图
function drawTrendChart() {
  if (!trendChart.value) return
  // 销毁旧实例
  if (trendChartInstance) {
    trendChartInstance.dispose()
  }
  // 初始化echarts实例
  trendChartInstance = echarts.init(trendChart.value)
  // 生成过去12个月的标签
  const labels = []
  const data = []
  const now = dayjs()
  for (let i = 11; i >= 0; i--) {
    const date = now.subtract(i, 'month')
    labels.push(date.format('YYYY-MM'))
    // 生成随机评分数据(实际项目中应从API获取)
    data.push(Math.floor(Math.random() * 30) + 70) // 70-100分
  }
  // 配置项
  const option = {
    tooltip: {
      trigger: 'axis',
    },
    grid: {
      left: '3%',
      right: '4%',
      bottom: '3%',
      containLabel: true,
    },
    xAxis: {
      type: 'category',
      boundaryGap: false,
      data: labels,
      axisLabel: {
        rotate: 45,
      },
    },
    yAxis: {
      type: 'value',
      min: 60,
      max: 100,
      interval: 8,
    },
    series: [
      {
        name: '评分',
        type: 'line',
        data: data,
        smooth: true,
        lineStyle: {
          color: '#409eff',
        },
        itemStyle: {
          color: '#409eff',
        },
        areaStyle: {
          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
            {
              offset: 0,
              color: 'rgba(64, 158, 255, 0.3)',
            },
            {
              offset: 1,
              color: 'rgba(64, 158, 255, 0.1)',
            },
          ]),
        },
      },
    ],
  }
  // 渲染图表
  trendChartInstance.setOption(option)
  // 监听窗口大小变化
  window.addEventListener('resize', () => {
    trendChartInstance.resize()
  })
}
function viewRiskWarnings(shop) {
  selectedShop.value = shop
  drawerVisible.value = true
  // 这里可以滚动到风险预警部分
}
function openModelConfig() {
  modelConfigVisible.value = true
}
function saveModelConfig() {
  // 保存配置逻辑
  modelConfigVisible.value = false
  console.log('保存评分模型配置')
}
</script>
<style scoped>
.huanxin-code-manage {
  padding: 20px;
}
.dashboard {
  margin-bottom: 30px;
}
.dashboard-card {
  cursor: pointer;
  transition: all 0.3s ease;
}
.dashboard-card:hover {
  transform: translateY(-5px);
}
.card-content {
  text-align: center;
  padding: 20px 0;
}
.card-title {
  font-size: 16px;
  margin-bottom: 10px;
}
.card-value {
  font-size: 32px;
  font-weight: bold;
  margin-bottom: 5px;
}
.card-percentage {
  font-size: 14px;
  opacity: 0.8;
}
.green-card .card-value {
  color: #67c23a;
}
.yellow-card .card-value {
  color: #e6a23c;
}
.red-card .card-value {
  color: #f56c6c;
}
.model-config {
  text-align: right;
  margin-bottom: 20px;
}
.shop-list {
  margin-bottom: 20px;
}
.pagination {
  margin-top: 20px;
  text-align: right;
}
.trend {
  display: flex;
  align-items: center;
  justify-content: center;
}
.trend-icon {
  margin-right: 5px;
}
.trend-icon.up {
  color: #67c23a;
}
.trend-icon.down {
  color: #f56c6c;
}
.trend .up {
  color: #67c23a;
}
.trend .down {
  color: #f56c6c;
}
.map-container {
  position: relative;
  height: 600px;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
}
.map-placeholder {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.map-legend {
  position: absolute;
  bottom: 20px;
  right: 20px;
  background: white;
  padding: 10px;
  border-radius: 4px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.legend-item {
  display: flex;
  align-items: center;
  margin-bottom: 5px;
}
.legend-dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  margin-right: 8px;
}
.legend-dot.green {
  background-color: #67c23a;
}
.legend-dot.yellow {
  background-color: #e6a23c;
}
.legend-dot.red {
  background-color: #f56c6c;
}
.shop-details {
  padding: 20px;
}
.code-header {
  display: flex;
  align-items: flex-start;
  flex-direction: column;
  margin-bottom: 20px;
}
.code-icon {
  margin-right: 30px;
}
.score-info {
  flex: 1;
}
.score-label {
  font-size: 16px;
  margin-bottom: 5px;
}
.score-value {
  font-size: 48px;
  font-weight: bold;
}
.chart-section {
  margin-bottom: 20px;
}
.chart-section h3 {
  margin-bottom: 10px;
  font-size: 16px;
}
.radar-chart {
  width: 500px;
  height: 500px;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.trend-chart {
  height: 350px;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.warning-section {
  margin-top: 20px;
}
.warning-section h3 {
  margin-bottom: 10px;
  font-size: 16px;
}
.image {
  width: 300px;
  /* height: 250px; */
  border-radius: 4px;
  margin-bottom: 6px;
}
</style>