浏览代码

feat: 新增虫害分析模块并更新版本号

1. 升级应用版本到1.15.26
2. 新增shuHai业务页面路由和害虫分析页面
3. 新增设备控制、虫害识别等多个公共组件
4. 新增坐标转换工具类和SSE连接工具
5. 修复高德/百度地图导航坐标系适配问题
6. 新增设备列表GPS坐标转换逻辑
7. 注释调试用的设备跳转代码
allen 1 天之前
父节点
当前提交
3379e07213

+ 2 - 2
manifest.json

@@ -2,8 +2,8 @@
     "name" : "智控",
     "appid" : "__UNI__DBA6730",
     "description" : "",
-    "versionName" : "1.15.25",
-    "versionCode" : 11525,
+    "versionName" : "1.15.26",
+    "versionCode" : 11526,
     "transformPx" : false,
     "sassImplementationName" : "node-sass",
     /* 5+App特有相关 */

+ 32 - 0
pages.json

@@ -431,6 +431,30 @@
       }
     },
     {
+      "path": "pages/shuHai/detail",
+      "style": {
+        "navigationBarTitleText": "设备详情",
+        "enablePullDownRefresh": false,
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/shuHai/deviceControl",
+      "style": {
+        "navigationBarTitleText": "设备控制",
+        "enablePullDownRefresh": false,
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/shuHai/devicePhoto",
+      "style": {
+        "navigationBarTitleText": "查看图片",
+        "enablePullDownRefresh": false,
+        "navigationStyle": "custom"
+      }
+    },
+    {
       "path": "pages/sy/detail",
       "style": {
         "navigationBarTitleText": "设备详情",
@@ -564,6 +588,14 @@
       }
     },
     {
+      "path": "pages/cb/xy/equip-set/xypesthistoryile",
+      "style": {
+        "navigationBarTitleText": "害虫分析",
+        "enablePullDownRefresh": true,
+        "navigationStyle": "custom"
+      }
+    },
+    {
       "path": "pages/disandpests/index",
       "style": {
         "navigationBarTitleText": "病虫害识别",

+ 7 - 2
pages/cb/equip-detail/equip-detail.vue

@@ -222,6 +222,11 @@
 					path: '/pages/cb/xy/equip-set/xyhistoryile',
 					tf: QueryPermission(67),
 				}, {
+					icon: '/image/cb/2.png',
+					tex: '害虫分析',
+					path: '/pages/cb/xy/equip-set/xypesthistoryile',
+					tf: QueryPermission(67),
+				}, {
 					icon: '/image/environment/7.png',
 					tex: '一键报修',
 					path: '/pages/afterSale/addafter',
@@ -552,7 +557,7 @@
 						action: 'iosamap://'
 					})
 				) {
-					let url = `amapuri://route/plan?sourceApplication=yourAppName&dlat=${roundedLatitude}&dlon=${roundedLongitude}&dev=0&t=0&style=0&start=&auto=1&rtdType=1&coordinate=wgs84`;
+					let url = `amapuri://route/plan?sourceApplication=yourAppName&dlat=${roundedLatitude}&dlon=${roundedLongitude}&dev=0&t=0&style=0&start=&auto=1&rtdType=1&coordinate=gcj02`;
 					plus.runtime.openURL(url);
 				} else if (
 					plus.runtime.isApplicationExist({
@@ -560,7 +565,7 @@
 						action: 'baidumap://'
 					})
 				) {
-					let url = `baidumap://map/direction?destination=${roundedLongitude},${roundedLatitude}&mode=navigation&output=html&coord_type=wgs84`;
+					let url = `baidumap://map/direction?destination=${roundedLongitude},${roundedLatitude}&mode=navigation&output=html&coord_type=gcj02`;
 					plus.runtime.openURL(url);
 				} else {
 					uni.showToast({

+ 387 - 0
pages/cb/xy/equip-set/xypesthistoryile.vue

@@ -0,0 +1,387 @@
+<template>
+	<view>
+		<view class="status_bar"></view>
+		<view class="" style="position: relative;top: 40px;">
+			<view style="position: fixed;z-index: 100;top: 40px;">
+				<uni-nav-bar @clickLeft="clickLeft" left-icon="back" title="害虫分析"></uni-nav-bar>
+			</view>
+			<view class="shuju_one">
+				<view class="shuju_one_title">
+					<view
+						class="select-date"
+						style="border-radius: 40rpx;"
+						@click="showCalendar"
+					>
+						<text>{{ timeText }}</text>
+					</view>
+				</view>
+				<view class="time-change-range">
+					<text>选择对比年份:</text>
+					<text class="years-text" @click="changeindex">{{ yearsText }}</text>
+				</view>
+				<!-- 折线图:md_lst 为 x 轴,pest_data[i] 对应 years_lst[i] 年份数据 -->
+				<view v-if="dataloadingtf" class="canvastishi"><text class="dataloading">加载中</text></view>
+				<view v-else-if="!canvastishiTF" class="canvastishi">暂无数据</view>
+				<view v-else class="chart-container">
+					<qiun-data-charts
+						type="line"
+						:chartData="chartData"
+						:opts="opts"
+						:canvas2d="true"
+						:ontouch="true"
+					/>
+				</view>
+			</view>
+		</view>
+		<!-- 对比年份多选弹窗 -->
+		<u-popup v-model="show1" mode="bottom" border-radius="16">
+			<view class="year-popup">
+				<view class="year-popup__header">
+					<text class="year-popup__cancel" @click="show1 = false">取消</text>
+					<text class="year-popup__title">选择对比年份</text>
+					<text class="year-popup__confirm" @click="confirmYears">确定</text>
+				</view>
+				<scroll-view scroll-y class="year-popup__list">
+					<view
+						v-for="item in list"
+						:key="item.value"
+						class="year-popup__item"
+						:class="{ active: tempYears.indexOf(item.value) > -1 }"
+						@click="toggleYear(item.value)"
+					>
+						<text>{{ item.label }}</text>
+						<u-icon
+							v-if="tempYears.indexOf(item.value) > -1"
+							name="checkbox-mark"
+							color="#28AE4F"
+							size="32"
+						></u-icon>
+					</view>
+				</scroll-view>
+			</view>
+		</u-popup>
+		<u-calendar v-model="show" :mode="mode" @change="changeDate"></u-calendar>
+	</view>
+</template>
+<script>
+	export default {
+		data() {
+			return {
+				time: '请选择时间范围',
+				show: false,
+				show1: false,
+				mode: 'range',
+				canvastishiTF: false,//暂无数据提示
+				dataloadingtf:true,//加载中提示
+				d_id: '',
+				titletext: ["24小时", "近一个月", "近半年", "近一年"],
+				page: 1,
+				forbidden: false,
+				pagesum: 1,
+				years: new Date().getFullYear() - 1,
+				selectedYears: [],//多选的对比年份(已确认)
+				tempYears: [],//弹窗内勾选的临时年份,点确定才生效
+				list: [],
+				start_time: Math.floor(Date.now() / 1000) - 7*24 * 60 * 60,//开始时间 默认前7天 秒级时间戳
+				end_time: Math.floor(Date.now() / 1000),//结束时间 默认当前时间 秒级时间戳
+				device_type_id: '',
+				chartData: {},
+				// 折线图配置:每年一条曲线
+				opts: {
+					color: ['#0085FF', '#91CB74', '#FAC858', '#EE6666', '#73C0DE', '#3CA272', '#FC8452', '#9A60B4', '#ea7ccc'],
+					dataLabel: false,
+					animation: true,
+					enableScroll: true,
+					legend: {
+						show: true,
+						position: 'top',
+						fontSize: 10,
+						fontColor: '#666666'
+					},
+					xAxis: {
+						disableGrid: true,
+						fontSize: 9,
+						fontColor: '#999999',
+						itemCount: 8,
+						scrollShow: true
+					},
+					yAxis: {
+						gridType: 'dash',
+						gridColor: '#E5E5E5',
+						fontColor: '#999999',
+						splitNumber: 4,
+						min: 0,
+						tofix: 0
+					},
+					extra: {
+						line: { type: 'curve', width: 2 },
+						tooltip: { showBox: true }
+					}
+				},
+			}
+		},
+		computed: {
+			// 选中年份的显示文本
+			yearsText() {
+				return this.selectedYears.length ? this.selectedYears.join('、') : '请选择';
+			},
+			// 时间范围显示文本
+			timeText() {
+				if (this.time !== '请选择时间范围') {
+					return this.time;
+				}
+				return this.formatTs(this.start_time) + ' 至 ' + this.formatTs(this.end_time);
+			}
+		},
+		methods: {
+			showCalendar() {
+				this.show = true
+			},
+			// 秒级时间戳转 YYYY-MM-DD
+			formatTs(ts) {
+				const d = new Date(ts * 1000);
+				const p = n => (n < 10 ? '0' + n : '' + n);
+				return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
+			},
+			changeDate(e){
+				this.start_time = Math.floor(new Date(e.startDate + ' 00:00:00').getTime() / 1000)
+				this.end_time = Math.floor(new Date(e.endDate + ' 23:59:59').getTime() / 1000)
+				this.time = e.startDate + '至' + e.endDate
+				this.show = false
+				this.getChartNew()
+			},
+			async getChartNew(){
+				this.dataloadingtf = true;
+				try {
+					const res = await this.$myRequest({
+						url: '/api/api_gateway?method=forecast.worm_lamp.xycb_pest_chart_new',
+						method: 'POST',
+						data: {
+							device_type_id: this.deviceInfo.device_type,
+							d_id: this.deviceInfo.d_id,
+							start_time: this.start_time,
+							end_time: this.end_time,
+							years: this.selectedYears.join(','),
+						},
+					});
+					// $myRequest 已把响应体里的 data 字段解出来,res 即 {total_num, years_lst, pest_data, md_lst}
+					const mdLst = res?.md_lst || [];
+					const yearsLst = res?.years_lst || [];
+					const pestData = res?.pest_data || [];
+					if (mdLst.length && yearsLst.length) {
+						this.chartData = {
+							categories: mdLst,
+							series: yearsLst.map((year, index) => ({
+								name: String(year),
+								data: pestData[index] || []
+							}))
+						};
+						this.canvastishiTF = true;
+					} else {
+						this.chartData = {};
+						this.canvastishiTF = false;
+					}
+				} catch(e) {
+					this.chartData = {};
+					this.canvastishiTF = false;
+				} finally {
+					this.dataloadingtf = false;
+				}
+			},
+			changeindex() {
+				// 打开弹窗前备份已确认的年份,取消时直接丢弃临时勾选
+				this.tempYears = [...this.selectedYears];
+				this.show1 = true;
+			},
+			// 年份多选(只改临时数组)
+			toggleYear(value) {
+				const index = this.tempYears.indexOf(value);
+				if (index > -1) {
+					this.tempYears.splice(index, 1);
+				} else {
+					this.tempYears.push(value);
+				}
+			},
+			confirmYears() {
+				if (!this.tempYears.length) {
+					uni.showToast({ title: '请至少选择一个年份', icon: 'none' });
+					return;
+				}
+				this.selectedYears = [...this.tempYears];
+				this.show1 = false;
+				this.getChartNew();
+			},
+			clickLeft() {
+				uni.navigateBack({
+					delta: 1
+				})
+			},
+		},
+		onLoad(option) {
+			console.log(option,'optionoptionoptionoption')
+			// this.list 前10年的时间范围(先清空,避免每次 onShow 重复追加)
+			var now = new Date()
+			this.list = []
+			for (var i = 0; i < 10; i++) {
+				this.list.push({
+					label: now.getFullYear() - i,
+					value: now.getFullYear() - i,
+				})
+			}
+			// 默认选中当前年
+			this.selectedYears = [this.list[0].value]
+			this.end_time = Math.floor(+new Date() / 1000) + 1
+			this.start_time = this.end_time - 7 * 24 * 60 * 60
+			this.deviceInfo = option
+			this.d_id = option.d_id
+			this.device_id = option.device_id
+			this.device_type_id = option.device_type
+			this.getChartNew()
+		},
+	}
+</script>
+
+<style lang="scss">
+	::v-deep .u-calendar__action{
+		display:flex;
+	}
+	::v-deep .u-calendar__action__text{
+		width:80%;
+		text-align: center;
+	}
+	.time-change-range{
+		width: 90%;
+		margin: 10rpx auto;
+		display: flex;
+		align-items: center;
+		text-align: center;
+		> text:first-child{
+			flex-shrink: 0;
+		}
+		.years-text{
+			flex: 1;
+			min-width: 0;
+			overflow: hidden;
+			white-space: nowrap;
+			text-overflow: ellipsis;
+			text-align: left;
+			color: #28AE4F;
+			font-weight: 700;
+		}
+	}
+	// 年份多选弹窗
+	.year-popup{
+		padding: 24rpx 32rpx 48rpx;
+		.year-popup__header{
+			display: flex;
+			align-items: center;
+			justify-content: space-between;
+			padding: 16rpx 0 24rpx;
+			.year-popup__title{
+				font-size: 32rpx;
+				font-weight: 700;
+				color: #333333;
+			}
+			.year-popup__cancel{
+				font-size: 28rpx;
+				color: #999999;
+			}
+			.year-popup__confirm{
+				font-size: 28rpx;
+				color: #28AE4F;
+				font-weight: 700;
+			}
+		}
+		.year-popup__list{
+			max-height: 560rpx;
+			.year-popup__item{
+				display: flex;
+				align-items: center;
+				justify-content: space-between;
+				padding: 24rpx 8rpx;
+				font-size: 28rpx;
+				color: #333333;
+				border-bottom: 1rpx solid #F1F4F8;
+				&.active{
+					color: #28AE4F;
+					font-weight: 700;
+				}
+			}
+		}
+	}
+	.date-range{
+		display: flex;
+		text-align: center;
+	}
+	.chart-container{
+		width: 100%;
+		height: 420rpx;
+		margin-top: 16rpx;
+	}
+	.shuju_one,
+	.shuju_two {
+		position: absolute;
+		top: 54px;
+		width: 90%;
+		left: 5%;
+		box-shadow: 0 0 10rpx #bcb9ca;
+		padding-top: 20rpx;
+		height: 550rpx;
+		.canvastishi {
+			font-size: 32rpx;
+			position: absolute;
+			top: 50%;
+			left: 50%;
+			margin-left: -64rpx;
+			margin-top: -21rpx;
+			.dataloading:after {
+				overflow: hidden;
+				display: inline-block;
+				vertical-align: bottom;
+				animation: ellipsis 2s infinite;
+				content: "\2026";
+			}
+			@keyframes ellipsis {
+				from {
+					width: 2px;
+				}
+			
+				to {
+					width: 15px;
+				}
+			}
+		}
+		
+		.shuju_one_title {
+			width: 90%;
+			margin: 0 auto;
+			display: flex;
+			.select-date{
+				border-radius: 30rpx;
+				border:2rpx solid #B2B2B2;
+				width: 100%;
+				padding: 16rpx 20rpx;
+				color:#B2B2B2;
+			}
+			.tltle_text {
+				width: 25%;
+				border: 2rpx solid #B2B2B2;
+				color: #B2B2B2;
+				text-align: center;
+				font-size: 24rpx;
+				height: 50rpx;
+				line-height: 50rpx;
+			}
+
+			.title_text_color {
+				width: 25%;
+				border: 2rpx solid #28AE4F;
+				color: #28AE4F;
+				text-align: center;
+				font-size: 24rpx;
+				height: 50rpx;
+				line-height: 50rpx;
+			}
+		}
+	}
+</style>

+ 22 - 0
pages/equipList2/index.vue

@@ -240,6 +240,7 @@ import signal3 from '../assets/signal3.png';
 import signal4 from '../assets/signal4.png';
 import signal5 from '../assets/signal5.png';
 import openingDegree from '../assets/openingDegree.png';
+import { wgs84togcj02, bd09togcj02 } from '../../util/coordtransform_utils.js';
 
 	import {
 		Debounce,
@@ -520,6 +521,13 @@ import openingDegree from '../assets/openingDegree.png';
 				return name.slice(0,4)
 			},
 			modification(item) {
+				if(item?.gps == 1){
+					item.lng = wgs84togcj02(item.lng, item.lat)[0];
+					item.lat = wgs84togcj02(item.lng, item.lat)[1];
+				}else if(item?.gps == 3){
+					item.lng = bd09togcj02(item.lng, item.lat)[0];
+					item.lat = bd09togcj02(item.lng, item.lat)[1];
+				}
 				uni.navigateTo({
 					url:
 						'./seabox/modification?data=' +
@@ -702,6 +710,13 @@ import openingDegree from '../assets/openingDegree.png';
       },
 			historys(item) {
 				const type_id = item.type_id;
+				if(item?.gps == 1){
+					item.lng = wgs84togcj02(item.lng, item.lat)[0];
+					item.lat = wgs84togcj02(item.lng, item.lat)[1];
+				}else if(item?.gps == 3){
+					item.lng = bd09togcj02(item.lng, item.lat)[0];
+					item.lat = bd09togcj02(item.lng, item.lat)[1];
+				}
 				this.accessToken = uni.getStorageSync('session_key')
 				switch (type_id) {
 					// 水肥新设备
@@ -1027,6 +1042,13 @@ import openingDegree from '../assets/openingDegree.png';
 							url: '../cb/xylps/detail?detail=' + JSON.stringify(item),
 						});
 						break;
+					// case 31:
+					// 	item.addtime = item.uptime;
+					// 	item.type = item.type_id;
+					// 	uni.navigateTo({
+					// 		url: '../shuHai/detail?info=' + JSON.stringify(item),
+					// 	});
+					// 	break;
 					case 40:
 						item.addtime = item.uptime;
 						uni.navigateTo({

+ 133 - 0
pages/shuHai/components/DeviceCard.vue

@@ -0,0 +1,133 @@
+<template>
+  <view class="device-card__body">
+    <view class="device-card">
+      <view :class=" dataSource.status == 1 ?'online-status':'offline-status'">{{ dataSource.status === 1 ? '在线' : '离线' }}</view>
+      <view class="device-card__title">设备ID</view>
+      <view class="device-card__content">
+        <text class="device-id-text">{{ dataSource.id  }}</text>
+        <img class="copy-icon" :src="copy" @click="copyDeviceId"/>
+      </view>
+    </view>
+    <view class="device-card">
+      <view class="device-card__title">上报时间</view>
+      <view class="device-card__content">{{ dataSource.uptime | timeFormat()}}</view>
+    </view>
+    <view class="device-card">
+      <view class="device-card__title">设备位置</view>
+      <view class="device-card__content address">{{ dataSource.address || '-' }}</view>
+    </view>
+  </view>
+</template>
+<script>
+export default {
+  props: {
+    dataSource: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+  data() {
+    return {
+      copy:'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/newImg/home/copy.png',
+      title: ''
+    }
+  },
+  methods: {
+    copyDeviceId() {
+      uni.setClipboardData({
+        data: this.dataSource.id,
+        success: () => {
+          uni.showToast({
+            title: '复制成功',
+            icon: 'success'
+          })
+        }
+      })
+    }
+  }
+}
+</script>
+<style scoped lang="scss">
+.device-card__body {
+  display: flex;
+  padding: 16px 12px;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 8px;
+  border-radius: 8px;
+  background: linear-gradient(180deg, #EFF 0%, #FFF 23.56%);
+  margin-top: 34rpx;
+  position: relative;
+  .device-card {
+    display: flex;
+    gap: 16px;
+    .device-card__title {
+      width: 120rpx;
+      color: #4E5969;
+      font-size: 14px;
+      font-style: normal;
+      font-weight: 400;
+      text-align: right;
+      display: flex;
+      align-items: center;
+      justify-content: flex-end;
+    }
+    .online-status {
+      width: 136rpx;
+      height: 56rpx;
+      line-height: 56rpx;
+      position: absolute;
+      text-align: center;
+      top: 0;
+      right: 0;
+      background: url('https://s3.hnyfwlw.com/webstaticimg/bigdata_app/newImg/home/online1.png') no-repeat center center;
+      background-size: 100% 100%;
+      color: #0bbc58;
+      font-family: "Source Han Sans CN VF";
+      font-size: 28rpx;
+    }
+    .offline-status{
+      width: 136rpx;
+      height: 56rpx;
+      line-height: 56rpx;
+      position: absolute;
+      text-align: center;
+      top: 0;
+      right: 0;
+      background: url('https://s3.hnyfwlw.com/webstaticimg/bigdata_app/newImg/home/offline.png') no-repeat center center;
+      background-size: 100% 100%;
+      color: #ff3546;
+      font-family: "Source Han Sans CN VF";
+      font-size: 28rpx;
+    }
+    .device-card__content {
+      color: #4E5969;
+      font-size: 14px;
+      font-style: normal;
+      font-weight: 400;
+      display: flex;
+      align-items: center;
+      gap: 8rpx;
+      .device-id-text {
+        flex: 1;
+      }
+      .copy-icon {
+        font-family: 'iconfont';
+        font-size: 32rpx;
+        color: #86909C;
+        cursor: pointer;
+        width: 28rpx;
+        height: 28rpx;
+        &:active {
+          opacity: 0.6;
+        }
+      }
+    }
+    .address {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+}
+</style>

+ 48 - 0
pages/shuHai/components/DeviceControl.vue

@@ -0,0 +1,48 @@
+<template>
+  <view class="device-control-container">
+    <view class="device-status">
+      <view class="status-item">
+        <text class="status-label">环境温度</text>
+        <text class="status-value">{{ deviceStatus.temp }}°C</text>
+      </view>
+      <view class="status-item">
+        <text class="status-label">环境湿度</text>
+        <text class="status-value">{{ deviceStatus.humidity }}%</text>
+      </view>
+      <view class="status-item">
+        <text class="status-label">环境光照</text>
+        <text class="status-value">{{ deviceStatus.light }}Lux</text>
+      </view>
+      <view class="status-item">
+        <text class="status-label">环境湿度</text>
+        <text class="status-value">{{ deviceStatus.moisture }}%</text>
+      </view>
+      <view class="status-item">
+        <text class="status-label">环境湿度</text>
+        <text class="status-value">{{ deviceStatus.pest }}%</text>
+      </view>
+    </view>
+  </view>
+</template>
+<script>
+export default {
+  data() {
+    return {
+      deviceStatus: {
+        temp: 25,
+        humidity: 50,
+        light: 800,
+        moisture: 300,
+        pest: 10
+      }
+    }
+  }
+}
+</script>
+<style scoped lang="scss">
+.device-control-container {
+  padding: 24rpx;
+  background-color: #F7F8FA;
+  border-radius: 12rpx;
+}
+</style>

+ 359 - 0
pages/shuHai/components/aiChat.vue

@@ -0,0 +1,359 @@
+<template>
+  <view class="deepseek-chat-container">
+    <scroll-view class="chat-messages" scroll-y :scroll-into-view="scrollIntoView" v-show="pestMessage">
+      <view class="message-content">
+        <view class="message-input" style="padding:0">{{ pestMessage }}</view>
+        <view v-if="isloading" class="loading-hint">AI 正在分析中…</view>
+        <rich-text class="message-text markdown-output" :nodes="outputHtml"></rich-text>
+        <view class="message-input footer">内容由大模型生成,仅供参考,相关风险需自行承担。</view>
+      </view>
+      <view id="chatBottomAnchor"></view>
+    </scroll-view>
+    <view v-if="!pestMessage" style="height:100%">
+      
+    </view>
+  </view>
+</template>
+
+<script>
+import { createSSEConnection } from './sse.js';
+import { renderMarkdown } from './markdownRenderer.js';
+
+export default {
+  name: 'DeepSeekChat',
+  props: {
+    pestMessage: {
+      type: String,
+      default: ''
+    },
+  },
+  data() {
+    return {
+      text: '',
+      sseConnection: null,
+      result: '',
+      thinking: false,
+      activeNames: ['1'],
+      thinkHtml: '',
+      isloading: true,
+      mdParser: null,
+      mdRawText: '',   // 累积的原始 markdown 文本
+      outputHtml: '',  // renderMarkdown 渲染后的 HTML,交给 rich-text 展示
+      scrollIntoView: '', // scroll-view 滚动到底部锚点用
+    };
+  },
+  watch: {
+    text: {
+      handler() {
+        this.$nextTick(() => {
+          this.scrollToBottom();
+        });
+      },
+      deep: true
+    },
+    pestMessage(newVal) {
+      if (newVal) {
+        this.connectSSE(newVal);
+      } else {
+        this.isloading = false;
+      }
+    }
+  },
+  methods: {
+    initParser() {
+      this.mdRawText = '';
+      this.outputHtml = '';
+    },
+    connectSSE(message) {
+      this.initParser();
+      this.text = '';
+      this.thinkHtml = '';
+      let thinkContent = '';
+      let isCollecting = false;
+      const sseUrl = `https://ai.nyzhwlw.com/analysis?message=${message}`;
+      this.sseConnection = createSSEConnection(sseUrl);
+      this.sseConnection.onMessage((data) => {
+        this.isloading = false;
+        if (data === 'yfkj@2025' || data === '[DONE]') {
+          return;
+        }
+        // 处理 <think>...</think> 思考块:模板里不展示思考内容,
+        // 且原代码用到的 mdParser 未初始化(为 null)会抛错,导致 isCollecting 一直为 true、
+        // 后续正文也再不会渲染。这里整体丢弃思考块,只保留 </think> 之后可能跟着的正文。
+        if (isCollecting || data.indexOf('<think>') > -1) {
+          if (data.indexOf('<think>') > -1) {
+            isCollecting = true;
+            this.thinking = true;
+          }
+          thinkContent += data;
+          const endIdx = thinkContent.indexOf('</think>');
+          if (endIdx === -1) {
+            return; // 仍在思考块内,等待结束标记
+          }
+          // 思考块结束,取出其后的正文继续走正常渲染
+          data = thinkContent.slice(endIdx + '</think>'.length);
+          thinkContent = '';
+          isCollecting = false;
+          this.thinking = false;
+          if (!data) {
+            return;
+          }
+        }
+        // 累积正文并渲染为 markdown,用 rich-text 展示
+        this.mdRawText += data;
+        this.outputHtml = renderMarkdown(this.mdRawText);
+        this.scrollToBottom();
+      });
+
+      this.sseConnection.onError((error) => {
+        console.error('SSE Error:', error);
+        this.disconnectSSE();
+      });
+    },
+    collapseHtml() {
+      let result = '';
+      return result;
+    },
+    async renderHtml(result) {
+      this.text = result;
+      // 触发浏览器重绘
+      await new Promise((resolve) => setTimeout(resolve, 20));
+    },
+    disconnectSSE() {
+      if (this.sseConnection) {
+        this.sseConnection.close();
+        this.sseConnection = null;
+        this.isloading = false;
+      }
+    },
+    formatTime(date) {
+      return date.toLocaleTimeString([], {
+        hour: '2-digit',
+        minute: '2-digit'
+      });
+    },
+    scrollToBottom() {
+      // 小程序没有 DOM,不能用 $refs.scrollTop;改用 scroll-view 的 scroll-into-view 滚到底部锚点
+      this.scrollIntoView = '';
+      this.$nextTick(() => {
+        this.scrollIntoView = 'chatBottomAnchor';
+      });
+    }
+  },
+  mounted() {
+    this.scrollToBottom();
+    if (this.pestMessage) {
+      this.connectSSE(this.pestMessage);
+    } else {
+      this.isloading = false;
+    }
+  },
+  beforeDestroy() {
+    // 组件卸载时断开连接(uni-app 默认 Vue2,用 beforeDestroy)
+    this.disconnectSSE();
+  }
+};
+</script>
+
+<style scoped lang="less">
+.el-collapse {
+  background: #f6f6f6;
+  color: #a4aab2;
+  border-radius: 8rpx;
+}
+::v-deep .el-collapse-item__header {
+  padding: 0 16rpx;
+  height: 72rpx;
+  background: #f6f6f6 !important;
+  border-radius: 8rpx;
+}
+::v-deep .el-collapse-item__wrap {
+  background: #f6f6f6 !important;
+  color: #a4aab2 !important;
+  border-radius: 8rpx;
+}
+::v-deep .el-collapse-item__content {
+  color: #a4aab2 !important;
+  border-radius: 8rpx;
+}
+.deep-thinking {
+  padding: 0 52rpx;
+  border-radius: 8rpx;
+}
+.deepseek-chat-container {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  height: calc(100vh - 640rpx);
+  // max-width: 1086px;
+  margin: 0 auto;
+  border-radius: 8rpx;
+  // box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+  overflow: hidden;
+  font-family: 'Segoe UI', Arial, sans-serif;
+  // background-color: #f9f9f9;
+}
+.chat-messages {
+  flex: 1;
+  overflow-y: auto;
+  height: 100%;
+  // background-color: #ffffff;
+}
+
+.message {
+  display: flex;
+  margin-bottom: 24rpx;
+}
+
+.message-content {
+  max-width: 100%;
+}
+.loading-hint {
+  padding: 16rpx;
+  color: #999;
+  font-size: 26rpx;
+}
+
+.message-text {
+  padding: 16rpx;
+  border-radius: 18rpx;
+  line-height: 1.4;
+  word-wrap: break-word;
+  padding-bottom: 60rpx;
+}
+
+.message.bot .message-text {
+  background-color: #fff;
+  border-top-left-radius: 8rpx;
+  color: #5c5c5c;
+}
+
+.message.user .message-text {
+  color: white;
+  border-top-right-radius: 8rpx;
+}
+
+.message-time {
+  font-size: 22rpx;
+  color: #777;
+  margin-top: 8rpx;
+  text-align: right;
+}
+
+.message.user .message-time {
+  text-align: left;
+}
+
+.typing-indicator {
+  display: flex;
+  padding: 12rpx 30rpx;
+  background-color: #f6f6f6;
+  border-radius: 18rpx;
+  border-top-left-radius: 8rpx;
+}
+
+.typing-indicator span {
+  width: 16rpx;
+  height: 16rpx;
+  margin: 0 2rpx;
+  background-color: #999;
+  border-radius: 50%;
+  display: inline-block;
+  animation: bounce 1.4s infinite ease-in-out both;
+}
+
+.typing-indicator span:nth-child(1) {
+  animation-delay: -0.32s;
+}
+
+.typing-indicator span:nth-child(2) {
+  animation-delay: -0.16s;
+}
+@keyframes bounce {
+  0%,
+  80%,
+  100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+
+/* Markdown样式 - 使用深度选择器 */
+::v-deep .markdown-output {
+  /* 标题样式 */
+  h1, h2, h3, h4, h5, h6 {
+    margin-top: 32rpx;
+    margin-bottom: 16rpx;
+    font-weight: 600;
+    line-height: 1.25;
+  }
+  h1 { font-size: 2em; }
+  h2 { font-size: 1.5em; }
+  h3 { font-size: 1.25em; }
+  h4 { font-size: 1em; }
+  h5 { font-size: 0.875em; }
+  h6 { font-size: 0.85em; }
+
+  /* 列表样式 */
+  ul, ol {
+    padding-left: 2em;
+    margin: 16rpx 0;
+    list-style-type: disc;
+  }
+  ol {
+    list-style-type: decimal;
+  }
+  li {
+    margin: 8rpx 0;
+    line-height: 1.5;
+  }
+
+  /* 粗体斜体 */
+  strong {
+    font-weight: 600;
+  }
+  em {
+    font-style: italic;
+  }
+
+  /* 代码块 */
+  pre {
+    background-color: #f6f8fa;
+    padding: 32rex;
+    border-radius: 12rpx;
+    overflow-x: auto;
+    margin: 16rpx 0;
+  }
+  code {
+    background-color: #f6f8fa;
+    padding: 4rpx 8rpx;
+    border-radius: 6rpx;
+    font-family: monospace;
+  }
+
+  /* 段落 */
+  p {
+    margin: 16rpx 0;
+    line-height: 1.5;
+  }
+}
+
+.message-input {
+  padding: 20rpx 30rpx;
+  border-radius: 8rpx;
+  background-color: #f6f6f6;
+
+  &.footer {
+    position: fixed;
+    bottom: 20rpx;
+    left: 0;
+    right: 0rpx;
+    margin-top: 40rpx;
+    font-size: 24rpx;
+    color: #999;
+    text-align: center;
+  }
+}
+</style>

文件差异内容过多而无法显示
+ 1267 - 0
pages/shuHai/components/deviceData.vue


+ 219 - 0
pages/shuHai/components/floating-button.vue

@@ -0,0 +1,219 @@
+<template>
+	<view
+		class="floating-container"
+		:style="{ top: position.y + 'px', left: position.x + 'px' }"
+		@touchstart="onTouchStart"
+		@click="onClick"
+	>
+		<view class="floating-icon" :class="{ expanded: isExpanded }">
+			<view class="icon-content">
+				<slot name="icon">
+					<text class="default-icon">+</text>
+				</slot>
+			</view>
+			<view v-if="isExpanded" class="expanded-content">
+				<slot name="expanded">
+				</slot>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'FloatingButton',
+	props: {
+		// 初始位置距离右边的距离
+		right: {
+			type: Number,
+			default: 20
+		},
+		// 初始位置距离底部的距离
+		bottom: {
+			type: Number,
+			default: 100
+		},
+		// 按钮大小
+		size: {
+			type: Number,
+			default: 50
+		},
+		// 是否可拖拽
+		draggable: {
+			type: Boolean,
+			default: true
+		}
+	},
+	data() {
+		return {
+			position: {
+				x: 0,
+				y: 0
+			},
+			isDragging: false,
+			isExpanded: false,
+			startX: 0,
+			startY: 0,
+			lastX: 0,
+			lastY: 0,
+			clickStartTime: 0,
+			hasMoved: false
+		};
+	},
+	mounted() {
+		this.initPosition();
+	},
+	methods: {
+		// 初始化位置
+		initPosition() {
+			const systemInfo = uni.getSystemInfoSync();
+			const screenWidth = systemInfo.windowWidth;
+			const screenHeight = systemInfo.windowHeight;
+
+			this.position = {
+				x: screenWidth - this.right - this.size,
+				y: screenHeight - this.bottom - this.size
+			};
+		},
+
+		// 触摸开始
+		onTouchStart(e) {
+			if (!this.draggable) return;
+
+			const touch = e.touches[0];
+			this.startX = touch.clientX;
+			this.startY = touch.clientY;
+			this.lastX = this.position.x;
+			this.lastY = this.position.y;
+			this.isDragging = true;
+			this.hasMoved = false;
+			this.clickStartTime = Date.now();
+		},
+
+		// 触摸移动
+		onTouchMove(e) {
+			if (!this.draggable || !this.isDragging) return;
+
+			e.preventDefault();
+
+			const touch = e.touches[0];
+			const deltaX = touch.clientX - this.startX;
+			const deltaY = touch.clientY - this.startY;
+
+			// 如果移动超过5像素,认为是拖拽操作
+			if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
+				this.hasMoved = true;
+			}
+
+			const systemInfo = uni.getSystemInfoSync();
+			const screenWidth = systemInfo.windowWidth;
+			const screenHeight = systemInfo.windowHeight;
+
+			// 计算新位置,限制在屏幕范围内
+			let newX = this.lastX + deltaX;
+			let newY = this.lastY + deltaY;
+
+			// 边界限制
+			newX = Math.max(0, Math.min(newX, screenWidth - this.size));
+			newY = Math.max(0, Math.min(newY, screenHeight - this.size));
+
+			this.position = {
+				x: newX,
+				y: newY
+			};
+		},
+
+		// 触摸结束
+		onTouchEnd(e) {
+			if (!this.draggable) return;
+
+			this.isDragging = false;
+
+			// 自动吸附到屏幕边缘
+			this.snapToEdge();
+		},
+
+		// 吸附到屏幕左右边缘
+		snapToEdge() {
+			const systemInfo = uni.getSystemInfoSync();
+			const screenWidth = systemInfo.windowWidth;
+
+			// 判断当前在左半屏还是右半屏
+			const centerX = this.position.x + this.size / 2;
+
+			if (centerX < screenWidth / 2) {
+				// 吸附到左边
+				this.position.x = 10;
+			} else {
+				// 吸附到右边
+				this.position.x = screenWidth - this.size - 10;
+			}
+		},
+
+		// 点击事件
+		onClick(e) {
+			// 如果发生了拖拽移动,不触发点击事件
+			const clickDuration = Date.now() - this.clickStartTime;
+
+			if (this.hasMoved || clickDuration > 300) {
+				return;
+			}
+
+			// 切换展开/收起状态
+			this.isExpanded = !this.isExpanded;
+
+			// 触发父组件事件
+			this.$emit(this.isExpanded ? 'expand' : 'collapse', {
+				expanded: this.isExpanded
+			});
+		},
+
+		// 手动展开
+		expand() {
+			this.isExpanded = true;
+		},
+
+		// 手动收起
+		collapse() {
+			this.isExpanded = false;
+		}
+	}
+};
+</script>
+
+<style scoped>
+.floating-container {
+	position: fixed;
+	z-index: 9999;
+	width: 50px;
+	height: 50px;
+}
+.expanded-content{
+	position: absolute;
+}
+.floating-icon {
+	width: 100%;
+	height: 100%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	transition: all 0.3s ease;
+}
+
+.icon-content {
+	width: 100rpx;
+	height: 100rpx;
+	border-radius: 50%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	transition: all 0.3s ease;
+}
+
+.default-icon {
+	font-size: 24px;
+	color: #ffffff;
+	font-weight: bold;
+}
+
+</style>

+ 112 - 0
pages/shuHai/components/markdownRenderer.js

@@ -0,0 +1,112 @@
+// markdownRenderer.js
+export function renderMarkdown(raw) {
+  // 过滤结束标记
+  let text = raw.replace(/\[DONE\]/g, '')
+
+  // ── 预处理:在块级标识前插入换行(处理 AI 不换行直接切换块的情况)──
+  // 在 ## 标题前插入换行(前面不是换行时)
+  text = text.replace(/([^\n])(#{1,6} )/g, '$1\n$2')
+  // 在无序列表项前插入换行
+  text = text.replace(/([^\n])([ \t]*[-*+] )/g, '$1\n$2')
+  // 在有序列表项前插入换行
+  text = text.replace(/([^\n])([ \t]*\d+\. )/g, '$1\n$2')
+  // 在引用块前插入换行
+  text = text.replace(/([^\n])(> )/g, '$1\n$2')
+
+  // ── 按段落分割处理(双换行为段落分隔)──
+  // 先把单换行统一,再按块处理
+  const lines = text.split('\n')
+  const blocks = []
+  let i = 0
+
+  while (i < lines.length) {
+    const line = lines[i]
+
+    // 空行跳过
+    if (line.trim() === '') {
+      i++
+      continue
+    }
+
+    // 代码块
+    if (line.startsWith('```')) {
+      const lang = line.slice(3).trim()
+      const codeLines = []
+      i++
+      while (i < lines.length && !lines[i].startsWith('```')) {
+        codeLines.push(lines[i])
+        i++
+      }
+      i++ // 跳过结束 ```
+      const escaped = codeLines.join('\n')
+        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+      blocks.push(`<pre><code${lang ? ` class="language-${lang}"` : ''}>${escaped}</code></pre>`)
+      continue
+    }
+
+    // 标题
+    const headingMatch = line.match(/^(#{1,6}) (.+)/)
+    if (headingMatch) {
+      const level = headingMatch[1].length
+      blocks.push(`<h${level}>${applyInline(headingMatch[2])}</h${level}>`)
+      i++
+      continue
+    }
+
+    // 分割线
+    if (/^[-*_]{3,}\s*$/.test(line)) {
+      blocks.push('<hr>')
+      i++
+      continue
+    }
+
+    // 引用块
+    if (line.startsWith('> ') || line === '>') {
+      const content = line.slice(line.startsWith('> ') ? 2 : 1)
+      blocks.push(`<blockquote>${applyInline(content)}</blockquote>`)
+      i++
+      continue
+    }
+
+    // 无序列表:收集连续的列表项
+    if (/^[ \t]*[-*+] /.test(line)) {
+      const items = []
+      while (i < lines.length && /^[ \t]*[-*+] /.test(lines[i])) {
+        const m = lines[i].match(/^[ \t]*[-*+] (.+)/)
+        if (m) items.push(`<li>${applyInline(m[1])}</li>`)
+        i++
+      }
+      blocks.push(`<ul>${items.join('')}</ul>`)
+      continue
+    }
+
+    // 有序列表:收集连续的列表项
+    if (/^[ \t]*\d+\. /.test(line)) {
+      const items = []
+      while (i < lines.length && /^[ \t]*\d+\. /.test(lines[i])) {
+        const m = lines[i].match(/^[ \t]*\d+\. (.+)/)
+        if (m) items.push(`<li>${applyInline(m[1])}</li>`)
+        i++
+      }
+      blocks.push(`<ol>${items.join('')}</ol>`)
+      continue
+    }
+
+    // 普通段落
+    blocks.push(`<p>${applyInline(line)}</p>`)
+    i++
+  }
+
+  return blocks.join('\n')
+}
+
+// 行内样式处理
+function applyInline(text) {
+  return text
+    .replace(/\*\*\*([^*\n]+?)\*\*\*/g, '<strong><em>$1</em></strong>')
+    .replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>')
+    .replace(/\*([^*\n]+?)\*/g, '<em>$1</em>')
+    .replace(/~~([^~\n]+?)~~/g, '<del>$1</del>')
+    .replace(/`([^`\n]+)`/g, '<code>$1</code>')
+    .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
+}

+ 213 - 0
pages/shuHai/components/pestArchive.vue

@@ -0,0 +1,213 @@
+<template>
+  <view class="pest-archive">
+    <view class="pest-archive__header">
+      <view class="pest-archive__title">虫害档案</view>
+    </view>
+      <view
+        class="pest-card"
+        @tap="handleCardClick()"
+      >
+        <view class="pest-card__image">
+          <image :src="pest_info.img_urls" mode="aspectFill" class="pest-img"></image>
+        </view>
+        <view class="pest-card__info">
+          <view class="pest-card__top">
+            <view class="pest-card__name">
+              名称
+            </view>
+            <view class="pest-card__status">
+              {{ pest_info.name || '未知' }}
+            </view>
+          </view>
+          <view class="pest-card__top">
+            <view class="pest-card__name">
+              害虫信息
+            </view>
+            <view class="pest-card__status" style="height:160rpx;overflow: auto;">
+              {{ pest_info.prevention || '未知' }}
+            </view>
+          </view>
+        </view>
+      </view>
+      <u-popup v-model="showDetail" mode="bottom" border-radius="32">
+        <view class="pest-card__detail">
+          <view class="pest-card__image">
+            <image :src="pest_info.img_urls" mode="aspectFill" class="pest-img"></image>
+          </view>
+          <view>
+            {{ pest_info.name || '未知' }}
+          </view>
+          <view>
+            {{ pest_info.prevention || '未知' }}
+          </view>
+        </view>
+      </u-popup>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    scrollHeight: {
+      type: String,
+      default: '500rpx'
+    },
+    pest_info: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+  data() {
+    return {
+      showDetail: false,
+    }
+  },
+  methods: {
+    showPestDetail(){
+      this.showDetail = true;
+    },
+    handleCardClick() {
+      this.showPestDetail()
+      this.$emit('click', this.pest_info)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.pest-archive {
+  width: 100%;
+  padding: 32rpx;
+  margin-top: 24rpx;
+  box-sizing: border-box;
+  background: #fff;
+  border-radius: 16rpx;
+}
+
+.pest-archive__header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.pest-archive__title {
+  font-size: 32rpx;
+  color: #042118;
+  font-family: 'Source Han Sans CN VF';
+  font-weight: 500;
+}
+
+.pest-archive__add {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #00D26A 0%, #00B55A 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4rpx 12rpx rgba(0, 210, 106, 0.3);
+
+  .add-icon {
+    font-size: 40rpx;
+    color: #fff;
+    line-height: 1;
+    font-weight: 300;
+  }
+}
+.pest-card__detail{
+  height: 70vh;
+  padding: 40rpx;
+}
+.pest-archive__list {
+  width: 100%;
+}
+.pest-card {
+  display: flex;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  background: #F7F8F9;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+
+  &:active {
+    opacity: 0.8;
+  }
+}
+
+.pest-card__image {
+  width: 200rpx;
+  height: 200rpx;
+  border-radius: 12rpx;
+  overflow: hidden;
+  flex-shrink: 0;
+  margin-right: 20rpx;
+  background: #e0e0e0;
+
+  .pest-img {
+    width: 100%;
+    height: 100%;
+  }
+}
+
+.pest-card__info {
+  padding: 4rpx 0;
+}
+
+.pest-card__top {
+  display: flex;
+}
+
+.pest-card__name {
+  font-size: 26rpx;
+  color: #999999;
+  font-family: "Source Han Sans CN VF";
+  font-weight: 400;
+  width: 110rpx;
+}
+
+.pest-card__status {
+  width: 270rpx;
+  color: #303133;
+  font-size: 26srpx;
+  font-weight: 500;
+
+  &.status--handled {
+    background: rgba(0, 210, 106, 0.15);
+    color: #00B55A;
+  }
+
+  &.status--handling {
+    background: rgba(255, 149, 0, 0.15);
+    color: #FF9500;
+  }
+
+  &.status--pending {
+    background: rgba(255, 53, 70, 0.15);
+    color: #FF3546;
+  }
+}
+
+.pest-card__time {
+  font-size: 24rpx;
+  color: #86909C;
+  margin-top: 8rpx;
+}
+
+.pest-card__desc {
+  font-size: 26rpx;
+  color: #4E5969;
+  margin-top: 12rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  line-clamp: 2;
+  -webkit-box-orient: vertical;
+  line-height: 1.5;
+}
+</style>

+ 120 - 0
pages/shuHai/components/pestDiscern.vue

@@ -0,0 +1,120 @@
+<template>
+  <view class="pest-discern" v-if="pests.length">
+    <view class="pest-discern__header">
+      <view class="pest-discern__title">
+        虫害识别
+      </view>
+    </view>
+    <view class="pest-discern__content">
+      <view class="pest-discern__item" v-for="(item,index) in pests" :key="index">
+        <view class="pest-discern__item-title">
+          {{ item.name }}
+        </view>
+        <view class="pest-discern__item-content">
+          <u-line-progress :active-color="item.color" :percent="item.percent" :show-percent="false" height="8"></u-line-progress>
+        </view>
+        <view class="pest-discern__item-count">
+          {{ item.count }}
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+<script>
+export default {
+  props: {
+    total: {
+      type: Number,
+      default: 0
+    },
+    pest_order: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+  watch:{
+    pest_order:{
+      handler(val){
+        this.pests = [];
+        let index = 0;
+        for(let key in val){
+          index++;
+          this.pests.push({
+            color: this.colors[index],
+            name: key,
+            count: val[key] || 0,
+            percent: (val[key] / this.total) * 100
+          })
+        }
+      },
+      deep: true
+    }
+  },
+  data() {
+    return {
+      colors:[
+        '#FF5951',
+        '#66EDED',
+        '#E67B3E',
+        '#6DE28B',
+        '#FFC97A',
+        '#E7EB4B',
+        '#1561F3',
+        '#FA73F5',
+        '#159AFF',
+        '#FA73F5'
+      ],
+      pests: []
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.pest-discern {
+  width: 100%;
+  padding: 32rpx;
+  box-sizing: border-box;
+  background: #fff;
+  border-radius: 16rpx;
+}
+.pest-discern__header {
+  padding: 0rpx 0rpx 20rpx 0rpx;
+  font-size: 32rpx;
+  color: #042118;
+  font-family: 'Source Han Sans CN VF';
+  font-weight: 700;
+}
+.pest-discern__title {
+  font-size: 28rpx;
+  color: #042118;
+  font-family: 'Source Han Sans CN VF';
+  font-weight: 500;
+}
+.pest-discern__item {
+  display: flex;
+  width: 100%;
+  margin-bottom: 20rpx;
+  align-items: center;
+  .pest-discern__item-title{
+    width: 120rpx;
+    font-size: 28rpx;
+    color: #042118;
+    font-family: 'Source Han Sans CN VF';
+    font-weight: 400;
+    // 超出隐藏
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .pest-discern__item-content{
+    flex: 1;
+    margin: 0 20rpx;
+  }
+  .pest-discern__item-count{
+    width: 80rpx;
+    font-size: 28rpx;
+    color: #042118;
+    text-align: right;
+  }
+}
+</style>

+ 433 - 0
pages/shuHai/components/pestEchart.vue

@@ -0,0 +1,433 @@
+<template>
+  <view class="pest-echart" v-show="tabs.length">
+    <view class="pest-echart__header">
+      <view class="tab-list">
+        <view
+          v-for="(tab, index) in tabs"
+          :key="index"
+          class="tab-item"
+          :class="{ active: activeTab === index }"
+          @click="switchTab(index)"
+        >
+          {{ tab.name }}
+        </view>
+      </view>
+    </view>
+
+    <view class="pest-echart__content" v-show="dayData.length">
+      <!-- 三个关键时期 -->
+      <view class="period-section">
+        <view class="period-item">
+          <view class="period-label">始见期</view>
+          <view class="period-value">{{ periodData.firstDate }}</view>
+        </view>
+        <view class="period-item">
+          <view class="period-label">高峰期</view>
+          <view class="period-value">{{ periodData.peakDate }}</view>
+        </view>
+        <view class="period-item">
+          <view class="period-label">终见期</view>
+          <view class="period-value">{{ periodData.lastDate }}</view>
+        </view>
+      </view>
+
+      <!-- 图表区域 -->
+      <view class="chart-container">
+        <!-- <canvas
+          canvas-id="pestChart"
+          id="pestChart"
+          class="charts"
+          :style="{'width':cWidth*pixelRatio+'px','height':cHeight*pixelRatio+'px', 'transform': 'scale('+(1/pixelRatio)+')','margin-left':-cWidth*(pixelRatio-1)/2+'px','margin-top':-cHeight*(pixelRatio-1)/2+'px'}"
+          @touchstart="touchChart($event)"
+          @touchmove="moveChart($event)"
+          @touchend="touchEndChart($event)"
+          disable-scroll=true
+        ></canvas> -->
+        <qiun-data-charts type="line" :chartData="chartData" :opts="opts" :canvas2d="true" :inScrollView="true" :ontouch="true" />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import uCharts from '../../../components/js_sdk/u-charts/u-charts/u-charts.js';
+
+let chartInstance = null;
+
+export default {
+  name: 'PestEchart',
+  props:{
+    pest_order:{
+      type: Object,
+      default: () => ({})
+    },
+    endDate: {
+      type: String,
+      default: ''
+    },
+    d_id: {
+      type: String | Number,
+      default: ''
+    },
+    day:{
+      type: Array,
+      default: () => []
+    },
+    pest:{
+      type: Array,
+      default: () => []
+    },
+  },
+  data() {
+    return {
+      tabs: [],
+      currentPest:'',
+      activeTab: 0,
+      dayData:[],
+      // 三个关键时期数据
+      periodData: {
+        firstDate: '-',
+        peakDate: '-',
+        lastDate: '-'
+      },
+      chartData: {},
+      // canvas 尺寸配置
+      cWidth: 650,
+      cHeight: 400,
+      pixelRatio: 1,
+      opts: {
+        type: 'line',
+        xAxis: {
+          disableGrid: true,
+          itemCount: 3,
+          scrollShow: true
+        },
+        yAxis: {
+          disableGrid: true,
+          gridType: 'dash',
+          splitNumber: 5,
+          min: 0,
+          format: (val) => {
+            return Math.round(val)
+          }
+        },
+        extra: {
+          line: {
+            type: 'curve'
+          },
+          tooltip: {
+            format: {
+              name: '',
+              value: (val) => Math.round(val)
+            }
+          }
+        },
+        legend: {
+        },
+        enableScroll: true
+      },
+    };
+  },
+  watch:{
+    pest_order:{
+      handler(val){
+        this.tabs = [];
+        for(let key in val){
+          this.tabs.push({
+            name: key,
+          })
+        }
+        const name = this.tabs[0]?.name;
+        this.currentPest = name;
+        if(this.currentPest){
+          this.getPestNameDetail(name);
+          this.setChartData();
+        }
+      },
+      deep: true
+    },
+    d_id:{
+      handler(val){
+        val && this.setChartData();
+      },
+      deep: true
+    },
+    endDate:{
+      handler(val){
+        val && this.setChartData();
+      },
+      deep: true
+    },
+    day:{
+      handler(){
+        this.initChart();
+      },
+      deep: true,
+      immediate: true
+    },
+  },
+  mounted() {
+    this.cWidth = uni.upx2px(650);
+    this.cHeight = uni.upx2px(400);
+    this.pixelRatio = uni.getSystemInfoSync().pixelRatio;
+  },
+  methods: {
+    async getPestNameDetail(name){
+      try{
+        const res = await this.$myRequest({
+          url: '/api/pest_name_detail',
+          method: 'POST',
+          data: {
+            name
+          },
+        });
+        this.deviceInfo = res
+        this.$emit('getInfo',res)
+      }catch(err){
+        this.$emit('getInfo',{})
+      }
+    },
+    async setChartData(){
+      if(!this.currentPest){
+        return;
+      }
+      if(!this.d_id || !this.endDate){
+        return;
+      }
+     const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.cbd_analysis.pest_predict_time',
+        method: 'POST',
+        data: {
+          model:'B',
+          d_id: this.d_id,
+          year: this.endDate.split('-')[0],
+          pest: this.currentPest,
+        },
+      });
+      this.periodData = {
+        firstDate: res[0][0],
+        peakDate: res[1][0],
+        lastDate: res[2][0],
+      }
+    },
+    initChart() {
+      this.$nextTick(() => {
+        this.updateChartsData(0);
+      });
+    },
+    drawChart(index) {
+      const dayData = this.day || [];
+      this.dayData = dayData;
+      const pestData = this.pest[index];
+      const ctx = uni.createCanvasContext('pestChart', this);
+      chartInstance = new uCharts({
+        context: ctx,
+        type: 'line',
+        fontSize: 11,
+        legend: {
+          show: false
+        },
+        background: '#FFFFFF',
+        pixelRatio: this.pixelRatio,
+        animation: true,
+        dataLabel: false,
+        categories: dayData,
+        series: [{
+          name: '',
+          data: pestData
+        }],
+        color: ['#0085FF'],
+        xAxis: {
+          disableGrid: false,
+          boundaryGap: 'justify',
+          axisLine: true,
+          lineColor: '#CCCCCC',
+          fontColor: '#999999'
+        },
+        yAxis: {
+          min: 0,
+          minInterval: 1,
+          splitNumber: 4,
+          axisLine: true,
+          lineColor: '#CCCCCC',
+          fontColor: '#999999',
+          gridType: 'dash',
+          gridColor: '#E5E5E5'
+        },
+        width: this.cWidth * this.pixelRatio,
+        height: this.cHeight * this.pixelRatio,
+        extra: {
+          line: {
+            type: 'curve',
+            width: 2,
+            activeType: 'hollow'
+          },
+          tooltip: {
+            showBox: true,
+            bgOpacity: 0.7
+          }
+        }
+      });
+    },
+    touchChart(e) {
+      if (chartInstance) {
+        chartInstance.scrollStart(e);
+      }
+    },
+    moveChart(e) {
+      if (chartInstance) {
+        chartInstance.scroll(e);
+      }
+    },
+    touchEndChart(e) {
+      if (chartInstance) {
+        chartInstance.scrollEnd(e);
+        chartInstance.showToolTip(e, {
+          format: function(item, category) {
+            return category + ' ' + item.name + ':' + item.data
+          }
+        });
+      }
+    },
+    switchTab(index) {
+      this.activeTab = index;
+      const name = this.tabs[index]?.name;
+      this.currentPest = name
+      this.getPestNameDetail(name);
+      this.setChartData();
+      this.$nextTick(() => {
+        this.updateChartsData(index);
+      });
+    },
+    updateChartsData(index){
+      const dayData = this.day || [];
+      this.dayData = dayData;
+      const pestData = this.pest[index];
+      this.updateYAxisSplitNumber(pestData);
+      const lineData = {
+        categories: dayData,
+        series: [
+          {
+            name: '',
+            data: pestData
+          }
+        ]
+      };
+
+      this.chartData = lineData;
+    },
+    // 根据数据范围动态计算 y 轴分段数,保证每段刻度间距 >= 1,
+    // 避免数据较小时刻度被取整后出现重复(如 0,0,0,1,1,1)
+    updateYAxisSplitNumber(pestData){
+      const numList = (pestData || [])
+        .map(v => Number(v))
+        .filter(v => !isNaN(v));
+      let dataMin = numList.length ? Math.min(...numList) : 0;
+      let dataMax = numList.length ? Math.max(...numList) : 0;
+      // 与 u-charts 内部逻辑一致:数据全部相同时 min 归 0(全为 0 时 max 取 10)
+      if (dataMin === dataMax) {
+        if (dataMax === 0) {
+          dataMax = 10;
+        }
+        dataMin = 0;
+      }
+      const span = dataMax - dataMin;
+      // 数据范围 >= 5 时固定 5 段(每段间距自然 >= 1);否则按范围取分段数,使每段正好为 1
+      const splitNumber = span >= 5 ? 5 : Math.max(1, Math.floor(span));
+      this.opts.yAxis = { ...this.opts.yAxis, splitNumber };
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.pest-echart {
+  margin-top: 24rpx;
+  &__header{
+    margin-bottom: 24rpx;
+  }
+}
+
+.tab-list {
+  width: 100%;
+  overflow-x: scroll;
+  white-space: nowrap;
+  overflow-y: hidden;
+  box-sizing: border-box;
+  -ms-overflow-style: none;
+  scrollbar-width: none;
+}
+
+.tab-item {
+  margin-right: 16rpx;
+  font-size: 28rpx;
+  font-family: 'Source Han Sans CN VF', sans-serif;
+  font-weight: 500;
+  color: #999999;
+  display: inline-block;
+  box-sizing: border-box;
+  background: #ffffff;
+  font-family: "Source Han Sans CN VF";
+  font-size: 28rpx;
+  padding: 10rpx 32rpx;
+  border-radius: 8rpx;
+  &:last-child {
+    margin-right: 0;
+  }
+  &.active {
+    color: #0BBC58;
+    font-weight: 700;
+  }
+  &:first-child.active {
+    color: #0BBC58;
+  }
+}
+
+.pest-echart__content {
+  background: #FFFFFF;
+  border-radius: 16rpx;
+  padding: 0 32rpx 32rpx;
+}
+
+.period-section {
+  display: flex;
+  justify-content: space-between;
+  padding: 24rpx;
+  background: #ffffff;
+  border-radius: 12rpx;
+}
+
+.period-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.period-label {
+  font-size: 28rpx;
+  color: #303133;
+  font-family: 'Source Han Sans CN VF', sans-serif;
+  font-weight: 700;
+  margin-bottom: 8rpx;
+}
+
+.period-value {
+  font-size: 24rpx;
+  color: #999999;
+  font-family: 'Source Han Sans CN VF', sans-serif;
+  font-weight: 400;
+}
+
+.chart-container {
+  position: relative;
+  width: 100%;
+  height: 400rpx;
+  border-radius: 12rpx;
+}
+
+.charts {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 275 - 0
pages/shuHai/components/photoImage.vue

@@ -0,0 +1,275 @@
+<template>
+  <view class="photo-image">
+    <view class="photo-tabs">
+      <view class="photo-tab-item" :class="{ active: activeTab === 0 }" @click="changeTabActive(0)">全部</view>
+      <view class="photo-tab-item" :class="{ active: activeTab === 1 }" @click="changeTabActive(1)">相机1</view>
+      <view class="photo-tab-item" :class="{ active: activeTab === 2 }" @click="changeTabActive(2)">相机2</view>
+      <view class="photo-tab-item" :class="{ active: activeTab === 3 }" @click="changeTabActive(3)">相机3</view>
+    </view>
+    <view
+      class="photo-image__tabs"
+      v-if="disableShow"
+    >
+      <view
+        class="photo-image__tab"
+        v-for="(pest, index) in pestList"
+        :key="index"
+        :class="{ active: activeIndexList.includes(index) }"
+        @click="changeTab(index)"
+      >
+        <text class="tab-text">{{ pest.pest_name }}({{ pest.pest_num }})</text>
+      </view>
+    </view>
+    <view class="photo-image__content" v-if="images.length">
+      <view class="photo-image__grid">
+        <view class="photo-image__item" v-for="(item, index) in images" :key="index" @click="handleClick(item)">
+          <view class="photo-image__item-img-container">
+            <image
+              class="photo-image__item-img"
+              :src="item.addr"
+            />
+            <image
+              class="photo-image__item-icon"
+              src="https://s3.hnyfwlw.com/webstaticimg/bigdata_app/img/ai.png"
+            />
+            <image
+              class="photo-image__item-icon-video"
+              src="https://s3.hnyfwlw.com/webstaticimg/bigdata_app/img/video.png"
+            />
+          </view>
+          <view class="photo-image__item-title">
+            相机1
+          </view>
+          <view class="photo-image__item-time">
+            {{ formatDate(item.addtime) }}
+          </view>
+        </view>
+      </view>
+    </view>
+    <view v-else>
+      <u-empty text="暂无图片"></u-empty>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props:{
+    disableShow:{
+      type: Boolean,
+      default: true
+    },
+    pestList:{
+      type: Array,
+      default: () => []
+    },
+    images:{
+      type: Array,
+      default: () => []
+    },
+    deviceInfo:{
+      type: Object,
+      default: () => ({})
+    },
+    currentYear:{
+      type: String | Number,
+      default: ''
+    },
+  },
+  data() {
+    return {
+      activeIndexList:[],
+      activePest:[],
+      activeTab: 0,
+      tabs: ['全部', '稻飞虱', '水龟虫', '蝼蛄', '系统','全部', '稻飞虱', '水龟虫', '蝼蛄', '系统'],
+    };
+  },
+  methods: {
+    changeTab(index) {
+      if(this.activeIndexList.includes(index)){
+        this.activePest = this.activePest.filter(item => item !== this.pestList[index])
+        this.activeIndexList = this.activeIndexList.filter(item => item !== index)
+      }else{
+        this.activeIndexList.push(index)
+        this.activePest.push(this.pestList[index])
+      }
+      this.$emit('changeTab', this.activePest)
+    },
+    changeTabActive(index) {
+      this.activeTab = index
+      this.$emit('changeTab', this.activePest)
+    },
+    // 格式化时间
+    formatDate(dateString) {
+      const date = new Date(dateString*1000);
+      const year = date.getFullYear();
+      const month = String(date.getMonth() + 1).padStart(2, '0');
+      const day = String(date.getDate()).padStart(2, '0');
+      const hour = String(date.getHours()).padStart(2, '0');
+      const minute = String(date.getMinutes()).padStart(2, '0');
+      const second = String(date.getSeconds()).padStart(2, '0');
+      return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
+    },
+    handleClick(item) {
+      uni.navigateTo({
+        url: '/pages/cbd/devicePhoto?device_id=' + item?.device_id + '&addtime=' + item?.addtime + '&img_id=' + item?.ids + '&id=' + this.deviceInfo.id + '&currentYear=' + this.currentYear
+      })
+    }
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.photo-image {
+  width: 100%;
+  box-sizing: border-box;
+  .photo-tabs{
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    text-align: center;
+    border-radius: 16rpx;
+    background: #ffffff;
+    padding: 20rpx;
+    .photo-tab-item{
+      flex:1;
+      font-size: 28rpx;
+      color: #999999;
+      font-family: "Source Han Sans CN VF";
+      font-weight: 400;
+    }
+    .photo-tab-item.active{
+      border-radius: 4px;
+      background: #0BBC58;
+      color: #ffffff;
+      line-height: 46rpx;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+  &__header {
+    margin-bottom: 32rpx;
+    white-space: nowrap;
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+  }
+
+  &__tabs {
+    box-sizing: border-box;
+    width: 100%;
+    overflow-x: auto;
+    overflow-y: hidden;
+    white-space: nowrap;
+    // 去掉滚动条
+    -ms-overflow-style: none;
+    scrollbar-width: none;
+  }
+
+  .photo-image__content{
+    margin-top: 24rpx;
+    .photo-image__grid{
+      display: grid;
+      grid-template-columns: repeat(2, 1fr);
+      gap: 20rpx;
+    }
+  }
+  &__tab {
+    position: relative;
+    display: inline-block;
+    box-sizing: border-box;
+    margin-right: 16rpx;
+    background: #ffffff;
+    color: #999999;
+    font-family: "Source Han Sans CN VF";
+    font-size: 28rpx;
+    padding: 10rpx 32rpx;
+    border-radius: 8rpx;
+    &:last-child {
+      margin-right: 0;
+    }
+
+    .tab-text {
+      margin-right: 8rpx;
+    }
+
+    .tab-check {
+      color: #4CAF50;
+      font-weight: bold;
+    }
+
+    &.active {
+      color: #0BBC58;
+    }
+
+    .tab-underline {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      height: 4rpx;
+      background: #4CAF50;
+      border-radius: 2rpx;
+    }
+  }
+  &__item {
+    border-radius: 16rpx;
+    padding: 20rpx;
+    position: relative;
+    width: 100%;
+    box-sizing: border-box;
+    display: inline-block;
+    overflow: hidden;
+    background: #ffffff;
+    &-img-container {
+      width: 100%;
+      padding-bottom: 60%;
+      position: relative;
+      border-radius: 16rpx;
+      overflow: hidden;
+      background-color: #f5f5f5;
+    }
+
+    &-img {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+    }
+    &-icon {
+      position: absolute;
+      top: 8rpx;
+      left: 8rpx;
+      width: 40rpx;
+      height: 40rpx;
+    }
+    &-icon-video {
+      position: absolute;
+      top: 8rpx;
+      left: 52rpx;
+      width: 40rpx;
+      height: 40rpx;
+    }
+    &-title {
+      font-size: 28rpx;
+      color: #333333;
+      font-family: "Source Han Sans CN VF";
+      font-weight: 500;
+      margin-top: 12rpx;
+    }
+    &-time {
+      width: 100%;
+      padding: 0;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      margin-top: 12rpx;
+      font-size: 22rpx;
+      color: #666666;
+    }
+  }
+}
+</style>

+ 569 - 0
pages/shuHai/components/rodentAnalysis.vue

@@ -0,0 +1,569 @@
+<template>
+  <view class="rodent-analysis">
+    <!-- 活动节律分析 -->
+    <view class="analysis-card">
+      <view class="analysis-card__header">
+        <view class="analysis-card__title">活动节律分析</view>
+        <view class="analysis-card__extra">
+          <text class="peak-label">活跃高峰</text>
+          <text class="peak-value">{{ peakRange }}</text>
+        </view>
+      </view>
+      <view class="chart-legend">
+        <view class="chart-legend__dot chart-legend__dot--bar"></view>
+        <text class="chart-legend__text">鼠害数量</text>
+      </view>
+      <view class="chart-wrap chart-wrap--rhythm">
+        <qiun-data-charts
+          type="column"
+          :chartData="rhythmChart"
+          :opts="rhythmOpts"
+          :reshow="active"
+          :canvas2d="true"
+          :inScrollView="true"
+          :ontouch="true"
+        />
+      </view>
+    </view>
+
+    <!-- 群落结构分析 -->
+    <view class="analysis-card">
+      <view class="analysis-card__header">
+        <view class="analysis-card__title">群落结构分析</view>
+      </view>
+      <view class="community-content">
+        <view class="chart-wrap chart-wrap--ring">
+          <qiun-data-charts
+            type="ring"
+            :chartData="communityChart"
+            :opts="communityOpts"
+            :reshow="active"
+            :canvas2d="true"
+            :inScrollView="true"
+            :ontouch="true"
+          />
+        </view>
+        <view class="community-total">
+          <text>总鼠害数量</text>
+          <text class="total-value">{{ totalCount }}
+            <text class="unit">只</text>
+          </text>
+        </view>
+        <view class="community-list">
+          <view
+            class="community-item"
+            v-for="(item, index) in communityList"
+            :key="index"
+          >
+            <view class="community-item__dot" :style="{ background: item.color }"></view>
+            <view class="community-item__name">{{ item.name }}</view>
+            <view class="community-item__count">{{ item.count }}只</view>
+            <view class="community-item__percent">{{ item.percent }}%</view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 鼠情发生量与气象关系 -->
+    <view class="analysis-card">
+      <view class="analysis-card__header">
+        <view class="analysis-card__title">鼠情发生量与气象关系</view>
+        <view class="range-tabs">
+          <view
+            class="range-tab"
+            :class="{ active: weatherRange === 'week' }"
+            @click="switchRange('week')"
+          >周</view>
+          <view
+            class="range-tab"
+            :class="{ active: weatherRange === 'month' }"
+            @click="switchRange('month')"
+          >月</view>
+          <view
+            class="range-tab"
+            :class="{ active: weatherRange === 'year' }"
+            @click="switchRange('year')"
+          >年</view>
+        </view>
+      </view>
+      <view class="chart-wrap chart-wrap--weather">
+        <qiun-data-charts
+          type="mix"
+          :chartData="weatherChart"
+          :opts="weatherOpts"
+          :reshow="active"
+          :canvas2d="true"
+          :inScrollView="true"
+          :ontouch="true"
+        />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+// 鼠情发生量与气象关系 mock 数据
+const WEATHER_MOCK = {
+  month: {
+    categories: ['8-1','8-16','8-20','8-29','9-1','9-28','10-1','10-28','11-1','11-28','12-9'],
+    pest: [12, 18, 25, 15, 32, 28, 40, 35, 22, 45, 38],
+    temp: [28, 30, 26, 22, 25, 18, 15, 20, 24, 12, 8],
+    humidity: [45, 42, 48, 40, 38, 44, 46, 42, 37, 48, 43]
+  },
+  week: {
+    categories: ['8-12','8-13','8-14','8-15','8-16','8-17','8-18'],
+    pest: [8, 15, 22, 12, 28, 35, 20],
+    temp: [26, 28, 30, 29, 27, 25, 26],
+    humidity: [40, 36, 32, 45, 48, 46, 42]
+  },
+  year: {
+    categories: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
+    pest: [35, 28, 22, 18, 15, 20, 32, 45, 48, 42, 38, 30],
+    temp: [5, 8, 14, 20, 26, 30, 32, 31, 26, 20, 12, 6],
+    humidity: [30, 34, 40, 44, 48, 46, 48, 46, 42, 40, 36, 32]
+  }
+};
+
+function buildWeatherChart(range) {
+  const mock = WEATHER_MOCK[range];
+  return {
+    categories: mock.categories,
+    series: [
+      { name: '鼠害总数', type: 'area', style: 'curve', index: 0, data: mock.pest },
+      { name: '温度', type: 'line', style: 'curve', index: 1, data: mock.temp },
+      { name: '湿度', type: 'line', style: 'curve', index: 1, data: mock.humidity }
+    ]
+  };
+}
+
+export default {
+  name: 'RodentAnalysis',
+  props: {
+    // 当前 tab 是否激活(tab 用 v-show 切换时,隐藏状态下 canvas 容器测量为 0,需激活后重绘)
+    active: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data() {
+    return {
+      // 活动节律(mock)
+      peakRange: '22:00',
+      rhythmChart: null,
+      rhythmOpts: {
+        color: ['#FFC107'],
+        dataLabel: false,
+        // 背景轨道系列会进 uCharts 图例,改用卡片内自定义图例(见 template 的 chart-legend)
+        legend: { show: false },
+        xAxis: {
+          disableGrid: true,
+          fontSize: 9,
+          fontColor: '#656565'
+        },
+        yAxis: {
+          showTitle: true,
+          disableGrid: false,
+          gridType: 'dash',
+          gridColor: '#EEEEEE',
+          data: [{
+            title: '数量:只',
+            titleFontSize: 10,
+            titleFontColor: '#656565',
+            titleOffsetX: -10,
+            titleOffsetY: -6,
+            min: 0,
+            max: 100,
+            splitNumber: 5,
+            tofix: 0
+          }]
+        },
+        extra: {
+          column: {
+            // 温度计模式:系列[0]绘制为整柱浅色背景轨道,系列[1]叠加实际数据柱
+            type: 'meter',
+            meterBorder: 0,
+            meterFillColor: '#d6dbed66',
+            width: 14,
+            barBorderCircle: false,
+            linearType: 'none'
+          }
+        }
+      },
+
+      // 群落结构(mock)
+      communityList: [
+        { name: '褐家鼠', count: 91, percent: 25, color: '#E53935' },
+        { name: '黄胸鼠', count: 91, percent: 25, color: '#FFC107' },
+        { name: '小家鼠', count: 91, percent: 25, color: '#2196F3' },
+        { name: '黑线姬鼠', count: 91, percent: 25, color: '#9E9E9E' }
+      ],
+      communityChart: null,
+      communityOpts: {
+        color: ['#E53935', '#FFC107', '#2196F3', '#9E9E9E'],
+        legend: { show: false },
+        // 环形图:中心显示当前占比最高的物种
+        dataLabel: false,
+        title: {
+          name: '25%',
+          fontSize: 16,
+          color: '#E53935',
+          offsetY: -6
+        },
+        subtitle: {
+          name: '褐家鼠',
+          fontSize: 10,
+          color: '#333333',
+          offsetY: 8
+        },
+        extra: {
+          ring: {
+            // 环粗细约为半径的三分之一
+            ringWidth: 16,
+            offsetAngle: 0,
+            // 去掉分段之间的白色分隔线(qiun-data-charts 的 ring 默认 border: true)
+            border: false,
+            // 关闭点击分段时外扩+半透明的高亮效果(默认 activeRadius: 10 / activeOpacity: 0.5)
+            activeRadius: 5,
+            activeOpacity: 1
+          }
+        }
+      },
+
+      // 总量(mock)
+      totalCount: 126,
+      weekOverWeek: '+12.8%',
+      alarmThreshold: 100,
+      barMax: 130,
+
+      // 鼠情与气象(mock)
+      weatherRange: 'month',
+      weatherChart: null,
+      weatherOpts: {
+        color: ['#FF5252', '#FFC107', '#2196F3'],
+        dataLabel: false,
+        legend: {
+          show: true,
+          position: 'bottom',
+          fontSize: 10,
+          fontColor: '#656565',
+          itemGap: 15,
+          lineHeight: 15
+        },
+        xAxis: {
+          disableGrid: true,
+          fontSize: 9,
+          fontColor: '#656565'
+        },
+        yAxis: {
+          showTitle: true,
+          disableGrid: false,
+          gridType: 'dash',
+          gridColor: '#EEEEEE',
+          data: [
+            {
+              title: '鼠害数量:只',
+              titleFontSize: 10,
+              titleFontColor: '#656565',
+              titleOffsetX: -10,
+              titleOffsetY: -6,
+              position: 'left',
+              min: 0,
+              splitNumber: 5,
+              tofix: 0
+            },
+            {
+              title: '湿度:%',
+              titleFontSize: 10,
+              titleFontColor: '#656565',
+              titleOffsetX: 10,
+              titleOffsetY: -6,
+              position: 'right',
+              min: 0,
+              splitNumber: 5,
+              tofix: 0
+            }
+          ]
+        },
+        extra: {
+          mix: {
+            line: { width: 2 },
+            area: { opacity: 0.15, gradient: true }
+          },
+          tooltip: {
+            showBox: true
+          }
+        }
+      }
+    };
+  },
+  computed: {
+    // 进度条填充:总量/量程
+    fillPercent() {
+      return Math.min((this.totalCount / this.barMax) * 100, 100);
+    },
+    // 告警阈值标记位置
+    thresholdPercent() {
+      return (this.alarmThreshold / this.barMax) * 100;
+    }
+  },
+  mounted() {
+    // 延迟赋值图表数据:等页面布局稳定后再初始化 canvas,避免容器尺寸测量为 0
+    setTimeout(() => {
+      this.initChartData();
+    }, 300);
+  },
+  methods: {
+    initChartData() {
+      this.rhythmChart = {
+        categories: ['00:00','02:00','04:00','06:00','08:00','10:00','12:00','14:00','16:00','18:00','20:00','22:00','24:00'],
+        series: [
+          // 背景轨道:满量程高度(meter 模式下系列[0]用 meterFillColor 填充)
+          { name: '背景', color: '#D6DBED', data: Array(13).fill(100) },
+          { name: '鼠害数量', color: '#FFC107', data: [42, 58, 66, 48, 22, 12, 8, 15, 28, 45, 95, 88, 52] }
+        ]
+      };
+      this.communityChart = {
+        series: [{
+          data: [
+            { name: '褐家鼠', value: 91 },
+            { name: '黄胸鼠', value: 91 },
+            { name: '小家鼠', value: 91 },
+            { name: '黑线姬鼠', value: 91 }
+          ]
+        }]
+      };
+      this.weatherChart = buildWeatherChart(this.weatherRange);
+    },
+    switchRange(range) {
+      if (this.weatherRange === range) return;
+      this.weatherRange = range;
+      this.weatherChart = buildWeatherChart(range);
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.rodent-analysis {
+  width: 100%;
+}
+.analysis-card {
+  width: 100%;
+  padding: 32rpx;
+  box-sizing: border-box;
+  background: #fff;
+  border-radius: 16rpx;
+  margin-bottom: 24rpx;
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+.analysis-card__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+}
+.analysis-card__title {
+  position: relative;
+  font-size: 32rpx;
+  color: #042118;
+  font-family: 'Source Han Sans CN VF';
+  font-weight: 700;
+}
+.analysis-card__extra {
+  display: flex;
+  align-items: center;
+  .peak-label {
+    font-size: 24rpx;
+    color: #656565;
+    font-family: 'Source Han Sans CN VF';
+    margin-right: 8rpx;
+  }
+  .peak-value {
+    font-size: 24rpx;
+    color: #E53935;
+    font-family: 'Source Han Sans CN VF';
+    font-weight: 700;
+  }
+}
+.chart-legend {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  margin-bottom:-20rpx;
+  .chart-legend__dot--bar {
+    width: 16rpx;
+    height: 16rpx;
+    border-radius: 4rpx;
+    background: #FFC107;
+    margin-right: 8rpx;
+  }
+  .chart-legend__text {
+    font-size: 20rpx;
+    color: #656565;
+    font-family: 'Source Han Sans CN VF';
+  }
+}
+.chart-wrap {
+  position: relative;
+  width: 100%;
+}
+.chart-wrap--rhythm {
+  height: 400rpx;
+}
+.chart-wrap--ring {
+  width: 360rpx;
+  height: 320rpx;
+  flex-shrink: 0;
+}
+.chart-wrap--weather {
+  height: 420rpx;
+}
+// 群落结构:上下布局,环形图在上,图例列表在下
+.community-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.community-total{
+  width: 90%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-family: 'Source Han Sans CN VF';
+  .total-value {
+    font-size: 32rpx;
+    color: #042118;
+    font-weight: 700;
+    line-height: 1;
+  }
+  .unit {
+    font-size: 28rpx;
+    font-weight: 400;
+    color:#999999;
+    margin-left: 8rpx;
+  }
+}
+.community-list {
+  width: 90%;
+  margin-top: 16rpx;
+}
+.community-item {
+  display: flex;
+  align-items: center;
+  padding: 12rpx 0;
+  font-family: 'Source Han Sans CN VF';
+  .community-item__dot {
+    width: 16rpx;
+    height: 16rpx;
+    border-radius: 4rpx;
+    margin-right: 12rpx;
+    flex-shrink: 0;
+  }
+  .community-item__name {
+    flex: 1;
+    font-size: 26rpx;
+    color: #042118;
+  }
+  .community-item__count {
+    font-size: 26rpx;
+    color: #042118;
+    margin-right: 16rpx;
+  }
+  .community-item__percent {
+    font-size: 26rpx;
+    color: #656565;
+    width: 72rpx;
+    text-align: right;
+  }
+}
+// 总量害虫数量
+.total-main {
+  display: flex;
+  align-items: flex-end;
+  margin-bottom: 24rpx;
+  font-family: 'Source Han Sans CN VF';
+  .total-value {
+    font-size: 56rpx;
+    color: #042118;
+    font-weight: 700;
+    line-height: 1;
+    .total-unit {
+      font-size: 28rpx;
+      font-weight: 500;
+      margin-left: 8rpx;
+    }
+  }
+  .total-compare {
+    font-size: 24rpx;
+    color: #656565;
+    margin-left: 24rpx;
+    padding-bottom: 4rpx;
+    .total-compare__value {
+      color: #E53935;
+      font-weight: 700;
+    }
+  }
+}
+.total-bar {
+  .total-bar__track {
+    position: relative;
+    height: 16rpx;
+    border-radius: 8rpx;
+    background: #F1F4F8;
+    overflow: visible;
+  }
+  .total-bar__fill {
+    height: 100%;
+    border-radius: 8rpx;
+    background: #0BBC58;
+  }
+  .total-bar__marker {
+    position: absolute;
+    top: -6rpx;
+    width: 4rpx;
+    height: 28rpx;
+    border-radius: 2rpx;
+    background: #E53935;
+    transform: translateX(-50%);
+  }
+  .total-bar__scale {
+    display: flex;
+    justify-content: space-between;
+    margin-top: 8rpx;
+    font-size: 22rpx;
+    color: #999999;
+    font-family: 'Source Han Sans CN VF';
+  }
+}
+// 周/月/年切换
+.range-tabs {
+  display: flex;
+  align-items: center;
+  .range-tab {
+    width: 64rpx;
+    height: 48rpx;
+    line-height: 44rpx;
+    text-align: center;
+    font-size: 24rpx;
+    color: #042118;
+    font-family: 'Source Han Sans CN VF';
+    background: #fff;
+    border: 2rpx solid #E4E7ED;
+    box-sizing: border-box;
+    &:first-child {
+      border-radius: 8rpx 0 0 8rpx;
+    }
+    &:last-child {
+      border-radius: 0 8rpx 8rpx 0;
+    }
+    &.active {
+      background: #0BBC58;
+      border-color: #0BBC58;
+      color: #fff;
+      font-weight: 700;
+    }
+  }
+}
+</style>

+ 149 - 0
pages/shuHai/components/sse.js

@@ -0,0 +1,149 @@
+// sse.js —— 跨端 SSE 连接
+// H5 / 浏览器:直接用原生 EventSource
+// 小程序(无 EventSource):用 uni.request 的 enableChunked 接收分块,手动解析 SSE 协议
+
+// ---- UTF-8 解码器:支持跨分片的多字节字符(中文、emoji),避免截断乱码 ----
+function createUtf8Decoder() {
+  let pending = [];
+  return function decode(bytes) {
+    const all = pending.length ? pending.concat(Array.from(bytes)) : Array.from(bytes);
+    let out = '';
+    let i = 0;
+    const len = all.length;
+    while (i < len) {
+      const c = all[i];
+      if (c < 0x80) {
+        out += String.fromCharCode(c);
+        i++;
+      } else if (c >> 5 === 0x06) {
+        // 2 字节
+        if (i + 1 < len) {
+          out += String.fromCharCode(((c & 0x1f) << 6) | (all[i + 1] & 0x3f));
+          i += 2;
+        } else { pending = all.slice(i); return out; }
+      } else if (c >> 4 === 0x0e) {
+        // 3 字节
+        if (i + 2 < len) {
+          out += String.fromCharCode(((c & 0x0f) << 12) | ((all[i + 1] & 0x3f) << 6) | (all[i + 2] & 0x3f));
+          i += 3;
+        } else { pending = all.slice(i); return out; }
+      } else if (c >> 3 === 0x1e) {
+        // 4 字节(代理对)
+        if (i + 3 < len) {
+          let cp = ((c & 0x07) << 18) | ((all[i + 1] & 0x3f) << 12) | ((all[i + 2] & 0x3f) << 6) | (all[i + 3] & 0x3f);
+          cp -= 0x10000;
+          out += String.fromCharCode(0xd800 + (cp >> 10)) + String.fromCharCode(0xdc00 + (cp & 0x3ff));
+          i += 4;
+        } else { pending = all.slice(i); return out; }
+      } else {
+        i++; // 非法字节,跳过
+      }
+    }
+    pending = [];
+    return out;
+  };
+}
+
+// ---- SSE 协议解析:以空行(\n\n)分隔事件,提取 data: 字段 ----
+function createSseParser(onData) {
+  let buffer = '';
+  return function feed(text) {
+    buffer += text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+    let idx;
+    while ((idx = buffer.indexOf('\n\n')) !== -1) {
+      const raw = buffer.slice(0, idx);
+      buffer = buffer.slice(idx + 2);
+      const lines = raw.split('\n');
+      let dataStr = '';
+      for (const line of lines) {
+        if (line.indexOf('data:') === 0) {
+          dataStr += line.slice(5).replace(/^ /, '');
+        }
+      }
+      if (dataStr !== '') {
+        onData(dataStr);
+      }
+    }
+  };
+}
+
+// H5:原生 EventSource
+function connectWithEventSource(url, handlers) {
+  const eventSource = new EventSource(url);
+  eventSource.addEventListener('message', (e) => {
+    try {
+      handlers.onMessage(e.data.replace(/\\n/g, '\n'));
+    } catch (err) {
+      console.error('Error parsing SSE data:', err);
+    }
+  });
+  eventSource.addEventListener('error', (e) => {
+    handlers.onError(e);
+  });
+  return {
+    close() {
+      try { eventSource.close(); } catch (e) {}
+    }
+  };
+}
+
+// 小程序:uni.request 分块流式接收
+function connectWithUniRequest(url, handlers) {
+  const decoder = createUtf8Decoder();
+  const feed = createSseParser((dataStr) => {
+    try {
+      handlers.onMessage(dataStr.replace(/\\n/g, '\n'));
+    } catch (err) {
+      console.error('Error parsing SSE data:', err);
+    }
+  });
+  let requestTask = null;
+  let closed = false;
+
+  requestTask = uni.request({
+    url,
+    method: 'GET',
+    enableChunked: true,
+    responseType: 'text',
+    success: () => {},
+    fail: (err) => {
+      if (!closed) handlers.onError(err);
+    }
+  });
+
+  if (requestTask && typeof requestTask.onChunkReceived === 'function') {
+    requestTask.onChunkReceived((res) => {
+      const bytes = new Uint8Array(res.data);
+      const text = decoder(bytes);
+      if (text) feed(text);
+    });
+  } else {
+    handlers.onError(new Error('当前环境不支持流式接收(enableChunked / onChunkReceived)'));
+  }
+
+  return {
+    close() {
+      closed = true;
+      try { requestTask && requestTask.abort && requestTask.abort(); } catch (e) {}
+    }
+  };
+}
+
+export function createSSEConnection(url, options = {}) {
+  const handlers = { onMessage: () => {}, onError: () => {} };
+  const conn = (typeof EventSource !== 'undefined')
+    ? connectWithEventSource(url, handlers)
+    : connectWithUniRequest(url, handlers);
+
+  return {
+    onMessage(callback) {
+      handlers.onMessage = callback || (() => {});
+    },
+    onError(callback) {
+      handlers.onError = callback || (() => {});
+    },
+    close() {
+      conn.close();
+    }
+  };
+}

+ 742 - 0
pages/shuHai/detail.vue

@@ -0,0 +1,742 @@
+<template>
+  <view class="device-detail">
+    <view class="device-detail__header">
+      <u-icon
+        size="36"
+        class="arrow-left"
+        name="arrow-left"
+        @click="handleBack"
+      ></u-icon>
+      {{ deviceInfo.name }}
+    </view>
+    <view class="operation" @click.stop="isShowOperation = !isShowOperation">操作</view>
+    <view class="operation-container" v-if="isShowOperation" @click="closeOperationHandler">
+      <view class="operation-background"></view>
+      <view class="operation-content">
+        <view class="operation-item" v-if="isShowPhoto" @click="handlePhotoClick">
+          <image :src="photoIcon" class="operation-icon"></image>
+          拍照
+        </view>
+        <view class="operation-item" @click="handleServiceClick">
+          <image :src="serviceIcon" class="operation-icon"></image>
+          维修
+        </view>
+        <view class="operation-item" @click="handleSettingClick">
+          <image :src="settingIcon" class="operation-icon"></image>
+          设置
+        </view>
+        <view class="operation-item" @click="handleSimClick">
+          <image :src="simIcon" class="operation-icon"></image>
+          SIM卡
+        </view>
+        <view class="operation-item" @click="modification">
+          <image :src="editIcon" class="operation-icon"></image>
+          修改
+        </view>
+      </view>
+    </view>
+    <view class="device-detail__body">
+      <DeviceCard
+        :dataSource="deviceInfo"
+        :collectTime="time"
+        :title="deviceInfo.name"
+        :deviceType="deviceType"
+      />
+      <view class="tabs">
+        <view class="tab-container" v-if="isShowTab">
+          <view class="tab-item" :class="activeTab === 'viewImage'?'active':''" @click="handleTabClick('viewImage')">
+            图像数据
+          </view>
+          <view class="tab-item" :class="activeTab === 'pestAnalysis'?'active':''" @click="handleTabClick('pestAnalysis')">
+            鼠情分析
+          </view>
+          <view class="tab-item" :class="activeTab === 'aiAnalysis'?'active':''" @click="handleTabClick('aiAnalysis')">
+            设备预警
+          </view>
+          <view class="tab-item" :class="activeTab === 'deviceData'?'active':''" @click="handleTabClick('deviceData')">
+            设备数据
+          </view>
+        </view>
+        <view class="select-timer-container">
+          <view class="select-year">
+            <view class="select-year-item" @click="showPicker = true">
+              {{ currentYear }}
+              <u-icon name="arrow-down" size="18" class="arrow-down"></u-icon>
+            </view>
+          </view>
+          <view class="tabs-timer-container" @click="showCalendar">
+            <view class="tabs-timer-item">
+              {{startDate}}
+            </view>
+            至
+            <view class="tabs-timer-item">
+              {{endDate}}
+            </view>
+            <u-icon name="calendar" size="36" class="calendar-icon"></u-icon>
+          </view>
+        </view>
+      </view>
+      <view v-show="activeTab === 'pestAnalysis'" class="tab-content">
+        <PestDiscern
+          :total="total"
+          :pest_order="pest_order"
+        />
+        <PestEchart
+          :pest_order="pest_order"
+          @getInfo="getInfo" 
+          :endDate="endDate"
+          :d_id="deviceInfo.d_id"
+          :day="day"
+          :pest="pest"
+        />
+        <RodentAnalysis :active="activeTab === 'pestAnalysis'" />
+        <PestArchive
+          :pest_info="pestInfo"
+        />
+        <!-- <view v-if="!pests.length">
+          <u-empty text="暂无数据"></u-empty>
+        </view> -->
+      </view>
+      <view v-if="activeTab === 'viewImage'">
+        <!-- 实现下拉加载 -->
+         <scroll-view class="scroll-view" scroll-y style="height: 60vh;" @scrolltolower="handleReachBottom">
+          <photoImage
+            :images="imageList"
+            :pestList="pestList"
+            :disableShow="disableShow"
+            @changeTab="changeTab"
+            :currentYear="currentYear"
+            :deviceInfo="deviceInfo"
+          />
+        </scroll-view>
+      </view>
+      <view v-if="activeTab === 'aiAnalysis'">
+        <AiChat
+          :pestMessage="pestMessage"
+        />
+      </view>
+      <view v-if="activeTab === 'deviceData'">
+        <DeviceData
+          :deviceStatic="deviceStatic"
+          :deviceInfo="deviceInfo"
+          :polylineList="polylineList"
+          :deviceHistoryList="deviceHistoryList"
+          :totalPage="totalPage"
+          :currentPage="page"
+          :page_size="page_size"
+          @prevPage="prevPage"
+          @nextPage="nextPage"
+        />
+      </view>
+    </view>
+    <u-calendar v-model="show" :mode="mode" range-color="#999" btn-type="success" active-bg-color="#0BBC58" range-bg-color="rgba(11,188,88,0.13)" @change="handleChange" :max-date="maxDate" :min-date="minDate"></u-calendar>
+    <u-picker v-model="showPicker" mode="selector" :range="selectorRange" range-key="id" :default-selector="[0]" @confirm="confirmHandler"></u-picker>
+  </view>
+</template>
+<script>
+import DeviceCard from './components/DeviceCard.vue';
+import PestDiscern from './components/pestDiscern.vue';
+import PestEchart from './components/pestEchart.vue';
+import PestArchive from './components/pestArchive.vue';
+import photoImage from './components/photoImage.vue';
+import DeviceData from './components/deviceData.vue';
+import AiChat from './components/aiChat.vue';
+import RodentAnalysis from './components/rodentAnalysis.vue';
+
+export default {
+  components: {
+    DeviceCard,
+    PestDiscern,
+    AiChat,
+    PestEchart,
+    PestArchive,
+    photoImage,
+    DeviceData,
+    RodentAnalysis,
+  },
+  data(){
+    return {
+      isShowTab:false,
+      showPicker: false,
+      disableShow: false,
+      isShowOperation: false,
+      photoIcon:'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/newImg/home/photoIcon.png',
+      editIcon:'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/newImg/home/editIcon.png',
+      serviceIcon:'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/newImg/home/serviceIcon.png',
+      simIcon:'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/newImg/home/simIcon.png',
+      settingIcon:'https://s3.hnyfwlw.com/webstaticimg/bigdata_app/newImg/home/settingIcon.png',
+      totalPage:0,
+      currentYear: new Date().getFullYear(),
+      selectorRange: [],
+      maxDate: this.formatDate(new Date()),
+      minDate: this.formatDate(new Date(new Date().getFullYear(), 0, 1)),
+      show: false,
+      showSim: false,
+      // 当前日期向前推30天 格式2026-01-01 月份和日期小于10的时候前面加个0
+      startDate: this.formatDate(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000)),
+      endDate: this.formatDate(new Date()),
+      mode: 'range',
+      imageList: [],
+      pest_order:{},
+      pestMessage:'',
+      deviceInfo: {},
+      pestInfo: {},
+      time: '',
+      activeTab: 'pestAnalysis',
+      deviceType: '',
+      location: '',
+      total: 0,
+      imgTotal: 0,
+      day: [],
+      pest: [],
+      page:1,
+      is_pest:'',
+      page_size:24,
+      pestList:[],
+      pest_names:'',
+      pests:[],
+      polylineList:[],
+      isShowPhoto:false,
+      deviceHistoryList:[],
+      deviceStatic:{}
+    }
+  },
+  onLoad(options){
+    this.deviceInfo = JSON.parse(options.info);
+    this.getPestAnalysis();
+    this.isShow();
+    const currentYear = new Date().getFullYear();
+    this.selectorRange = [];
+    for(let i = 0;i<50;i++){
+      const item = {
+        label: currentYear - i,
+        id: currentYear - i
+      }
+      this.selectorRange.push(item);
+    }
+  },
+  methods: {
+    formatePestMessage(data) {
+      if (data.length === 0) {
+        this.pestMessage = '';
+        return;
+      }
+      this.pestMessage = `当前设备位置为${this.deviceInfo.address},在${this.startDate}至${this.endDate} 监测到的害虫如下:`;
+      data.map((item, index) => {
+        if(index === data.length - 1){
+          this.pestMessage += `${item.name}:${item.value}头.`;
+        } else {
+          this.pestMessage += `${item.name}:${item.value}头,`;
+        }
+      });
+    },
+    handleReachBottom(){
+      if (this.activeTab === 'viewImage') {
+        if(this.imageList.length >= this.imgTotal){
+          return
+        }
+        this.initImageList(true);
+      }
+    },
+    handleSimClick(){
+      // this.showSim = true;
+      //`/pages/deviceDetails/weatherStation/${type}?deviceInfo=${encodeURIComponent(JSON.stringify(this.deviceInfo))}`
+      uni.navigateTo({
+        url:
+          '/pages/deviceDetails/weatherStation/simDetail?deviceInfo=' +
+          encodeURIComponent(JSON.stringify(this.deviceInfo))
+      }); 
+    },
+		modification() {
+      uni.navigateTo({
+        url:
+          '/pages/equipList2/seabox/modification?data=' +
+          JSON.stringify(this.deviceInfo) +
+          '&id=' +
+          this.deviceInfo.type,
+      }); 
+    },
+    async handlePhotoClick(){
+       const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.send_control.admin_device_control',
+        method: 'POST',
+        data: {
+          device_type_id: this.deviceInfo.type,
+          d_id: this.deviceInfo.d_id,
+          cmd: 'takephoto',
+        },
+      });
+      if(res){
+        this.$u.toast('拍照成功')
+      }
+    },
+    handleServiceClick(){
+      uni.navigateTo({
+        url: '/pages/afterSale/addafter?d_id=' + this.deviceInfo.d_id +'&device_id='+this.deviceInfo.id + '&device_type=' + this.deviceInfo.type,
+      })
+    },
+    handleSettingClick(){
+      uni.navigateTo({
+        url: '/pages/cbd/deviceControl?deviceId=' + this.deviceInfo.id + '&d_id=' + this.deviceInfo.d_id,
+      });
+    },
+    closeOperationHandler(){
+      this.isShowOperation = false;
+    },
+    isShow(){
+      // disable == 0 或者 device_model == 15 表示不可以查看
+      if(!this.deviceInfo.d_id){
+        return false;
+      }
+      let showStatus = true;
+      if(this.deviceInfo.device_model == 15 || this.deviceInfo.device_model == 12){
+        showStatus = false;
+      }
+      if(showStatus){
+        this.activeTab = 'pestAnalysis';
+      }else{
+        this.activeTab = 'deviceData';
+      }
+      this.initAction();
+      this.isShowTab = showStatus;
+      if(this.deviceInfo.disable == 0){
+        this.disableShow = false;
+        if(showStatus){
+          this.activeTab = 'viewImage';
+          this.handleTabClick('viewImage');
+        }
+      }else{
+        this.disableShow = true;
+      }
+    },
+    prevPage(e){
+      if(e == 1){
+        return
+      }
+      this.page = e-=1;
+      this.getDeviceHistoryData();
+    },
+    nextPage(e){
+      if(e * this.page_size >= this.totalPage){
+        return
+      }
+      this.page = e+=1;
+      this.getDeviceHistoryData();
+    },
+    changeTab(pestList){
+      let pest_names = pestList.map(item => item.pest_name);
+      this.pest_names = pest_names.join(',');
+      this.initImageList();
+    },
+    confirmHandler(e){
+      this.currentYear = this.selectorRange[e].id;
+      if(this.currentYear == new Date().getFullYear()){
+        // 结束日期为this.endDate的月份和日期加上选择的年份
+        const timeDate = this.currentYear + '-' + this.endDate.split('-')[1] + '-' + this.endDate.split('-')[2];
+        this.endDate = this.formatDate(new Date(timeDate));
+        // 开始日期为结束日期前30天
+        this.startDate = this.formatDate(new Date(new Date(this.endDate).getTime() - 30 * 24 * 60 * 60 * 1000));
+        this.maxDate = this.formatDate(new Date());
+        this.minDate = this.formatDate(new Date(new Date().getFullYear(), 0, 1));
+      }else{
+        // 结束日期为this.endDate的月份和日期加上选择的年份
+        const timeDate = this.currentYear + '-' + this.endDate.split('-')[1] + '-' + this.endDate.split('-')[2];
+        this.endDate = this.formatDate(new Date(timeDate));
+        // 开始日期为结束日期前30天
+        this.startDate = this.formatDate(new Date(new Date(this.endDate).getTime() - 30 * 24 * 60 * 60 * 1000));
+        this.maxDate = this.formatDate(new Date(this.currentYear, 11, 31));
+        this.minDate = this.formatDate(new Date(this.currentYear, 0, 1));
+      }
+      this.initAction();
+      this.showPicker = false;
+    },
+    getInfo(info){
+      this.pestInfo = info;
+    },
+    // 格式化日期为YYYY-MM-DD格式,月份和日期小于10时前面加0
+    formatDate(date) {
+      const year = date.getFullYear();
+      const month = String(date.getMonth() + 1).padStart(2, '0');
+      const day = String(date.getDate()).padStart(2, '0');
+      return `${year}-${month}-${day}`;
+    },
+    handleChange(e){
+      this.startDate = e.startDate;
+      this.endDate = e.endDate;
+      this.page = 1;
+      this.initAction();
+    },
+    showCalendar(){
+      this.show = true;
+    },
+    handleBack() {
+      uni.navigateBack({
+        delta: 1
+      });
+    },
+    initAction(){
+      // this.pest_order = 
+      if(this.activeTab === 'pestAnalysis'){
+        this.getPestAnalysis();
+      }else if(this.activeTab === 'aiAnalysis'){
+        this.getAiAnalysis();
+      }else if(this.activeTab === 'viewImage'){
+        this.initPest();
+        this.initImageList();
+      }else if(this.activeTab === 'deviceData'){
+        this.getDeviceData();
+        this.getPolylineData();
+        this.getDeviceHistoryData();
+      }
+    },
+    getAiAnalysis(){
+
+    },
+    handleTabClick(tab) {
+      this.activeTab = tab;
+      this.initAction();
+    },
+    async getDeviceData(){
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.worm_lamp.device_status_data',
+        method: 'POST',
+        data: {
+          device_id: this.deviceInfo.id,
+        },
+      });
+      this.deviceStatic = res;
+    },
+    async initImageList(isLoadMore){
+      if(isLoadMore){
+        this.page++;
+      }
+      uni.showLoading({
+        title: '加载中',
+      })
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.new_cbd.photo_list',
+        method: 'POST',
+        data: {
+          page:this.page,
+          page_size:this.page_size,
+          device_id: this.deviceInfo.id,
+          is_pest: this.is_pest,
+          identify_model: 'B',
+          pest_names: this.pest_names,
+          time_begin: this.formatDate(new Date(this.startDate)) + ' 00:00:00',// 格式化开始时间YYYY-MM-DD HH:MM:SS
+          time_end: this.formatDate(new Date(this.endDate)) + ' 23:59:59',// 格式化结束时间YYYY-MM-DD HH:MM:SS
+        },
+      });
+      const data = res?.data || [];
+      uni.hideLoading();
+      this.imgTotal = res?.num || 0;
+      if (isLoadMore) {
+        this.imageList = [...this.imageList, ...data];
+      } else {
+        this.imageList = data;
+      }
+    },
+    async initPest(){
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.new_cbd.pest_type_list',
+        method: 'POST',
+        data:{
+          page:1,
+          page_size:999999,
+          device_id: this.deviceInfo.id,
+          identify_model: 'B',
+          time_begin: this.formatDate(new Date(this.startDate)) + ' 00:00:00',// 格式化开始时间YYYY-MM-DD HH:MM:SS
+          time_end: this.formatDate(new Date(this.endDate)) + ' 23:59:59',// 格式化结束时间YYYY-MM-DD HH:MM:SS
+        },
+      });
+      const data = res?.data || [];
+      this.pestList = data
+    },
+    async getPolylineData(){
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.worm_lamp.device_polyline_data',
+        method: 'POST',
+        data: {
+          device_type_id: this.deviceInfo.type_id,
+          d_id: this.deviceInfo.d_id,
+          start_time: new Date(this.startDate + ' 00:00:00').getTime()/1000,// 转成毫秒
+          end_time: new Date(this.endDate + ' 23:59:59').getTime()/1000,
+        },
+      });
+      const data = res || [];
+      this.polylineList = data
+    },
+    async getDeviceHistoryData(){
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.worm_lamp.device_history_data',
+        method: 'POST',
+        data: {
+          device_type_id: this.deviceInfo.type_id,
+          device_id: this.deviceInfo.id,
+          start_time: new Date(this.startDate + ' 00:00:00').getTime()/1000,
+          end_time: new Date(this.endDate + ' 23:59:59').getTime()/1000,
+          page: this.page,
+          page_size: this.page_size,
+        },
+      });
+      const data = res?.data || [];
+      this.totalPage = res.counts;
+      this.deviceHistoryList = data
+    },
+    async getPestAnalysis(){
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.cbd_analysis.analysis_pest_result',
+        method: 'POST',
+        data: {
+        d_id: this.deviceInfo.d_id,
+        start: this.startDate,
+        end: this.endDate,
+        model: 'B'
+        },
+      });
+      console.log(res,'resresres')
+      const pest_order = res?.pest_order;
+      this.getInfo({});
+      let total = 0;
+      this.pests = [];
+      const data = [];
+      for(let key in pest_order){
+        total += pest_order[key] || 0;
+        this.pests.push({
+          name: key,
+          percent: (pest_order[key] / total) * 100
+        })
+        data.push({
+          name: key,
+          value: pest_order[key] || 0
+        })
+      }
+      this.pest_order = pest_order;
+      this.formatePestMessage([...data.reverse()]);
+      this.day = res?.day || [];
+      const pest = res?.pest || [];
+      this.pest = pest;
+      // pest.forEach(p =>{
+      //   for(let i = 0;i< p.length;i++){
+      //     this.pest.push(p[i]);
+      //   }
+      // })
+      this.total = total;
+    }
+  }
+}
+</script>
+<style scoped lang="scss">
+::v-deep .u-calendar__action{
+  display:flex;
+  justify-content: space-between;
+}
+::v-deep .u-hover-class{
+  .u-calendar__content__item__inner{
+    color:#aaa !important;
+  }
+}
+.operation-container{
+  position: fixed;
+  right: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 99;
+}
+.operation{
+  position: fixed;
+  top: 260rpx;
+  right: 0;
+  z-index: 999;
+  width:48rpx;
+  height: 100rpx;
+  line-height: 50rpx;
+  border-radius: 8px 0 0 8px;
+  border-top: 4rpx solid #FFF;
+  border-bottom: 4rpx solid #FFF;
+  border-left: 4rpx solid #FFF;
+  background: #dddfe6a3;
+  color: #515153;
+  text-align: center;
+  font-family: "Source Han Sans CN VF";
+  font-size: 24rpx;
+  font-weight: 500;
+  writing-mode: vertical-rl;
+}
+.operation-background{
+  position: fixed;
+  right: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 998;
+  background: #00000040;
+}
+.operation-content{
+  position: fixed;
+  top: 240rpx;
+  right: 80rpx;
+  height: 126rpx;
+  display: flex;
+  padding: 16rpx 32rpx;
+  align-items: center;
+  gap: 64rpx;
+  z-index: 999;
+  border-radius: 32rpx;
+  border: 2rpx solid #FFF;
+  background: #FFF;
+  backdrop-filter: blur(8rpx);
+  .operation-item{
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    color: #333333;
+    text-align: center;
+    font-family: "Source Han Sans CN VF";
+    font-size: 24rpx;
+    font-weight: 400;
+  }
+  .operation-icon{
+    width: 58rpx;
+    height: 58rpx;
+  }
+}
+.device-detail {
+  display: flex;
+  width: 100%;
+  min-height: calc(100vh - 112rpx);
+  padding-top: 112rpx;
+  flex-direction: column;
+  align-items: center;
+  background: linear-gradient(180deg, #ffffff00 0%, #F5F6FA 23.64%, #F5F6FA 100%), linear-gradient(102deg, #BFEADD 6.77%, #B8F1E7 40.15%, #B9EEF5 84.02%);
+  .device-detail__header {
+    width: 100%;
+    font-size: 28rpx;
+    color: #999;
+    color: #042118;
+    font-family: 'Source Han Sans CN VF';
+    font-weight: 700;
+    position: relative;
+    text-align: center;
+    .arrow-left {
+      position: absolute;
+      left: 32rpx;
+      margin-right: 12rpx;
+    }
+  }
+  .device-detail__body {
+    width: calc(100% - 64rpx);
+    margin: 0 auto;
+    border-radius: 16rpx;
+    overflow-x: hidden;
+    overflow-y: auto;
+    // 隐藏滚动条
+    -ms-overflow-style: none;
+    scrollbar-width: none;
+  }
+  .tab-content{
+    width: 100%;
+    padding-bottom: 32rpx;
+  }
+  .tabs {
+    background: #ffffff;
+    margin: 24rpx 0;
+    border-radius: 16rpx;
+    padding: 16rpx 0;
+    padding-top: 0;
+    .tab-container{
+      display: flex;
+      width: 100%;
+      height: 88rpx;
+      line-height: 88rpx;
+      text-align: center;
+      font-size: 28rpx;
+      font-weight: 700;
+      color: #042118;
+      font-family: 'Source Han Sans CN VF';
+    }
+    .select-timer-container{
+      display:flex;
+      align-items: center;
+      padding-left: 32rpx;
+      .select-year{
+        width: 110rpx;
+        text-align: center;
+        height: 64rpx;
+        border-radius: 32rpx;
+        font-family: 'Source Han Sans CN VF';
+        line-height: 64rpx;
+        background: #F1F4F8;
+        padding: 0 32rpx;
+        .select-year-item{
+          color: #656565;
+          font-size: 24rpx;
+          display: flex;
+          align-items: center;
+          justify-content: space-around;
+        }
+      }
+    }
+    .tabs-timer-container{
+      display: flex;
+      align-items: center;
+      width: calc(100% - 256rpx);
+      height: 64rpx;
+      line-height: 64rpx;
+      text-align: center;
+      font-size: 24rpx;
+      font-weight: 500;
+      font-family: 'Source Han Sans CN VF';
+      border-radius: 32rpx;
+      background: #F1F4F8;
+      padding: 0 32rpx;
+      color: #656565;
+      margin: 16rpx;
+      position: relative;
+      .tabs-timer-item{
+        width: 42%;
+        color: #656565;
+        text-align: center;
+        font-family: "Source Han Sans CN VF";
+        font-size: 24rpx;
+        font-weight: 400;
+      }
+      .calendar-icon{
+        margin-left: 24rpx;
+      }
+    }
+    .tab-item {
+      flex: 1;
+      color: #999999;
+      text-align: center;
+      font-family: "Source Han Sans CN VF";
+      font-size: 28rpx;
+      font-weight: 400;
+    }
+    .active{
+      position: relative;
+      color: #303133;
+      text-align: center;
+      font-family: "Source Han Sans CN VF";
+      font-size: 28rpx;
+      font-weight: 700;
+      &::after {
+        content: '';
+        position: absolute;
+        bottom: 0;
+        left: 50%;
+        -webkit-transform: translateX(-50%);
+        transform: translateX(-50%);
+        width: 36rpx;
+        height: 36rpx;
+        border: 6rpx solid #0BBC58;
+        border-radius: 50%;
+        border-color: transparent;
+        border-bottom-color: #0BBC58;
+      }
+    }
+  }
+}
+</style>

+ 625 - 0
pages/shuHai/deviceControl.vue

@@ -0,0 +1,625 @@
+<template>
+  <view class="device-detail">
+    <view class="device-detail__header">
+      <u-icon
+        size="36"
+        class="arrow-left"
+        name="arrow-left"
+        @click="handleBack"
+      ></u-icon>
+      {{ title }}
+    </view>
+    <view class="device-detail__body">
+      <view class="tabs">
+        <view class="tab-container">
+          <view class="tab-item" v-if="myuser_type" :class="activeTab === 'pestAnalysis'?'active':''" @click="handleTabClick('pestAnalysis')">
+            管理员
+          </view>
+          <view class="tab-item" :class="activeTab === 'viewImage'?'active':''" @click="handleTabClick('viewImage')">
+            设置
+          </view>
+        </view>
+      </view>
+      <view class="device-detail-content" v-if="activeTab === 'pestAnalysis'">
+        <view class="device-detail-item">
+          <text class="device-detail-label">联网模块</text>
+          <view class="device-detail-btn-container">
+            <view class="device-detail-btn" @click="setDeviceContorl('dtu_update')">升级</view>
+            <view class="device-detail-btn" @click="setDeviceContorl('dtu_reboot')">重启</view>
+            <!-- <view class="device-detail-btn" @click="showMqttConfig">MQTT配置</view> -->
+          </view>
+        </view>
+        <view class="device-detail-item">
+          <text class="device-detail-label">板子设置</text>
+          <view class="device-detail-btn-container">
+            <!-- <view class="device-detail-btn">查看原始IMEI</view> -->
+            <view class="device-detail-btn" @click="showMqttConfig('imei')">更改IMEI</view>
+          </view>
+        </view>
+        <view class="device-detail-item">
+          <text class="device-detail-label">强制操作</text>
+          <view class="device-detail-btn-container">
+            <view class="device-detail-btn force-btn" @click="setDeviceContorl('takephoto')">拍照</view>
+            <view class="device-detail-btn force-btn" @click="setDeviceContorl('update')">升级</view>
+            <view class="device-detail-btn force-btn" @click="setDeviceContorl('reboot')">重启</view>
+            <view class="device-detail-btn force-btn" @click="setDeviceContorl('open_shake')">震动开启</view>
+            <view class="device-detail-btn force-btn" @click="setDeviceContorl('close_shake')">震动关闭</view>
+          </view>
+        </view>
+      </view>
+      <view class="device-detail-content" style="padding-bottom: 50rpx" v-if="activeTab === 'viewImage'">
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">设备开关:</text>
+          <u-switch v-model="equipContrlForm.ds" size="40" active-color="#0BBC58" active-value="1" inactive-value="0"></u-switch>
+        </view>
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">定时模式</text>
+        </view>
+        <u-radio-group v-model="equipContrlForm.ts" @change="radioGroupChange" active-color="#0BBC58">
+          <u-radio 
+            :name="0"
+          >光控
+          </u-radio>
+          <u-radio 
+            :name="1"
+          >时控
+          </u-radio>
+        </u-radio-group>
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">定时时长(h){{ equipContrlForm.tt }}</text>
+        </view>
+        <!-- <view class="slider-container">
+          <view class="slider-min-value">0</view>
+          <u-slider v-model="equipContrlForm.tt" class="slider" max="100" min="0" active-color="#0BBC58"></u-slider>
+          <view class="slider-max-value">100</view>
+        </view> -->
+        <view class="slider-container">
+          <view class="slider-min-value">1</view>
+          <view
+            class="custom-progress"
+            @touchstart.stop="onSliderTouchStart($event, 'tt', 1, 10)"
+            @touchmove.stop.prevent="onSliderTouchMove($event, 1, 10)"
+            @touchend.stop="onSliderTouchEnd"
+            @tap.stop="onSliderTap($event, 'tt', 1, 10)"
+          >
+            <view class="progress-track">
+              <view class="progress-fill" :style="{ width: getProgressWidth(equipContrlForm.tt, 1, 10) + '%' }"></view>
+              <view class="progress-thumb" :style="{ left: getProgressWidth(equipContrlForm.tt, 1, 10) + '%' }"></view>
+            </view>
+          </view>
+          <view class="slider-max-value">10</view>
+        </view>
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">落虫时间(min){{ equipContrlForm.collt }}</text>
+        </view>
+        <!-- <view class="slider-container">
+          <view class="slider-min-value">0</view>
+          <u-slider v-model="equipContrlForm.collt" class="slider" max="100" min="0" active-color="#0BBC58"></u-slider>
+          <view class="slider-max-value">100</view>
+        </view> -->
+        <view class="slider-container">
+          <view class="slider-min-value">1</view>
+          <view
+            class="custom-progress"
+            @touchstart.stop="onSliderTouchStart($event, 'collt', 1, 20)"
+            @touchmove.stop.prevent="onSliderTouchMove($event, 1, 20)"
+            @touchend.stop="onSliderTouchEnd"
+            @tap.stop="onSliderTap($event, 'collt', 1, 20)"
+          >
+            <view class="progress-track">
+              <view class="progress-fill" :style="{ width: getProgressWidth(equipContrlForm.collt, 1, 20) + '%' }"></view>
+              <view class="progress-thumb" :style="{ left: getProgressWidth(equipContrlForm.collt, 1, 20) + '%' }"></view>
+            </view>
+          </view>
+          <view class="slider-max-value">20</view>
+        </view>
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">加热时间(min){{ equipContrlForm.htim }}</text>
+        </view>
+        <!-- <view class="slider-container">
+          <view class="slider-min-value">0</view>
+          <u-slider v-model="equipContrlForm.htim" class="slider" max="100" min="0" active-color="#0BBC58"></u-slider>
+          <view class="slider-max-value">100</view>
+        </view> -->
+        <view class="slider-container">
+          <view class="slider-min-value">1</view>
+          <view
+            class="custom-progress"
+            @touchstart.stop="onSliderTouchStart($event, 'htim', 1, 20)"
+            @touchmove.stop.prevent="onSliderTouchMove($event, 1, 20)"
+            @touchend.stop="onSliderTouchEnd"
+            @tap.stop="onSliderTap($event, 'htim', 1, 20)"
+          >
+            <view class="progress-track">
+              <view class="progress-fill" :style="{ width: getProgressWidth(equipContrlForm.htim, 1, 20) + '%' }"></view>
+              <view class="progress-thumb" :style="{ left: getProgressWidth(equipContrlForm.htim, 1, 20) + '%' }"></view>
+            </view>
+          </view>
+          <view class="slider-max-value">20</view>
+        </view>
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">加热温度(℃){{ equipContrlForm.hst }}</text>
+        </view>
+        <!-- <view class="slider-container">
+          <view class="slider-min-value">0</view>
+          <u-slider v-model="equipContrlForm.hst"  class="slider" max="100" min="0" active-color="#0BBC58"></u-slider>
+          <view class="slider-max-value">100</view>
+        </view> -->
+        <view class="slider-container">
+          <view class="slider-min-value">75</view>
+          <view
+            class="custom-progress"
+            @touchstart.stop="onSliderTouchStart($event, 'hst', 75, 120)"
+            @touchmove.stop.prevent="onSliderTouchMove($event, 75, 120)"
+            @touchend.stop="onSliderTouchEnd"
+            @tap.stop="onSliderTap($event, 'hst', 75, 120)"
+          >
+            <view class="progress-track">
+              <view class="progress-fill" :style="{ width: getProgressWidth(equipContrlForm.hst, 75, 120) + '%' }"></view>
+              <view class="progress-thumb" :style="{ left: getProgressWidth(equipContrlForm.hst, 75, 120) + '%' }"></view>
+            </view>
+          </view>
+          <view class="slider-max-value">120</view>
+        </view>
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">高温保护阀值(℃){{ equipContrlForm.tph }}</text>
+        </view>
+        <!-- <view class="slider-container">
+          <view class="slider-min-value">0</view>
+          <u-slider v-model="equipContrlForm.tph" class="slider" max="100" min="0" active-color="#0BBC58"></u-slider>
+          <view class="slider-max-value">100</view>
+        </view> -->
+        <view class="slider-container">
+          <view class="slider-min-value">50</view>
+          <view
+            class="custom-progress"
+            @touchstart.stop="onSliderTouchStart($event, 'tph', 50, 70)"
+            @touchmove.stop.prevent="onSliderTouchMove($event, 50, 70)"
+            @touchend.stop="onSliderTouchEnd"
+            @tap.stop="onSliderTap($event, 'tph', 50, 70)"
+          >
+            <view class="progress-track">
+              <view class="progress-fill" :style="{ width: getProgressWidth(equipContrlForm.tph, 50, 70) + '%' }"></view>
+              <view class="progress-thumb" :style="{ left: getProgressWidth(equipContrlForm.tph, 50, 70) + '%' }"></view>
+            </view>
+          </view>
+          <view class="slider-max-value">70</view>
+        </view>
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">低温保护阀值(℃){{ equipContrlForm.tpl }}</text>
+        </view>
+        <!-- <view class="slider-container">
+          <view class="slider-min-value">0</view>
+          <u-slider v-model="equipContrlForm.tpl" class="slider" max="100" min="0" active-color="#0BBC58"></u-slider>
+          <view class="slider-max-value">100</view>
+        </view> -->
+        <view class="slider-container">
+          <view class="slider-min-value">-30</view>
+          <view
+            class="custom-progress"
+            @touchstart.stop="onSliderTouchStart($event, 'tpl', -30, 20)"
+            @touchmove.stop.prevent="onSliderTouchMove($event, -30, 20)"
+            @touchend.stop="onSliderTouchEnd"
+            @tap.stop="onSliderTap($event, 'tpl', -30, 20)"
+          >
+            <view class="progress-track">
+              <view class="progress-fill" :style="{ width: getProgressWidth(equipContrlForm.tpl, -30, 20) + '%' }"></view>
+              <view class="progress-thumb" :style="{ left: getProgressWidth(equipContrlForm.tpl, -30, 20) + '%' }"></view>
+            </view>
+          </view>
+          <view class="slider-max-value">20</view>
+        </view>
+        <view class="device-detail-viewImage">
+          <text class="device-detail-label">数据上传间隔(min){{ equipContrlForm.datt }}</text>
+        </view>
+        <!-- <view class="slider-container">
+          <view class="slider-min-value">0</view>
+          <u-slider v-model="equipContrlForm.datt" class="slider" max="100" min="0" active-color="#0BBC58"></u-slider>
+          <view class="slider-max-value">100</view>
+        </view> -->
+        <view class="slider-container">
+          <view class="slider-min-value">10</view>
+          <view
+            class="custom-progress"
+            @touchstart.stop="onSliderTouchStart($event, 'datt', 10, 60)"
+            @touchmove.stop.prevent="onSliderTouchMove($event, 10, 60)"
+            @touchend.stop="onSliderTouchEnd"
+            @tap.stop="onSliderTap($event, 'datt', 10, 60)"
+          >
+            <view class="progress-track">
+              <view class="progress-fill" :style="{ width: getProgressWidth(equipContrlForm.datt, 10, 60) + '%' }"></view>
+              <view class="progress-thumb" :style="{ left: getProgressWidth(equipContrlForm.datt, 10, 60) + '%' }"></view>
+            </view>
+          </view>
+          <view class="slider-max-value">60</view>
+        </view>
+      </view>
+    </view>
+    <view class="device-detail-btn-footer" v-if="activeTab === 'viewImage'">
+      <view class="device-detail-btn" @click="saveSettings">保存</view>
+    </view>
+    <u-popup v-model="show" mode="bottom">
+      <view class="device-detail-content" style="height: 50vh">
+        <view class="device-detail-item">
+          <text class="device-detail-label">请输入imei</text>
+          <u-input v-model="equipContrlForm.imei" placeholder="请输入imei"></u-input>
+        </view>
+      </view>
+      <view class="device-detail-btn-footer">
+        <view class="device-detail-btn" @click="setDeviceContorl('imei')">保存</view>
+      </view>
+		</u-popup>
+  </view>
+</template>
+<script>
+
+export default {
+  data(){
+    return {
+      value: 10,
+      imageList: [],
+      equipContrlForm: {},
+      time: '',
+      activeTab: 'pestAnalysis',
+      title: '设置控制',
+      myuser_type: false,
+      deviceType: '',
+      location: '',
+      d_id: '',
+      show: false,
+      checked: false
+    }
+  },
+  onLoad(options){
+    this.d_id = options.d_id
+    this.getControlDeviceConfigInfo()
+    uni.getStorage({
+      key:"myuser_type",
+      success:(res)=>{
+        if(Number(res.data) == 1){
+          this.myuser_type = true
+          this.activeTab = 'pestAnalysis'
+        }else{
+          this.activeTab = 'viewImage'
+        }
+      }
+    })
+  },
+  methods: {
+    getProgressWidth(value, min, max) {
+      if (max === min) return 0
+      return ((value - min) / (max - min)) * 100
+    },
+    onSliderTouchStart(e, field, min, max) {
+      this.sliderField = field
+      this.sliderMin = min
+      this.sliderMax = max
+      const query = uni.createSelectorQuery().in(this)
+      query.select('.custom-progress').boundingClientRect(rect => {
+        this.sliderRect = rect
+      }).exec()
+      this.updateSliderValue(e.touches[0].clientX, field, min, max)
+    },
+    onSliderTouchMove(e, min, max) {
+      if (!this.sliderField || !this.sliderRect) return
+      this.updateSliderValue(e.touches[0].clientX, this.sliderField, min, max)
+    },
+    onSliderTouchEnd() {
+      this.sliderField = ''
+    },
+    onSliderTap(e, field, min, max) {
+      const query = uni.createSelectorQuery().in(this)
+      query.select('.custom-progress').boundingClientRect(rect => {
+        this.sliderRect = rect
+        this.updateSliderValue(e.detail.x + rect.left, field, min, max)
+      }).exec()
+    },
+    updateSliderValue(clientX, field, min, max) {
+      if (!this.sliderRect) return
+      let ratio = (clientX - this.sliderRect.left) / this.sliderRect.width
+      ratio = Math.max(0, Math.min(1, ratio))
+      const value = Math.round(min + ratio * (max - min))
+      this.$set(this.equipContrlForm, field, value)
+    },
+    handleBack() {
+      uni.navigateBack({
+        delta: 1
+      });
+    },
+    handleTabClick(tab) {
+      this.activeTab = tab;
+    },
+    showMqttConfig(){
+      this.show = true;
+    },
+    closeMqttConfig(){
+      this.show = false;
+    },
+    async setDeviceContorl(type){
+      const data = {
+        device_type_id: 3,
+        d_id: this.d_id,
+        cmd: type,
+      }
+      if(type === 'imei'){
+        data.imei = this.equipContrlForm.imei
+      }else{
+        delete data.imei
+      }
+      this.$myRequest({
+        url: '/api/api_gateway?method=forecast.send_control.admin_device_control',
+        method: 'POST',
+        data
+      }).then(res => {
+        this.closeMqttConfig()
+        if (res) {
+          uni.showToast({
+            title: '设备控制修改成功!',
+            icon: 'success',
+          });
+        } else {
+          uni.showToast({
+            title: '设备控制修改失败',
+            icon: 'error',
+          });
+        }
+      });
+    },
+    saveSettings(){
+      let newForm = Object.assign({}, this.equipContrlForm) // 深拷贝
+      newForm.st = newForm.st + ''
+      newForm.et = newForm.et + ''
+      newForm.st && newForm.st.slice(0, 2).charAt(0) != '0'
+        ? newForm.st.slice(0, 2)
+        : newForm.st.slice(1, 2)
+      newForm.et =
+        newForm.et && newForm.et.slice(0, 2).charAt(0) != '0'
+          ? newForm.et.slice(0, 2)
+          : newForm.et.slice(1, 2)
+
+      for (let k in newForm) {
+        if (typeof newForm[k] === 'number') {
+          newForm[k] = newForm[k] + ''
+        }
+      }
+      newForm.st = newForm.st.replace(':00', '')
+      this.$myRequest({
+        url: '/api/api_gateway?method=forecast.send_control.device_control',
+        method: 'POST',
+        data: {
+          device_type_id: 3,
+          d_id: this.d_id,
+          config: JSON.stringify(newForm)
+        }
+      }).then(res => {
+        if (res) {
+          // 设备控制修改成功
+          uni.showToast({
+            title: '设备控制修改成功!',
+            icon: 'success',
+          });
+        } else {
+          uni.showToast({
+            title: '设备控制修改失败',
+            icon: 'error',
+          });
+        }
+      })
+    },
+    async getControlDeviceConfigInfo(){
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.send_control.device_control_info',
+        method: 'POST',
+        data: {
+          d_id: this.d_id,
+          cmd:'paramconf'
+        },
+      });
+      this.equipContrlForm = res
+    },
+  }
+}
+</script>
+<style scoped lang="scss">
+.device-detail {
+  display: flex;
+  width: 100%;
+  height: calc(100vh - 112rpx);
+  padding-top: 112rpx;
+  flex-direction: column;
+  align-items: center;
+  background: linear-gradient(180deg, #ffffff00 0%, #F5F6FA 23.64%, #F5F6FA 100%), linear-gradient(102deg, #BFEADD 6.77%, #B8F1E7 40.15%, #B9EEF5 84.02%);
+  .device-detail__header {
+    width: 100%;
+    font-size: 28rpx;
+    color: #999;
+    color: #042118;
+    font-family: 'Source Han Sans CN VF';
+    font-weight: 700;
+    position: relative;
+    text-align: center;
+    .arrow-left {
+      position: absolute;
+      left: 32rpx;
+      margin-right: 12rpx;
+    }
+  }
+  .device-detail__body {
+    width: calc(100% - 64rpx);
+    margin: 0 auto;
+    border-radius: 16rpx;
+    overflow-x: hidden;
+    overflow-y: auto;
+    // 隐藏滚动条
+    -ms-overflow-style: none;
+    scrollbar-width: none;
+  }
+  .tabs {
+    margin: 24rpx 0;
+    border-radius: 16rpx;
+    padding: 16rpx 0;
+    padding-top: 0;
+    .tab-container{
+      display: flex;
+      width: 100%;
+      height: 88rpx;
+      line-height: 88rpx;
+      text-align: center;
+      font-size: 28rpx;
+      font-weight: 700;
+      color: #042118;
+      font-family: 'Source Han Sans CN VF';
+    }
+    .tab-item {
+      margin-right: 40rpx;
+      color:#999999;
+    }
+    .active{
+      position: relative;
+      color: #303133;
+      text-align: center;
+      font-family: "Source Han Sans CN VF";
+      font-size: 28rpx;
+      font-weight: 700;
+      &::after {
+        content: '';
+        position: absolute;
+        bottom: 10rpx;
+        left: 50%;
+        transform: translateX(-50%);
+        width: 100%;
+        height: 36rpx;
+        border-bottom: 6rpx solid #303133;
+      }
+    }
+  }
+  .device-detail-content{
+    display: flex;
+    padding: 24rpx 32rpx;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 20rpx;
+    border-radius: 16rpx;
+    background: #FFF;
+    .device-detail-item{
+      .device-detail-label{
+        color: #303133;
+        font-family: "Source Han Sans CN VF";
+        font-size: 28rpx;
+        font-weight: 400;
+      }
+      .device-detail-btn-container{
+        display: flex;
+        gap: 24rpx;
+        margin-top: 12rpx;
+      }
+      .device-detail-btn{
+        display: flex;
+        padding: 10rpx 16rpx;
+        justify-content: center;
+        align-items: center;
+        gap: 16rpx;
+        border-radius: 16rpx;
+        background: #0BBC58;
+        color: #ffffff;
+        font-family: "Source Han Sans CN VF";
+        font-size: 24rpx;
+        font-weight: 400;
+      }
+      .force-btn{
+        background: #FB4E52;
+      }
+    }
+    .device-detail-viewImage{
+      width: 100%;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding-bottom: 20rpx;
+    }
+  }
+  .slider-container{
+    position: relative;
+    margin-bottom: 20rpx;
+    .slider-min-value{
+      position: absolute;
+      left: 0;
+      top: -50rpx;
+    }
+    .slider{
+      width: 600rpx;
+    }
+    .slider-max-value{
+      position: absolute;
+      right: 0;
+      top: -50rpx;
+    }
+  }
+  .custom-progress{
+    width: 600rpx;
+    padding: 0;
+    .progress-track{
+      position: relative;
+      height: 12rpx;
+      background-color: #ebedf0;
+      border-radius: 6rpx;
+    }
+    .progress-fill{
+      position: absolute;
+      left: 0;
+      top: 0;
+      height: 100%;
+      background-color: #0BBC58;
+      border-radius: 6rpx;
+      transition: width 0.2s;
+    }
+    .progress-thumb{
+      position: absolute;
+      top: 50%;
+      width: 28rpx;
+      height: 28rpx;
+      margin-left: -14rpx;
+      margin-top: -14rpx;
+      border-radius: 50%;
+      background-color: #fff;
+      box-shadow: 0 1px 4px rgba(0,0,0,0.3);
+      transition: left 0.2s;
+    }
+  }
+  .device-detail-btn-footer{
+    position: fixed;
+    bottom: 0;
+    left: 0;
+    width: 100%;
+    height: 112rpx;
+    line-height: 112rpx;
+    text-align: center;
+    font-size: 28rpx;
+    font-weight: 700;
+    color: #042118;
+    font-family: 'Source Han Sans CN VF';
+    background: #ffffff;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    .device-detail-btn{
+      display: flex;
+      height: 80rpx;
+      width: 90%;
+      margin: 0 auto;
+      padding: 0rpx 20rpx;
+      justify-content: center;
+      align-items: center;
+      gap: 8rpx;
+      border-radius: 16rpx;
+      background:#0BBC58;
+      color: #ffffff;
+      text-align: center;
+      font-family: "Source Han Sans CN VF";
+      font-size: 14px;
+      font-style: normal;
+      font-weight: 500;
+    }
+  }
+}
+</style>

+ 900 - 0
pages/shuHai/devicePhoto.vue

@@ -0,0 +1,900 @@
+<template>
+  <view class="device-photo-page">
+    <!-- 顶部导航栏 -->
+    <view class="device-detail__header">
+      <u-icon
+        size="36"
+        class="arrow-left"
+        name="arrow-left"
+        @click="handleBack"
+      ></u-icon>
+      {{ title }}
+    </view>
+
+    <!-- 日期选择器 -->
+     <view class="date-container">
+        <!-- <view class="select-year">
+          <view class="select-year-item" @click="showPicker = true">
+            {{ currentYear }}
+            <u-icon name="arrow-down" size="18" class="arrow-down"></u-icon>
+          </view>
+        </view> -->
+        <view class="date-picker" @click="show = true">
+          <view class="date-input">
+            <text class="date-label">{{ time_begin || '-' }}</text>
+          </view>
+          <!-- <view class="date-separator">-</view>
+          <view class="date-input">
+            <text class="date-label">{{ time_end || '-' }}</text>
+          </view> -->
+          <u-icon name="calendar" class="calendar"></u-icon>
+        </view>
+    </view>
+    <!-- 主图片区域 -->
+    <view class="main-photo" v-if="currentImg.addr">
+      <movable-area class="movable-area" :style="{ height: containerHeight }">
+        <movable-view
+          class="movable-view"
+          direction="all"
+          scale="true"
+          scale-min="1"
+          scale-max="5"
+        >
+          <view class="image-container">
+            <!-- 原图 -->
+            <image
+              :src="currentImg.addr"
+              class="main-photo-image"
+              mode="widthFix"
+              @load="onImageLoad"
+            />
+            <!-- 虫子标记层 -->
+            <view class="bug-markers" v-if="bugMarkers.length > 0">
+              <view
+                v-for="(marker, index) in bugMarkers"
+                :key="index"
+                class="bug-marker"
+                :style="{
+                  left: marker.x + '%',
+                  top: marker.y + '%',
+                  width: marker.width + '%',
+                  height: marker.height + '%',
+                  borderColor: marker.color
+                }"
+              >
+                <view class="bug-label" :style="{ color: marker.color }">
+                  {{ getPestName(marker.id) }}
+                </view>
+              </view>
+            </view>
+          </view>
+        </movable-view>
+      </movable-area>
+      <view class="photo-timestamp">{{ formatDate(currentImg.addtime) }}</view>
+    </view>
+
+    <!-- 缩略图预览 -->
+    <view class="thumbnail-preview">
+      <view  class="thumbnail-scroll">
+        <view 
+          class="thumbnail-item" 
+          v-for="(item, index) in thumbnails" 
+          :key="index"
+          :class="{ active: img_id == item.ids }"
+          @click="selectThumbnail(item)"
+        >
+          <image 
+            :src="item.addr" 
+            class="thumbnail-image"
+          />
+        </view>
+      </view>
+    </view>
+
+    <!-- 识别结果 -->
+    <view class="recognition-result" v-if="pestList.length">
+      <view class="result-title">当前图片识别结果</view>
+      <!-- 一类害虫 -->
+      <view>
+        <view class="pest-category" v-for="(pest,index) in pestList" :key="index">
+          <view class="category-header">
+            <text class="category-title">{{ pest[0] }}</text>
+            <text class="category-count">数量</text>
+          </view>
+          <view class="pest-item" v-for="(p, i) in pest[1]" :key="i">
+            <view class="pest-info">
+              <view class="pest-color" :style="{ backgroundColor: getPestColor(p.pest_name) }"></view>
+              <text class="pest-name">{{ p.pest_name }}</text>
+            </view>
+            <text class="pest-count">{{ p.pest_num }}</text>
+          </view>
+        </view>
+      </view>
+      <!-- <view v-else>
+        <view class="pest-category" v-for="(pest,index) in pestList" :key="index">
+          <view class="pest-item">
+            <view class="pest-info">
+              <view class="pest-color" :style="{ backgroundColor: getPestColor(pest[1].text) }"></view>
+              <text class="pest-name">{{ pest[1].text }}</text>
+            </view>
+            <text class="pest-count">{{ pest[1].value }}</text>
+          </view>
+        </view>
+      </view> -->
+    </view>
+    <u-empty v-else text="暂无数据"></u-empty>
+		<u-calendar 
+      v-model="show"
+      :mode="mode"
+      @change="handleChange"
+      range-color="#999"
+      btn-type="success"
+      active-bg-color="#0BBC58"
+      range-bg-color="rgba(11,188,88,0.13)"
+      :defaultDate="defaultDate"
+    ></u-calendar>
+		<u-picker v-model="showPicker" mode="selector" :range="selectorRange" range-key="id" :default-selector="[0]" @confirm="confirmHandler"></u-picker>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      show: false,
+      mode: 'date',
+      maxDate: this.formatTime(new Date()),
+      minDate: this.formatTime(new Date(new Date().getFullYear(), 0, 1)),
+      defaultDate: this.formatTime(new Date(new Date().getFullYear(), 0, 1)),
+      showPicker: false,
+      selectorRange: [],
+      title: '查看图片',
+      currentThumbnail: 2,
+      pestYype:{},
+      is_mark:0,
+      device_id: '',
+      time_begin: this.formatTime(new Date(new Date().getFullYear(), 0, 1)),
+      time_end: this.formatTime(new Date()),
+      img_id: '',
+      pest_list:[],
+      pestList:[],
+      colors:[
+        '#FF5951',
+        '#66EDED',
+        '#E67B3E',
+        '#6DE28B',
+        '#FFC97A',
+        '#E7EB4B',
+        '#1561F3',
+        '#FA73F5',
+        '#159AFF',
+        '#FA73F5'
+      ],
+      currentYear:'',
+      pest_list_arr:[],
+      thumbnails: [],
+      addtime:'',
+      currentImg:{},
+      bugMarkers: [],
+      imageWidth: 0,
+      imageHeight: 0,
+      imgOld_id:'',
+      containerHeight: '300px',
+      pestDict: {}, // 虫子字典 {id: name}
+      pestColorMap: {}, // 害虫颜色映射 {pestName: color}
+    };
+  },
+  async onLoad(options) {
+    const {id,img_id,currentYear,addtime} = options
+    this.device_id = id
+    this.imgOld_id = img_id
+    this.currentYear = currentYear
+    this.addtime = this.formatTime(Number(addtime)*1000);
+    this.time_begin = this.addtime;
+    this.defaultDate = this.addtime;
+    this.time_end = this.addtime;
+    this.selectorRange = [];
+    const nowYear = new Date().getFullYear();
+    for(let i = 0;i<50;i++){
+      const item = {
+        label: nowYear - i,
+        id: nowYear - i
+      }
+      this.selectorRange.push(item);
+    }
+    this.pestList = [];
+    await this.getPestDict();
+    await this.getPestLevelMap();
+    await this.getPestList();
+    // this.getDevicePhotoDetails();
+  },
+  methods: {
+    getPestName(id){
+      if(this.is_mark == 0){
+        return this.pestDict[id] || id;
+      }
+      return id;
+    },
+    async confirmHandler(e){
+      this.currentYear = this.selectorRange[e].id;
+      if(this.currentYear == new Date().getFullYear()){
+        this.time_begin = this.formatTime(new Date(new Date().getFullYear(), 0, 1));
+        this.time_end = this.formatTime(new Date());
+        this.maxDate = this.formatTime(new Date());
+        this.minDate = this.formatTime(new Date(new Date().getFullYear(), 0, 1));
+      }else{
+        this.time_begin = this.formatTime(new Date(this.currentYear, 0, 1));
+        this.time_end = this.formatTime(new Date(this.currentYear, 11, 31));
+        this.maxDate = this.formatTime(new Date(this.currentYear, 11, 31));
+        this.minDate = this.formatTime(new Date(this.currentYear, 0, 1));
+        this.defaultDate = this.minDate;
+      }
+      this.showPicker = false;
+      this.pestList = [];
+      this.thumbnails = [];
+      await this.getPestList();
+    },
+    async handleChange(e){
+      console.log(e,'e')
+      this.time_begin = e.result;
+      this.time_end = e.result;
+      this.pestList = [];
+      await this.getPestList()
+      this.currentImg = {};
+    },
+    getPestColor(pestName) {
+      // 如果该害虫已有颜色,直接返回
+      if (this.pestColorMap[pestName]) {
+        return this.pestColorMap[pestName];
+      }
+      // 为新害虫分配颜色
+      const usedColors = Object.values(this.pestColorMap);
+      // 找一个未使用的颜色
+      let color;
+      for (const c of this.colors) {
+        if (!usedColors.includes(c)) {
+          color = c;
+          break;
+        }
+      }
+      // 如果所有颜色都用完了,使用哈希值确定颜色
+      if (!color) {
+        const hash = pestName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
+        color = this.colors[hash % this.colors.length];
+      }
+      this.pestColorMap[pestName] = color;
+      return color;
+    },
+     getLevelDisplayName(level) {
+      const levelMap = {
+        '-1': '其他害虫',
+        1: '一类害虫',
+        2: '二类害虫',
+        3: '三类害虫',
+        4: '四类害虫',
+        5: '五类害虫'
+      };
+      return levelMap[String(level)] || `${level}`;
+    },
+    async getPestList() {
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.new_cbd.photo_list',
+        method: 'POST',
+        data: {
+          identify_model:'B',
+          cmd:'cbd',
+          page:1,
+          page_size:500,
+          time_begin: this.time_begin + ' 00:00:00',
+          time_end: this.time_end + ' 23:59:59',
+          identify_model:'B',
+          device_id: this.device_id,
+        },
+      });
+      this.thumbnails = res?.data || [];
+      this.currentImg = {};
+      //如果和旧的id相同,就用旧的id不然就用第一个
+      if(this.thumbnails.find(item => item.ids == this.imgOld_id)){
+        this.img_id = this.imgOld_id
+      }else{
+        this.img_id = this.thumbnails[0].ids
+      }
+      this.getDevicePhotoDetails();
+      // if(this.img_id){
+      //   this.currentImg = this.thumbnails.find(item => item.ids == this.img_id) ||  this.thumbnails[0] || {}
+      // }else{
+      //   this.currentImg = this.thumbnails[0] || {}
+      // }
+    },
+    formatTime(dateString) {
+      const date = new Date(dateString);
+      const year = date.getFullYear();
+      const month = String(date.getMonth() + 1).padStart(2, '0');
+      const day = String(date.getDate()).padStart(2, '0');
+      return `${year}-${month}-${day}`;
+    },
+    async getPestLevelMap() {
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.new_cbd.pest_level_map',
+        method: 'POST',
+      });
+      const pestYype = {}
+      for(let key in res){
+        pestYype[this.getLevelDisplayName(key)] = res[key]
+      }
+      this.pestYype = pestYype
+    },
+    // 获取虫子字典
+    async getPestDict() {
+      try {
+        const res = await this.$myRequest({
+          url: '/api/api_gateway?method=forecast.pest_info.pest_dict',
+          method: 'POST',
+          data:{
+            type_name: '1'
+          }
+        });
+        this.pestDict = res || {};
+      } catch (error) {
+        console.error('获取虫子字典失败:', error);
+        this.pestDict = {};
+      }
+    },
+    formatDate(dateString) {
+      const date = new Date(dateString*1000);
+      const year = date.getFullYear();
+      const month = String(date.getMonth() + 1).padStart(2, '0');
+      const day = String(date.getDate()).padStart(2, '0');
+      const hour = String(date.getHours()).padStart(2, '0');
+      const minute = String(date.getMinutes()).padStart(2, '0');
+      const second = String(date.getSeconds()).padStart(2, '0');
+      return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
+    },
+    async getDevicePhotoDetails() {
+      const res = await this.$myRequest({
+        url: '/api/api_gateway?method=forecast.forecast_system.device_photo_details',
+        method: 'POST',
+        data: {
+          identify_model:'B',
+          cmd:'cbd',
+          img_id: this.img_id,
+        },
+      });
+      this.currentImg = res;
+      // 处理label参数生成bugMarkers
+      this.processBugMarkers();
+      const pest_list = res.pest_list
+      const pestArr = new Map()
+      const pesAlltList = []
+      for(let key in this.pestYype){
+        for(let j in this.pestYype[key]){
+          const pestName = this.pestYype[key][j]
+          pesAlltList.push(pestName)
+          pest_list.forEach(item => {
+            if(item.pest_name == pestName){
+              if(pestArr.has(key)){
+                pestArr.get(key).push(item)
+              }else{
+                pestArr.set(key, [item])
+              }
+            }
+          })
+        }
+      }
+      pest_list.forEach(item => {
+        if(!pesAlltList.includes(item.pest_name)){
+          if(pestArr.has('其他害虫')){
+            pestArr.get('其他害虫').push(item)
+          }else{
+            pestArr.set('其他害虫', [item])
+          }
+        }
+      })
+      const pestList = []
+      for(let key of pestArr){
+        pestList.push(key)
+      }
+      if (this.currentImg.is_mark != 0) {
+        this.processBugMarkers();
+      }
+      this.pestList = pestList
+    },
+    onImageLoad(e) {
+      this.imageWidth = e.detail.width;
+      this.imageHeight = e.detail.height;
+
+      // 计算容器显示高度(根据屏幕宽度和图片宽高比)
+      const systemInfo = uni.getSystemInfoSync();
+      const screenWidth = systemInfo.windowWidth;
+      const containerWidth = screenWidth - 24; // 减去左右margin (24rpx ≈ 12px)
+      const displayHeight = (containerWidth / this.imageWidth) * this.imageHeight;
+
+      this.containerHeight = displayHeight + 'px';
+      console.log('图片尺寸:', this.imageWidth, this.imageHeight, '容器高度:', this.containerHeight);
+
+      // 图片加载完成后处理bugMarkers
+      this.processBugMarkers();
+    },
+    
+    processBugMarkers() {
+      if (this.currentImg.is_mark === 0) {
+        if (!this.currentImg.label) {
+          console.log('没有label参数');
+          this.bugMarkers = [];
+          return;
+        }
+
+        // 如果没有图片尺寸,先设置默认值
+        if (!this.imageWidth || !this.imageHeight) {
+          console.log('没有图片尺寸,使用默认值');
+          this.imageWidth = 1000;
+          this.imageHeight = 1000;
+        }
+
+        try {
+          // 根据原图宽度计算缩放比例
+          let scaleRatio;
+          if (this.imageWidth >= 5000) {
+            scaleRatio = 1;
+          } else if (this.imageWidth >= 4000) {
+            scaleRatio = 1;
+          } else {
+            scaleRatio = 1;
+          }
+          // 处理label参数,将单引号替换为双引号
+          let labelStr = this.currentImg.label;
+          labelStr = labelStr.replace(/'/g, '"');
+          console.log(labelStr,'labelStrlabelStr')
+          const label = JSON.parse(labelStr);
+          const markers = [];
+
+          console.log('原图宽度:', this.imageWidth, '缩放比例:', scaleRatio);
+
+          label.forEach(item => {
+            for (let key in item) {
+              const [x1, y1, x2, y2] = item[key];
+              // 使用缩放比例计算标记位置和尺寸
+              const x = (x1 * scaleRatio / this.imageWidth) * 100;
+              const y = (y1 * scaleRatio / this.imageHeight) * 100;
+              const width = ((x2 - x1) * scaleRatio / this.imageWidth) * 100;
+              const height = ((y2 - y1) * scaleRatio / this.imageHeight) * 100;
+
+              // 从字典中获取虫子名字(尝试字符串和数字两种类型)
+              const pestName = this.pestDict[key] || this.pestDict[String(key)] || key;
+              const color = this.getPestColor(pestName);
+
+              markers.push({
+                id: pestName, // 显示虫子名字
+                x,
+                y,
+                width,
+                height,
+                color
+              });
+            }
+          });
+          this.bugMarkers = markers;
+        } catch (error) {
+          console.error('处理label参数失败:', error);
+          this.bugMarkers = [];
+        }
+      } else {
+        // this.pestList = [];
+        const markers = [];
+        // 根据原图宽度计算缩放比例
+        let scaleRatio = 3.1;
+        // if (this.imageWidth >= 5000) {
+        //   scaleRatio = 4;
+        // } else if (this.imageWidth >= 4000) {
+        //   scaleRatio = 1;
+        // } else {
+        //   scaleRatio = 2.5;
+        // }
+        // const pestArr = new Map()
+        this.currentImg.mark.map((item, index) => {
+          // if(pestArr.has(item.text)){
+          //   pestArr.set(item.text,{
+          //     value: pestArr.get(item.text).value+=1,
+          //     text: item.text
+          //   })
+          // }else{
+          //   pestArr.set(item.text, {
+          //     value: 1,
+          //     text: item.text
+          //   })
+          // }
+          // const pestList = []
+          // for(let key of pestArr){
+          //   pestList.push(key)
+          // }
+          // this.pestList = pestList
+          this.is_mark = 1
+          const { startX, startY,text } = item;
+          // 使用缩放比例计算标记位置和尺寸
+          const x = item.width > 0 ? (startX * scaleRatio / this.imageWidth) * 105 : ((startX + item.width) * scaleRatio / this.imageWidth) * 105;
+          const y = item.height > 0 ? (startY * scaleRatio / this.imageHeight) * 105 : ((startY + item.height) * scaleRatio / this.imageHeight) * 105;
+          const width = (item.width > 0 ? item.width : -item.width) * scaleRatio / this.imageWidth * 100;
+          const height = (item.height > 0 ? item.height : -item.height) * scaleRatio / this.imageHeight * 100;
+
+          // 从字典中获取虫子名字(尝试字符串和数字两种类型)
+          const pestName = text;
+          const color = this.getPestColor(text);
+
+          markers.push({
+            id: pestName, // 显示虫子名字
+            x,
+            y,
+            width,
+            height,
+            color
+          });
+        });
+        this.bugMarkers = markers;
+      }
+    },
+    handleBack() {
+      uni.navigateBack({
+        delta: 1
+      });
+    },
+    goBack() {
+      uni.navigateBack();
+    },
+    selectThumbnail(item) {
+      this.img_id = item.ids;
+      this.getDevicePhotoDetails();
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+::v-deep .u-calendar__action{
+  display:flex;
+  justify-content: space-between;
+}
+::v-deep .u-hover-class{
+  .u-calendar__content__item__inner{
+    color:#aaa !important;
+  }
+}
+.device-photo-page {
+  background-color: #F5F5F5;
+  min-height: 100vh;
+  width: 100%;
+  padding-top: 112rpx;
+  background: linear-gradient(180deg, #ffffff00 0%, #F5F6FA 23.64%, #F5F6FA 100%), linear-gradient(102deg, #BFEADD 6.77%, #B8F1E7 40.15%, #B9EEF5 84.02%);
+  .device-detail__header {
+    width: 100%;
+    font-size: 28rpx;
+    color: #999;
+    color: #042118;
+    font-family: 'Source Han Sans CN VF';
+    font-weight: 700;
+    position: relative;
+    text-align: center;
+    .arrow-left {
+      position: absolute;
+      left: 32rpx;
+      margin-right: 12rpx;
+    }
+  }
+
+  /* 顶部导航栏 */
+  .nav-bar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 24rpx;
+    background-color: #E6F7EF;
+
+    .nav-left {
+      .back-icon {
+        font-size: 36rpx;
+        color: #042118;
+      }
+    }
+
+    .nav-title {
+      font-size: 32rpx;
+      font-weight: 700;
+      color: #042118;
+    }
+
+    .nav-right {
+      display: flex;
+      align-items: center;
+
+      .more-icon {
+        font-size: 32rpx;
+        color: #042118;
+        margin-right: 24rpx;
+      }
+
+      .eye-icon {
+        font-size: 32rpx;
+      }
+    }
+  }
+  .date-container{
+    padding: 0 20rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .select-year{
+      width: 150rpx;
+      height: 80rpx;
+      display: flex;
+      align-items: center;
+      justify-content: space-around;
+      color: #656565;
+      font-size: 26rpx;
+      line-height: 80rpx;
+      text-align: center;
+      border-radius: 48rpx;
+      background-color: #FFFFFF;
+      margin-right: 10rpx;
+      padding: 0 26rpx;
+      .select-year-item{
+        width: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: space-around;
+      }
+    }
+  }
+  /* 日期选择器 */
+  .date-picker {
+    display: flex;
+    align-items: center;
+    padding: 20rpx 24rpx;
+    background-color: #FFFFFF;
+    margin-bottom: 24rpx;
+    width: 100%;
+    margin:20rpx auto;
+    border-radius: 48rpx;
+    position: relative;
+    .date-input {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+      text-align: center;
+      .date-label {
+        font-size: 24rpx;
+        color: #999999;
+        margin-bottom: 8rpx;
+      }
+
+      .date-value {
+        font-size: 28rpx;
+        color: #042118;
+        font-weight: 500;
+      }
+    }
+    .calendar {
+      position: absolute;
+      right: 24rpx;
+      top: 50%;
+      transform: translateY(-50%);
+      font-size: 32rpx;
+      color: #999999;
+    }
+    .date-separator {
+      margin: 0 24rpx;
+      font-size: 28rpx;
+      color: #999999;
+    }
+  }
+
+  /* 主图片区域 */
+  .main-photo {
+    position: relative;
+    margin: 0 24rpx 24rpx;
+    background-color: #FFFFFF;
+    border-radius: 16rpx;
+    padding: 0;
+    overflow: hidden;
+    .movable-area {
+      width: 100%;
+      overflow: hidden;
+    }
+
+    .movable-view {
+      width: 100%;
+    }
+
+    .image-container {
+      position: relative;
+      width: 100%;
+    }
+
+    .main-photo-image {
+      width: 100%;
+      display: block;
+    }
+
+    /* 虫子标记层 */
+    .bug-markers {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      pointer-events: none;
+    }
+
+    /* 虫子标记 */
+    .bug-marker {
+      position: absolute;
+      border: 2rpx solid;
+      border-radius: 4rpx;
+    }
+
+    /* 虫子标记标签 */
+    .bug-label {
+      position: absolute;
+      top: -24rpx;
+      left: 0;
+      padding: 2rpx 6rpx;
+      border-radius: 4rpx;
+      font-size: 20rpx;
+      color: white;
+      white-space: nowrap;
+    }
+
+    /* 虫子标记层 */
+    .bug-markers {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      pointer-events: none;
+    }
+
+    /* 虫子标记 */
+    .bug-marker {
+      position: absolute;
+      border: 2rpx solid;
+      border-radius: 4rpx;
+      pointer-events: auto;
+    }
+
+    /* 虫子标记标签 */
+    .bug-label {
+      position: absolute;
+      top: -30rpx;
+      left: 0;
+      padding: 4rpx 8rpx;
+      border-radius: 4rpx;
+      font-size: 20rpx;
+      font-weight: bold;
+    }
+
+    .photo-timestamp {
+      position: absolute;
+      bottom: 0rpx;
+      left: 0rpx;
+      width:100%;
+      background-color: rgba(0, 0, 0, 0.6);
+      color: #FFFFFF;
+      padding: 8rpx 16rpx;
+      border-radius: 8rpx;
+      font-size: 24rpx;
+    }
+  }
+
+  /* 缩略图预览 */
+  .thumbnail-preview {
+    margin: 0 24rpx 24rpx;
+
+    .thumbnail-scroll {
+      gap: 16rpx;
+      padding-bottom: 16rpx;
+      box-sizing: border-box;
+      white-space: nowrap;
+      overflow-x: auto;
+      overflow-y: hidden;
+      width: 100%;
+      // 隐藏滚动条
+      -ms-overflow-style: none;
+      scrollbar-width: none;
+      .thumbnail-item {
+        width: 120rpx;
+        height: 120rpx;
+        border-radius: 8rpx;
+        overflow: hidden;
+        border: 2rpx solid transparent;
+        display: inline-block;
+        box-sizing: border-box;
+        margin-right: 10rpx;
+        &.active {
+          border-color: #0BBC58;
+        }
+
+        .thumbnail-image {
+          width: 100%;
+          height: 100%;
+          object-fit: cover;
+        }
+      }
+    }
+  }
+
+  /* 识别结果 */
+  .recognition-result {
+    margin: 0 24rpx 24rpx;
+    background-color: #FFFFFF;
+    border-radius: 16rpx;
+    padding: 24rpx;
+
+    .result-title {
+      font-size: 28rpx;
+      font-weight: 700;
+      color: #042118;
+      margin-bottom: 24rpx;
+    }
+
+    /* 害虫类别 */
+    .pest-category {
+      margin-bottom: 32rpx;
+      border: 1px solid #E4E7ED;
+      border-radius: 16rpx;
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      .category-header {
+        display: flex;
+        justify-content: space-between;
+        margin-bottom: 16rpx;
+        background: #F6F8FC;
+        padding: 18rpx 24rpx;
+        .category-title {
+          font-size: 24rpx;
+          font-weight: 500;
+          color: #042118;
+        }
+
+        .category-count {
+          font-size: 24rpx;
+          color: #999999;
+        }
+      }
+
+      .pest-item {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        margin-bottom: 12rpx;
+        padding: 6rpx 24rpx;
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        .pest-info {
+          display: flex;
+          align-items: center;
+
+          .pest-color {
+            width: 16rpx;
+            height: 16rpx;
+            border-radius: 50%;
+            margin-right: 12rpx;
+          }
+
+          .pest-name {
+            font-size: 24rpx;
+            color: #042118;
+          }
+        }
+
+        .pest-count {
+          font-size: 24rpx;
+          color: #042118;
+        }
+      }
+    }
+  }
+}
+</style>

+ 142 - 0
util/coordtransform_utils.js

@@ -0,0 +1,142 @@
+/**
+ * Created by Wandergis on 2015/7/8.
+ * 提供了百度坐标(BD09)、国测局坐标(火星坐标,GCJ02)、和WGS84坐标系之间的转换
+ */
+//定义一些常量
+var x_PI = 3.14159265358979324 * 3000.0 / 180.0;
+var PI = 3.1415926535897932384626;
+var a = 6378245.0;
+var ee = 0.00669342162296594323;
+/**
+ * 百度坐标系 (BD-09) 与 火星坐标系 (GCJ-02)的转换
+ * 即 百度 转 谷歌、高德
+ * @param bd_lon
+ * @param bd_lat
+ * @returns {*[]}
+ */
+var bd09togcj02 = function bd09togcj02(bd_lon, bd_lat) {
+  var bd_lon = +bd_lon;
+  var bd_lat = +bd_lat;
+  var x = bd_lon - 0.0065;
+  var y = bd_lat - 0.006;
+  var z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * x_PI);
+  var theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * x_PI);
+  var gg_lng = z * Math.cos(theta);
+  var gg_lat = z * Math.sin(theta);
+  return [gg_lng, gg_lat]
+};
+
+/**
+ * 火星坐标系 (GCJ-02) 与百度坐标系 (BD-09) 的转换
+ * 即谷歌、高德 转 百度
+ * @param lng
+ * @param lat
+ * @returns {*[]}
+ */
+var gcj02tobd09 = function gcj02tobd09(lng, lat) {
+  var lat = +lat;
+  var lng = +lng;
+  var z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * x_PI);
+  var theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * x_PI);
+  var bd_lng = z * Math.cos(theta) + 0.0065;
+  var bd_lat = z * Math.sin(theta) + 0.006;
+  return [bd_lng, bd_lat]
+};
+
+/**
+ * WGS84转GCj02
+ * @param lng
+ * @param lat
+ * @returns {*[]}
+ */
+var wgs84togcj02 = function wgs84togcj02(lng, lat) {
+  var lat = +lat;
+  var lng = +lng;
+  if (out_of_china(lng, lat)) {
+    return [lng, lat]
+  } else {
+    var dlat = transformlat(lng - 105.0, lat - 35.0);
+    var dlng = transformlng(lng - 105.0, lat - 35.0);
+    var radlat = lat / 180.0 * PI;
+    var magic = Math.sin(radlat);
+    magic = 1 - ee * magic * magic;
+    var sqrtmagic = Math.sqrt(magic);
+    dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * PI);
+    dlng = (dlng * 180.0) / (a / sqrtmagic * Math.cos(radlat) * PI);
+    var mglat = lat + dlat;
+    var mglng = lng + dlng;
+    return [mglng, mglat]
+  }
+};
+
+/**
+ * GCJ02 转换为 WGS84
+ * @param lng
+ * @param lat
+ * @returns {*[]}
+ */
+var gcj02towgs84 = function gcj02towgs84(lng, lat) {
+  var lat = +lat;
+  var lng = +lng;
+  if (out_of_china(lng, lat)) {
+    return [lng, lat]
+  } else {
+    var dlat = transformlat(lng - 105.0, lat - 35.0);
+    var dlng = transformlng(lng - 105.0, lat - 35.0);
+    var radlat = lat / 180.0 * PI;
+    var magic = Math.sin(radlat);
+    magic = 1 - ee * magic * magic;
+    var sqrtmagic = Math.sqrt(magic);
+    dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * PI);
+    dlng = (dlng * 180.0) / (a / sqrtmagic * Math.cos(radlat) * PI);
+    var mglat = lat + dlat;
+    var mglng = lng + dlng;
+    return [lng * 2 - mglng, lat * 2 - mglat]
+  }
+};
+
+var transformlat = function transformlat(lng, lat) {
+  var lat = +lat;
+  var lng = +lng;
+  var ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng));
+  ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
+  ret += (20.0 * Math.sin(lat * PI) + 40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0;
+  ret += (160.0 * Math.sin(lat / 12.0 * PI) + 320 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0;
+  return ret
+};
+
+var transformlng = function transformlng(lng, lat) {
+  var lat = +lat;
+  var lng = +lng;
+  var ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng));
+  ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
+  ret += (20.0 * Math.sin(lng * PI) + 40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0;
+  ret += (150.0 * Math.sin(lng / 12.0 * PI) + 300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0;
+  return ret
+};
+
+/**
+ * 判断是否在国内,不在国内则不做偏移
+ * @param lng
+ * @param lat
+ * @returns {boolean}
+ */
+var out_of_china = function out_of_china(lng, lat) {
+  var lat = +lat;
+  var lng = +lng;
+  // 纬度3.86~53.55,经度73.66~135.05 
+  return !(lng > 73.66 && lng < 135.05 && lat > 3.86 && lat < 53.55);
+};
+
+// return {
+//   bd09togcj02: bd09togcj02,   //百度 转 谷歌、高德
+//   gcj02tobd09: gcj02tobd09,   //高德 转 百度
+//   wgs84togcj02: wgs84togcj02, //国际 转 国测
+//   gcj02towgs84: gcj02towgs84  //国测 转 国际
+// }
+export {
+  bd09togcj02,
+  gcj02tobd09,
+  wgs84togcj02,
+  gcj02towgs84
+}