当前位置: 首页 > news >正文

数字果园管理系统的设计与实现(Tensorflow的害虫识别结合高德API的害虫定位与Websocket的在线聊天室)

文章目录

    • 技术栈
    • 主要功能
    • 害虫识别与定位
      • 害虫识别的实现
        • 训练与测试评估代码
        • 模型转化为TFLite
        • 预测脚本
        • PredictController预测控制器
        • 害虫识别过程展示
      • 害虫定位实现
        • 害虫定位代码
        • 害虫定位过程展示
    • 专家咨询功能
      • 在线咨询聊天室
        • 主要前端代码如下
        • 主要后端代码如下

技术栈

  • Spring Boot
  • Vue3
  • MyBatis
  • ECharts
  • Tensorflow
  • ElementUI

主要功能

  1. 害虫识别与定位功能:利用Tensorflow深度学习框架,在预训练模型MobileNetV2的基础上进行训练优化,对训练集9840张,测试集2513张害虫图片进行训练,每种害虫大体上按照4:1的比例划分训练集和测试集,并生成可直接部署的模型文件并编写预测脚本。利用高德API对每种害虫出现地点进行标记定位并提供相关定位功能(步行、驾车、害虫定位、当前定位等)。
  2. 专家咨询功能:利用Websocket提供在线咨询,通过聊天室进行在线聊天并对每一次咨询进行系统自动记录。同时提供预约咨询功能(主要预约线下),提供咨询反馈,咨询者可以对每次咨询记录进行评星、反馈等。评星将直接影响到专家个人的评星。

害虫识别与定位

害虫识别的实现

因为害虫图片数据集有限,且在计算资源、硬件等方面受限,在训练上可能有失准确率,最终选择的版本(本系统使用的害虫识别模型)训练准确率:96.15%;验证准确率:90.55%;测试Top-1:78.90%;测试Top-3:93.87%;测试Top-5:96.78%。以下是多版本训练模型的表格:

模型增加的处理步骤训练准确率验证准确率测试Top-1测试Top-3测试Top-5
BaseMobileNetV2基础模型94.74%86.38%76.47%93.19%96.10%
MobileNetV2_V1-添加Dropout -规范验证集划分83.88%84.45%74.96%92.56%95.86%
MobileNetV2_V2-增强数据增强 -添加L2正则化 -分层Dropout -两阶段微调95.34%88.11%79.10%92.68%96.14%
MobileNetV2_V3-学习率调度可视化 -延长微调周期至70轮95.40%88.36%77.67%93.99%96.82%
本模型-AdamW优化器​ -余弦退火学习率 -梯度裁剪 -适度数据增强96.15%90.55%78.90%93.87%96.78%
训练与测试评估代码
import tensorflow as tf
from tensorflow.keras import layers, models, optimizers, regularizers
from tensorflow.keras.models import Sequential
from tensorflow.keras.callbacks import EarlyStopping, TensorBoard, LearningRateScheduler, ModelCheckpoint
import os
import json
import numpy as np
from sklearn.utils.class_weight import compute_class_weight# ======================
# 配置参数
# ======================
IMG_SIZE = (224, 224)
BATCH_SIZE = 64
EPOCHS = 100
NUM_CLASSES = len(os.listdir('train'))
LEARNING_RATE = 1e-4
FINE_TUNE_LR = 1e-6
SAVE_PATH = 'pestModel_MobileNetV2_V4'
REGULARIZATION = 1e-3
DROPOUT_RATE = 0.5# ======================
# 数据准备(新增部分)
# ======================
def prepare_datasets():# 加载原始数据集以获取class_namesraw_train = tf.keras.utils.image_dataset_from_directory('train',image_size=IMG_SIZE,batch_size=BATCH_SIZE,validation_split=0.2,subset='training',seed=123)class_names = raw_train.class_names  # 先获取类别名称# 训练集和验证集train_dataset = tf.keras.utils.image_dataset_from_directory('train',image_size=IMG_SIZE,batch_size=BATCH_SIZE,label_mode='categorical',validation_split=0.2,subset='training',seed=123).map(lambda x, y: (x/255.0, y))  # 归一化validation_dataset = tf.keras.utils.image_dataset_from_directory('train',image_size=IMG_SIZE,batch_size=BATCH_SIZE,label_mode='categorical',validation_split=0.2,subset='validation',seed=123).map(lambda x, y: (x/255.0, y))# 测试集test_dataset = tf.keras.utils.image_dataset_from_directory('test',image_size=IMG_SIZE,batch_size=BATCH_SIZE,label_mode='categorical').map(lambda x, y: (x/255.0, y))return train_dataset, validation_dataset, test_dataset, class_names# ======================
# 数据增强(调整参数以减少过度扰动)
# ======================
data_augmentation = Sequential([layers.RandomFlip("horizontal", seed=42),  # 仅水平翻转,保留语义信息layers.RandomRotation(factor=0.1, fill_mode='reflect'),  # 旋转角度±10%layers.RandomZoom(height_factor=(-0.05, 0.05)),  # 缩小缩放幅度layers.RandomContrast(factor=0.05),  # 降低对比度扰动强度
])# ======================
# 学习率调度(余弦退火)
# ======================
def lr_scheduler(epoch):warmup_epochs = 5total_epochs = 50  # 总训练周期if epoch < warmup_epochs:return LEARNING_RATE * (epoch + 1) / warmup_epochsprogress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs)return LEARNING_RATE * 0.5 * (1 + tf.math.cos(np.pi * progress))# ======================
# 模型构建(优化分类头结构)
# ======================
def build_enhanced_model():base_model = tf.keras.applications.MobileNetV2(input_shape=(*IMG_SIZE, 3),include_top=False,weights='imagenet')base_model.trainable = Falseinputs = tf.keras.Input(shape=(*IMG_SIZE, 3))x = data_augmentation(inputs)x = base_model(x)x = layers.GlobalAveragePooling2D()(x)# 优化分类头(增加层间批标准化)x = layers.Dense(1024, kernel_regularizer=regularizers.l2(1e-4))(x)  # 降低L2系数x = layers.BatchNormalization()(x)x = layers.Activation('relu')(x)x = layers.Dropout(0.4)(x)  # 降低Dropout率x = layers.Dense(512, kernel_regularizer=regularizers.l2(1e-4))(x)x = layers.BatchNormalization()(x)x = layers.Activation('relu')(x)x = layers.Dropout(0.3)(x)  # 分层设置Dropoutoutputs = layers.Dense(NUM_CLASSES, activation='softmax',kernel_regularizer=regularizers.l2(1e-4))(x)model = tf.keras.Model(inputs, outputs)# 使用AdamW优化器(提升泛化能力)model.compile(optimizer=optimizers.AdamW(learning_rate=LEARNING_RATE, weight_decay=1e-5),loss=tf.keras.losses.CategoricalCrossentropy(label_smoothing=0.05),  # 调整标签平滑metrics=['accuracy',tf.keras.metrics.TopKCategoricalAccuracy(k=3, name='top3_acc'),tf.keras.metrics.TopKCategoricalAccuracy(k=5, name='top5_acc')])return model# ======================
# 主程序流程
# ======================
if __name__ == "__main__":# 准备数据train_dataset, validation_dataset, test_dataset, class_names = prepare_datasets()  # 添加class_names接收# 计算类别权重y_train = np.concatenate([y for x, y in train_dataset], axis=0)class_weights = compute_class_weight('balanced',classes=np.arange(NUM_CLASSES),y=np.argmax(y_train, axis=1))class_weights = dict(enumerate(class_weights))# ======================# 回调配置(增加梯度裁剪与早停监控Top-3)# ======================callbacks = [EarlyStopping(monitor='val_top3_acc', patience=12, mode='max', restore_best_weights=True),ModelCheckpoint('best_model.h5', monitor='val_top3_acc', mode='max', save_best_only=True),LearningRateScheduler(lr_scheduler),TensorBoard(log_dir='./logs'),tf.keras.callbacks.TerminateOnNaN(),  # 防止数值不稳定]# 初始训练model = build_enhanced_model()history = model.fit(train_dataset,epochs=EPOCHS,validation_data=validation_dataset,class_weight=class_weights,callbacks=callbacks,verbose=2)# 微调策略(分阶段解冻更多层)# ======================# 初始训练后执行def unfreeze_layers(model, unfreeze_ratio=0.5):base_model = model.layers[2]num_layers = len(base_model.layers)unfreeze_from = int(num_layers * (1 - unfreeze_ratio))for layer in base_model.layers[:unfreeze_from]:layer.trainable = Falsefor layer in base_model.layers[unfreeze_from:]:layer.trainable = True# 微调阶段model.layers[2].trainable = Trueunfreeze_layers(model, unfreeze_ratio=0.5)  # 解冻后50%的层model.compile(optimizer=optimizers.AdamW(learning_rate=FINE_TUNE_LR, weight_decay=1e-5),loss=tf.keras.losses.CategoricalCrossentropy(label_smoothing=0.02),  # 减少微调阶段的标签平滑metrics= ['accuracy',tf.keras.metrics.TopKCategoricalAccuracy(k=3, name='top3_acc'),tf.keras.metrics.TopKCategoricalAccuracy(k=5, name='top5_acc')])history_fine = model.fit(train_dataset,epochs=EPOCHS + 20,initial_epoch=history.epoch[-1],validation_data=validation_dataset,class_weight=class_weights,callbacks=callbacks,verbose=2)# 梯度裁剪(提升稳定性)tf.keras.backend.set_epsilon(1e-4)  # 防止梯度爆炸optimizer = model.optimizeroptimizer.clipnorm = 1.0  # 设置梯度裁剪# ======================# 扩展评估(新增测试集Top-K评估)# ======================print("扩展评估测试集...")test_loss, test_acc, test_top3, test_top5 = model.evaluate(test_dataset)print(f"测试结果: Acc={test_acc:.2%}, Top-3={test_top3:.2%}, Top-5={test_top5:.2%}")# 模型评估与保存# ======================print("评估测试集...")test_loss, test_acc, test_top3, test_top5 = model.evaluate(test_dataset)print(f"测试准确率: {test_acc:.2%}")print("保存模型...")# 保存为 SavedModel 格式tf.saved_model.save(model, SAVE_PATH)print(f"Model saved to {SAVE_PATH}")
模型转化为TFLite
import tensorflow as tf
from tensorflow.python.lib.io.file_io import create_dir_v2# 设置输入输出路径(根据实际路径调整)
saved_model_dir = r"C:\Users\lenove\Desktop\doms\src\main\resources\models\pestModel_MobileNetV2_V4"
output_tflite = "optimized_model.tflite"# 创建TFLite转换器
converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)# 添加优化配置(关键步骤!)
converter.optimizations = [tf.lite.Optimize.DEFAULT]  # 启用默认优化(动态范围量化)
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS]  # 确保TFLite兼容性# 执行转换
tflite_model = converter.convert()# 保存优化后的模型
with open(output_tflite, "wb") as f:f.write(tflite_model)print(f"转换成功!输出文件: {output_tflite}")
预测脚本
import warnings
warnings.filterwarnings('ignore')
import sys
import json
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'  # 禁用TensorFlow日志
os.environ['OMP_NUM_THREADS'] = '1'       # 优化OpenMP配置
import cv2
import numpy as np
import tensorflow as tf
from time import time# ---------------------- 全局配置 ----------------------
# 设置TensorFlow线程数 (根据CPU核心数调整)
tf.config.threading.set_intra_op_parallelism_threads(4)  # 单个操作内部并行线程
tf.config.threading.set_inter_op_parallelism_threads(2)   # 操作间并行线程# ---------------------- 常驻内存组件 ----------------------
# TFLite模型加载 (只需加载一次)
INTERPRETER = tf.lite.Interpreter(model_path=r'C:\Users\lenove\Desktop\doms\src\main\resources\models\optimized_model.tflite')
INTERPRETER.allocate_tensors()
INPUT_DETAILS = INTERPRETER.get_input_details()[0]
OUTPUT_DETAILS = INTERPRETER.get_output_details()[0]# 类别标签加载 (只需加载一次)
with open(r'C:\Users\lenove\Desktop\doms\src\main\resources\scripts\class_labels.json', 'r') as f:CLASS_LABELS = json.load(f)# ---------------------- 预处理优化 ----------------------
def preprocess_image_opencv(img_path, target_size=(224, 224)):"""OpenCV预处理提速约3倍"""img = cv2.imread(img_path)if img is None:raise ValueError(f"无法读取图片: {img_path}")# 单次转换替代PIL的多步操作img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)img = cv2.resize(img, target_size)# 归一化并直接生成批处理维度return np.expand_dims(img.astype(np.float32) / 255.0, axis=0)# ---------------------- 推理核心 ----------------------
def batch_predict(image_batch):"""批量推理优化"""# 设置输入张量INTERPRETER.resize_tensor_input(INPUT_DETAILS['index'], image_batch.shape)INTERPRETER.allocate_tensors()INTERPRETER.set_tensor(INPUT_DETAILS['index'], image_batch)INTERPRETER.invoke()return INTERPRETER.get_tensor(OUTPUT_DETAILS['index'])# ---------------------- 主逻辑 ----------------------
def main(img_paths):try:# 批量预处理batch_images = np.vstack([preprocess_image_opencv(p) for p in img_paths])# 执行推理start_time = time()predictions = batch_predict(batch_images)infer_time = time() - start_time# 解析结果results = []for i, probs in enumerate(predictions):top_3_indices = np.argsort(probs)[-3:][::-1]results.append({"image": img_paths[i],"predictions": [{"class": CLASS_LABELS[str(idx)],"confidence": float(probs[idx])} for idx in top_3_indices],"infer_time": f"{infer_time/len(img_paths):.3f}s per image"})print(json.dumps({"status": "success", "results": results}))except Exception as e:print(json.dumps({"status": "error", "message": str(e)}))if __name__ == "__main__":if len(sys.argv) < 2:print(json.dumps({"status": "error","message": "请传入图片路径,支持多图批量处理"}))sys.exit(1)# 支持多图批量推理main(sys.argv[1:])
PredictController预测控制器
package com.example.doms.controller;import com.example.doms.po.Pest;
import com.example.doms.resultDTO.PredictionResultDTO;
import com.example.doms.service.impl.PestServiceImpl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;@RestController
@RequestMapping("/predict")
public class PredictController {@Autowiredprivate PestServiceImpl pestService;@Async@PostMapping("/")public ResponseEntity<?> predict(@RequestParam("file") MultipartFile file) {System.out.println("Received file: " + file.getOriginalFilename());System.out.println("File size: " + file.getSize());if (file == null || file.isEmpty()) {return ResponseEntity.badRequest().body(Map.of("error", "文件为空或未正确上传"));}try {// 1. 保存临时文件Path tempDir = Files.createTempDirectory("pest-");File tempFile = new File(tempDir.toFile(), file.getOriginalFilename());file.transferTo(tempFile);// 2. 调用Python脚本ProcessBuilder pb = new ProcessBuilder("python","C:\\Users\\lenove\\Desktop\\doms\\src\\main\\resources\\scripts\\predict.py",tempFile.getAbsolutePath());Process process = pb.start();// 3. 读取Python输出String result = new String(process.getInputStream().readAllBytes());String error = new String(process.getErrorStream().readAllBytes());//            if (!error.isEmpty()) {//                System.err.println("Python错误输出: " + error);//            }System.out.println("Python原始输出: " + result); // 添加在解析前//解析后的predictions列表List<PredictionResultDTO> predictions = parseResult(result); // 解析JSON// 补充害虫信息predictions = predictions.stream().map(p -> {Pest pest = pestService.getPestByPestName(p.getClassName());if (pest != null) {p.setPestId(pest.getPestId());p.setDescription(pest.getDescription());p.setControlMeasures(pest.getControlMeasures());}return p;}).collect(Collectors.toList());if (predictions == null || predictions.isEmpty()) {return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", "预测结果解析失败"));}return ResponseEntity.ok().body(Map.of("predictions", predictions));} catch (IOException e) {return ResponseEntity.internalServerError().body(Map.of("error", "文件处理失败:" + e.getMessage()));}}private List<PredictionResultDTO> parseResult(String jsonResult) {ObjectMapper objectMapper = new ObjectMapper();try {Map<String, Object> resultMap = objectMapper.readValue(jsonResult, new TypeReference<Map<String, Object>>() {});// 检查状态是否为错误if ("error".equals(resultMap.get("status"))) {System.err.println("Python错误: " + resultMap.get("message"));return Collections.emptyList();}// 获取顶层results列表List<Map<String, Object>> results = (List<Map<String, Object>>) resultMap.get("results");if (results == null) {System.err.println("JSON结构错误: 缺少results字段");return Collections.emptyList();}List<PredictionResultDTO> allPredictions = new ArrayList<>();for (Map<String, Object> resultItem : results) {// 提取每个result项中的predictions列表List<Map<String, Object>> predictions = (List<Map<String, Object>>) resultItem.get("predictions");if (predictions == null || predictions.isEmpty()) {System.err.println("警告: 某条结果缺少predictions字段");continue;}// 转换每个预测项for (Map<String, Object> predMap : predictions) {try {String className = predMap.containsKey("class") ?(String) predMap.get("class") :"未知类别";Double confidence = predMap.containsKey("confidence") ?((Number) predMap.get("confidence")).doubleValue() :0.0;allPredictions.add(new PredictionResultDTO(className, confidence));} catch (ClassCastException e) {System.err.println("类型转换异常: " + predMap);}}}return allPredictions;} catch (IOException e) {System.err.println("JSON解析失败: " + e.getMessage());return Collections.emptyList();} catch (Exception e) {System.err.println("未知解析错误: " + e.getMessage());return Collections.emptyList();}}
}
害虫识别过程展示
  1. 害虫识别初始显示:通过点击左侧蓝色按钮“点击上传害虫图片”进行上传图片进行上传,右侧将展示识别三个识别结果(由模型识别准确率前三决定)

在这里插入图片描述

  1. 上传图片
    在这里插入图片描述
  2. 展示害虫识别结果。从左到右依次为模型认为准确率前三的害虫类别。
    在这里插入图片描述

害虫定位实现

害虫定位代码

基于高德API及相关害虫定位经纬度进行实现。相关高德API使用可在如下链接查看:高德API,代码如下:

<template><div style="display: flex;background-color: #b0ffca;box-shadow: 10px 10px 10px #6dffff, -10px -10px 10px #f9f9c6 !important;border-radius: 20px;overflow: hidden;"><div id="container"></div><div style="padding: 20px;"><div style="display: flex;flex-direction: column;"><span style="font-size: x-large;font-weight: 600;color: #08e908;">害虫分布</span><div style="margin-top: 10px;"><el-button type="success" @click="currentGeolocation" size="small"style="margin-left: 10px;">当前定位</el-button><el-button type="primary" @click="clearRoute" size="small"style="margin-left: 10px;">清除路线</el-button><el-button type="info" @click="showFilteredPestStorages" size="small" style="margin-left: 10px;">{{ user.role === '工作人员' ? '所属果园害虫分布' : '管理果园害虫分布' }}</el-button><el-button type="warning" @click="showAllPestStorages" size="small" style="margin-left: 10px;">所有果园害虫分布</el-button></div></div><el-table :data="paginatedPestStorages"style="width: 100%; border-radius: 20px; font-weight: 600;margin-top: 10px;"><el-table-column prop="chineseName" label="害虫名称" width="150px" /><el-table-column prop="latitude" label="纬度"><template #default="scope">{{ scope.row.latitude || '无记录' }}</template></el-table-column><el-table-column prop="longitude" label="经度"><template #default="scope">{{ scope.row.longitude || '无记录' }}</template></el-table-column><el-table-column label="操作" width="200px"><template #default="scope"><!-- 当坐标存在时显示操作按钮 --><div v-if="hasValidLocation(scope.row)"><el-button type="warning" size="small" @click="handleWalk(scope.row)">步行</el-button><el-button type="primary" size="small" @click="handleDrive(scope.row)">驾车</el-button><el-button type="success" size="small" @click="handleLocate(scope.row)">定位</el-button></div><!-- 坐标缺失时显示提示 --><span v-else style="color:#999">不可操作</span></template></el-table-column></el-table><!-- 分页控件 --><el-pagination v-if="totalPestStorages > 0" v-model:current-page="currentPestStoragesPage":page-size="pestStoragesPageSize" :total="totalPestStorages" layout="prev, pager, next"@current-change="handlePestStoragesPageChange" class="mt-4" backgroundstyle="display:flex; justify-content: center;" /></div></div>
</template><script>
import AMapLoader from '@amap/amap-jsapi-loader';
import { ElMessage } from 'element-plus';
import { onMounted, ref, getCurrentInstance, computed } from 'vue';export default {name: 'MapOrchard',setup() {const instance = getCurrentInstance();let map = ref(null);let AMapInstance = ref(null);let markers = ref([]);let drivingInstance = ref(null);let walkingInstance = ref(null);let geolocation = ref(null);const pestStorages = ref([]);// 从 sessionStorage 读取用户信息const user = JSON.parse(sessionStorage.getItem('user') || { userName: '', role: '' })// 害虫寄存分页状态管理const currentPestStoragesPage = ref(1);const pestStoragesPageSize = ref(2); // 每页显示数量const totalPestStorages = computed(() => pestStorages.value.length);// 计算当前害虫寄存页的数据const paginatedPestStorages = computed(() => {const start = (currentPestStoragesPage.value - 1) * pestStoragesPageSize.value;const end = start + pestStoragesPageSize.value;return pestStorages.value.slice(start, end);});const handlePestStoragesPageChange = (page) => {currentPestStoragesPage.value = page;}// 初始化地图const initMap = () => {window._AMapSecurityConfig = {securityJsCode: "",};AMapLoader.load({key: "",version: "2.0",plugins: ["AMap.Scale", "AMap.Geolocation", "AMap.ControlBar", "AMap.Driving", "AMap.Walking"],}).then((AMap) => {AMapInstance.value = AMap;map.value = new AMap.Map("container", {resizeEnable: true,viewMode: "2D",zoom: 15,zoomToAccuracy: true,center: [113.380696, 23.202551],});// 添加地图控件map.value.addControl(new AMap.ControlBar());map.value.addControl(new AMap.Scale());geolocation.value = new AMap.Geolocation({enableHighAccuracy: true,timeout: 10000,buttonOffset: new AMap.Pixel(10, 20),zoomToAccuracy: true});map.value.addControl(geolocation.value);// 开始定位geolocation.value.getCurrentPosition((status, result) => {if (status === 'complete') {ElMessage.success('当前定位成功');map.value.setCenter([result.position.lng, result.position.lat]);} else {ElMessage.error('当前定位失败');}});// 初始化驾车实例drivingInstance.value = new AMap.Driving({map: map.value,});//初始化步行规划walkingInstance.value = new AMap.Walking({map: map.value});// 加载害虫位置loadPestLocations();}).catch(console.error);};// 加载害虫位置const loadPestLocations = async () => {try {const response = await instance.proxy.$request.get('/pestStorage/getAll');const rawResponse = response._rawResponse;if (rawResponse.status === 200) {pestStorages.value = response.pestStorages.map(item => ({...item,latitude: item.latitude || null,longitude: item.longitude || null}));response.pestStorages.forEach(pestStorage => {if (pestStorage.latitude && pestStorage.longitude) {addMarker([pestStorage.longitude, pestStorage.latitude], pestStorage);}});}} catch (error) {ElMessage.error('加载害虫位置失败:' + (error.message || '未知错误'));}};// 更新害虫位置并动态更新标记const updatePestLocations = async (pestStoragesData) => {try {clearMarkers(); // 清除现有标记pestStorages.value = pestStoragesData; // 更新害虫数据pestStoragesData.forEach(pestStorage => {if (pestStorage.latitude && pestStorage.longitude) {addMarker([pestStorage.longitude, pestStorage.latitude], pestStorage);}});} catch (error) {ElMessage.error('更新害虫位置失败:' + (error.message || '未知错误'));}};const showFilteredPestStorages = async () => {try {let response;if (user.role === '工作人员') {// 工作人员:获取所属果园的害虫分布response = await instance.proxy.$request.get(`/pestStorage/getByStaff/${user.userId}`);} else {// 果园管理者:获取所有管理果园的害虫分布response = await instance.proxy.$request.get(`/pestStorage/getByManager/${user.userId}`);}const rawResponse = response._rawResponse;if (rawResponse.status === 200) {await updatePestLocations(response.pestStorages); // 动态更新数据ElMessage.success('加载成功');}} catch (error) {ElMessage.error('加载失败:' + (error.message || '未知错误'));}};const showAllPestStorages = async () => {try {// 加载所有果园的害虫分布const response = await instance.proxy.$request.get('/pestStorage/getAll');const rawResponse = response._rawResponse;if (rawResponse.status === 200) {await updatePestLocations(response.pestStorages); // 动态更新数据ElMessage.success('加载成功');}} catch (error) {ElMessage.error('加载失败:' + (error.message || '未知错误'));}};// 添加标记const addMarker = (lngLat, pestStorage) => {if (!map.value) return;const marker = new AMapInstance.value.Marker({position: lngLat,icon: new AMapInstance.value.Icon({size: new AMapInstance.value.Size(19, 31),imageSize: new AMapInstance.value.Size(19, 31),image: "https://webapi.amap.com/theme/v1.3/markers/n/mark_b.png",imageOffset: new AMapInstance.value.Pixel(0, 0)}),offset: new AMapInstance.value.Pixel(-9, -31)});marker.on('click', () => {ElMessage.info(`害虫类型:${pestStorage.chineseName},发现时间:${formatDate(pestStorage.discoveryTime)}`);});map.value.add(marker);markers.value.push(marker);};// 清除地图上的所有标记const clearMarkers = () => {if (map.value && markers.value.length > 0) {markers.value.forEach(marker => {map.value.remove(marker);});markers.value = []; // 清空标记数组}};// 定位到标记const handleLocate = (pestStorage) => {if (!map.value) {console.error("地图实例未初始化");return;}const lngLat = [pestStorage.longitude, pestStorage.latitude];map.value.setCenter(lngLat);};// 驾车路线规划const drivingRoute = (start, end) => {if (!drivingInstance.value) return;drivingInstance.value.clear();drivingInstance.value.search(start, end, (status, result) => {if (status === 'complete') {ElMessage.success('驾车路线规划成功');console.log('驾车路线规划成功', result);} else {ElMessage.error('驾车路线规划失败');console.error('驾车路线规划失败', result);}});};// 步行路线规划const walkingRoute = (start, end) => {if (!walkingInstance.value) return;walkingInstance.value.clear();walkingInstance.value.search(start, end, (status, result) => {if (status === 'complete') {ElMessage.success('步行路线规划成功');console.log('步行路线规划成功', result);} else {ElMessage.error('步行路线规划失败');console.error('步行路线规划失败', result);}});};// 处理驾车路线规划const handleDrive = async (pestStorage) => {try {const startLngLat = await currentGeolocation();const endLngLat = [pestStorage.longitude, pestStorage.latitude];drivingRoute(startLngLat, endLngLat);} catch (error) {ElMessage.error('获取当前位置失败,使用默认起点');const startLngLat = [113.380696, 23.202551]; // 默认起点const endLngLat = [pestStorage.longitude, pestStorage.latitude];drivingRoute(startLngLat, endLngLat);}};// 处理步行路线规划const handleWalk = async (pestStorage) => {try {const startLngLat = await currentGeolocation();const endLngLat = [pestStorage.longitude, pestStorage.latitude];walkingRoute(startLngLat, endLngLat);} catch (error) {ElMessage.error('获取当前位置失败,使用默认起点');const startLngLat = [113.380696, 23.202551]; // 默认起点const endLngLat = [pestStorage.longitude, pestStorage.latitude];walkingRoute(startLngLat, endLngLat);}};// 清除步行或者驾车路线const clearRoute = async () => {if (drivingInstance.value) {await drivingInstance.value.clear();currentGeolocation();}if (walkingInstance.value) {await walkingInstance.value.clear();currentGeolocation();}};//currentGeolocation 当前定位// 获取当前定位const currentGeolocation = () => {return new Promise((resolve, reject) => {if (!geolocation.value) {reject(new Error('定位功能未初始化'));return;}geolocation.value.getCurrentPosition((status, result) => {if (status === 'complete') {resolve([result.position.lng, result.position.lat]);} else {reject(new Error('获取定位失败'));}});}).catch(() => {// 定位失败时使用默认值return [113.380696, 23.202551]; // 默认经纬度});};// 检查坐标有效性const hasValidLocation = (row) => {return row.latitude && row.longitude &&!isNaN(row.latitude) &&!isNaN(row.longitude)}// 格式化时间的方法const formatDate = (time) => {if (!time) return '';const date = new Date(time);const year = date.getFullYear();const month = String(date.getMonth() + 1).padStart(2, '0');const day = String(date.getDate()).padStart(2, '0');const hours = String(date.getHours()).padStart(2, '0');const minutes = String(date.getMinutes()).padStart(2, '0');const seconds = String(date.getSeconds()).padStart(2, '0');return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;};onMounted(async () => {await initMap();await loadPestLocations();});return {user,currentPestStoragesPage,pestStoragesPageSize,totalPestStorages,paginatedPestStorages,handlePestStoragesPageChange,showFilteredPestStorages,showAllPestStorages,pestStorages,handleLocate,handleDrive,handleWalk,clearRoute,currentGeolocation,hasValidLocation,loadPestLocations,};}
};
</script><style scoped>
#container {width: 100%;height: 100%;border-radius: 20px;
}
</style>
害虫定位过程展示
  1. 一开始地图将展示当前定位位置,右侧害虫分布将展示系统内数据库所存在的所有害虫上报的位置信息(包括有经纬度和无经纬度信息的)
    在这里插入图片描述
  2. 点击对应害虫的步行/驾车按钮效果如下图所示:
    在这里插入图片描述
    在这里插入图片描述
  3. 点击对应害虫的“定位”按钮,将会把地图中心移至害虫标记所在位置,如图所示:
    在这里插入图片描述
  4. 点击“清除路线”按钮,将清除地图上所有的路线规划,并且地图中心将回到当前定位位置。
  5. “所属果园害虫分布”按钮是为系统角色“工作人员”专设,将为“工作人员”展示所属果园的害虫分布,方便“工作人员”进行定位处理,点击“所有果园害虫分布”按钮将展示系统内所保存的所有果园的害虫分布信息。

专家咨询功能

在线咨询聊天室

用户可以对所有在线的专家发起咨询请求,并进行聊天室的咨询对话。如图所示:

  1. 选择对应专家进行“立即咨询”操作

在这里插入图片描述

  1. 专家接受咨询

在这里插入图片描述

  1. 用户与专家聊天室进行咨询

在这里插入图片描述

主要前端代码如下
  • 工作人员端
<template><el-row :gutter="20" style="margin-top: 20px;"><!-- 左侧在线专家列表 --><el-col :span="12"><el-card class="list-card"><template #header><span style="font-size:large;font-weight: 600;color:#08e908;">专家列表</span></template><el-table :data="paginatedExperts" style="border-radius: 20px;"><el-table-column prop="expertId" label="专家ID" width="80" /><el-table-column prop="userName" label="用户名" width="80" /><el-table-column prop="expertName" label="专家姓名" /><el-table-column prop="expertise" label="专家详情"><template #default="{ row }"><el-button @click="showModal(row)" type="primary" size="small">查看</el-button></template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag v-if="users.some(user => user.userName === row.userName)" type="success">在线</el-tag><el-tag v-else type="info">离线</el-tag></template></el-table-column><el-table-column label="操作" width="120"><template #default="{ row }"><!-- 继续咨询按钮 --><el-button v-if="activeConsultations[row.userName]" @click="handleContinueConsult(row)"type="warning" size="small" style="margin-left: 5px">继续咨询<el-badge v-if="unreadCount[row.userName]" :value="unreadCount[row.userName]":offset="[21, 0]" :max="10"></el-badge></el-button><el-button v-else-if="users.some(user => user.userName === row.userName)"@click="handleConsult(row)" type="primary" size="small">立即咨询</el-button><el-button v-else type="info" size="small" @click="handleAppointment(row)">预约咨询</el-button></template></el-table-column></el-table><el-pagination v-if="totalExperts > 0" v-model:current-page="currentExpertPage":page-size="expertPageSize" :total="totalExperts" layout="prev, pager, next"@current-change="handleExpertPageChange" class="mt-4" backgroundstyle="display:flex; justify-content: center; margin-top: 18px;" /></el-card><!-- 新增:预约咨询模态框 --><el-dialog title="新建预约咨询" v-model="appointmentModalVisible" width="600px" :append-to-body="true"><el-form :model="appointmentForm" :rules="rules" ref="formRef" label-width="100px"><el-form-item label="用户名" prop="userName"><el-input v-model="appointmentForm.userName" disabled /></el-form-item><!-- 果园信息 --><el-form-item label="所属果园" prop="orchardName"><el-input v-model="appointmentForm.orchardName" disabled /></el-form-item><!-- 专家信息 --><el-form-item label="咨询专家" prop="expertId"><el-select v-model="appointmentForm.expertId" placeholder="选择专家" @change="handleExpertChange"><el-option v-for="expert in experts" :key="expert.expertId" :label="expert.expertName":value="expert.expertId" /></el-select></el-form-item><!-- 预约时间 --><el-form-item label="预约时间" prop="appointmentTime"><el-date-picker v-model="appointmentForm.appointmentTime" type="datetime":disabled-date="disabledPastDates" /></el-form-item><!-- 联系方式 --><el-form-item label="手机号码" prop="contactMethod"><el-input v-model="appointmentForm.contactMethod" placeholder="请输入手机号" maxlength="11" /></el-form-item><!-- 咨询内容 --><el-form-item label="咨询内容" prop="appointmentContent"><el-input v-model="appointmentForm.appointmentContent" type="textarea" :rows="4"placeholder="请详细描述问题(如病虫害症状、果园面积等)" /></el-form-item></el-form><template #footer><el-button @click="appointmentModalVisible = false">取消</el-button><el-button type="primary" @click="submitAppointment">提交预约</el-button></template></el-dialog><!-- 新增:预约记录和反馈表格(放在专家列表下方) --><el-card class="list-card" style="margin-top: 20px"><template #header><span style="font-size:large;font-weight: 600;color:#08e908;">预约咨询</span></template><el-table :data="paginatedAppointments" style="width: 100%;border-radius: 20px;" stripe border><el-table-column prop="expertName" label="专家姓名" width="120" /><el-table-column prop="appointmentTime" label="预约时间" width="180"><template #default="{ row }">{{ formatDate(row.appointmentTime) }}</template></el-table-column><el-table-column prop="appointmentContent" label="咨询内容"><template #default="{ row }"><el-button @click="showAppointmentContent(row)" type="primary" size="small">查看</el-button></template></el-table-column><el-table-column prop="status" label="状态" width="100"><template #default="{ row }"><el-tag :type="statusTagType(row.status)">{{ row.status }}</el-tag></template></el-table-column></el-table><el-pagination v-if="totalAppointments > 0" v-model:current-page="currentAppointmentPage":page-size="appointmentPageSize" :total="totalAppointments" layout="prev, pager, next"@current-change="handleAppointmentPageChange" class="mt-4" backgroundstyle="display:flex; justify-content: center; margin-top: 18px;" /><el-dialog v-model="appointmentContentDialog" title="咨询内容" width="50%"><el-descriptions :column="1" border><el-descriptions-item label="果园名称">{{ currentAppointment.orchardName }}</el-descriptions-item><el-descriptions-item label="联系方式">{{ currentAppointment.contactMethod }}</el-descriptions-item><el-descriptions-item label="咨询问题">{{currentAppointment.appointmentContent }}</el-descriptions-item></el-descriptions></el-dialog></el-card></el-col><!-- 右侧聊天框 --><el-col :span="12"><el-card style="height: inherit;" class="chat-card"><div v-if="chatUser !== ''"><div class="chat-container"><div style="text-align: center; line-height: 50px;">聊天室({{ chatUser }}</div><div style="height: 350px; overflow:auto; border-top: 1px solid #ccc" v-html="content"></div><div style="height: 250px"><textarea v-model="text" style="height: 160px; width: 100%; padding: 20px; border: none; border-top: 1px solid #ccc;border-bottom: 1px solid #ccc; outline: none" @keydown.enter="handleEnter"></textarea><div style="text-align: right; padding-right: 10px"><el-button @click="handleEndConsult" type="danger" size="small">结束咨询</el-button><el-button type="primary" size="small" @click="send">发送</el-button></div></div></div></div><div v-else style="background-color: #b0ffca;"><el-card class="feedback-card"><template #header><span style="font-size:large;font-weight: 600;color:#08e908;">咨询反馈</span></template><!-- 未反馈记录 --><div class="feedback-section" v-if="pendingFeedbacks.length > 0"style="display: flex;flex-direction: column;align-items: center;"><divstyle="background-color: #ccf6f6;width:100%;border-radius: 20px;padding: 0px 10px;color: #65f578;margin-bottom: 10px;height: 60px;"><h4>待反馈记录</h4></div><el-table :data="paginatedPending" style="width: 100%;border-radius: 20px;"><el-table-column prop="consultationType" label="咨询类型" width="80" /><el-table-column prop="consultationTime" label="咨询时间" width="180"><template #default="{ row }">{{ formatDate(row.consultationTime) }}</template></el-table-column><el-table-column prop="expertName" label="专家" width="100" /><el-table-column label="操作"><template #default="{ row }"><el-button type="success" size="default"@click="openFeedbackDialog(row)">填写反馈</el-button></template></el-table-column></el-table><el-pagination v-if="totalPending > 0" v-model:current-page="currentPendingPage":page-size="pendingPageSize" :total="totalPending" layout="prev, pager, next"@current-change="handlePendingPageChange" backgroundstyle="margin-top: 15px; justify-content: center" /></div><!-- 已反馈记录 --><div class="feedback-section" v-if="completedFeedbacks.length > 0"style="display: flex;flex-direction: column;align-items: center;"><divstyle="margin-top: 10px;background-color: #ccf6f6;width:100%;border-radius: 20px;padding: 0px 10px;color: #65f578;margin-bottom: 10px;height: 60px;"><h4>历史反馈</h4></div><el-table :data="paginatedCompleted" style="width: 100%;border-radius: 20px;"><el-table-column prop="consultationType" label="咨询类型" width="80" /><el-table-column prop="consultationTime" label="咨询时间" width="180"><template #default="{ row }">{{ formatDate(row.consultationTime) }}</template></el-table-column><el-table-column prop="expertName" label="专家" width="80" /><el-table-column prop="rating" label="评分"><template #default="{ row }"><el-rate v-model="row.rating" disabled:colors="['#99A9BF', '#F7BA2A', '#FF9900']" /></template></el-table-column><el-table-column label="反馈内容"><template #default="{ row }"><el-button type="primary" size="default"@click="viewFeedbackDetail(row)">查看详情</el-button></template></el-table-column></el-table><el-pagination v-if="totalCompleted > 0" v-model:current-page="currentCompletedPage":page-size="completedPageSize" :total="totalCompleted" layout="prev, pager, next"@current-change="handleCompletedPageChange" backgroundstyle="margin-top: 15px; justify-content: center" /></div><!-- 无记录提示 --><el-empty v-if="!pendingFeedbacks.length && !completedFeedbacks.length" description="暂无反馈记录" /></el-card><!-- 反馈模态框 --><el-dialog title="填写反馈" v-model="feedbackDialogVisible" width="600px"><el-form :model="feedbackForm" :rules="feedbackRules" ref="feedbackFormRef"><el-form-item label="咨询专家" prop="expertName"><el-input v-model="feedbackForm.expertName" disabled /></el-form-item><el-form-item label="咨询时间" prop="consultationTime"><el-input :model-value="formatDate(feedbackForm.consultationTime)" disabled /></el-form-item><el-form-item label="服务评分" prop="rating"><el-rate v-model="feedbackForm.rating" :colors="['#99A9BF', '#F7BA2A', '#FF9900']":texts="['非常差', '差劲', '一般', '良好', '优秀']" show-text /></el-form-item><el-form-item label="反馈内容" prop="feedbackText"><el-input v-model="feedbackForm.feedbackText" type="textarea" :rows="4"placeholder="请输入您的反馈意见" maxlength="500" show-word-limit /></el-form-item></el-form><template #footer><el-button @click="feedbackDialogVisible = false">取消</el-button><el-button type="primary" @click="submitFeedback" :loading="submitting">提交反馈</el-button></template></el-dialog><!-- 反馈详情模态框 --><el-dialog title="反馈详情" v-model="detailDialogVisible" width="500px"><el-descriptions :column="1" border><el-descriptions-item label="专家姓名">{{ currentFeedback.expertName }}</el-descriptions-item><el-descriptions-item label="咨询时间">{{ formatDate(currentFeedback.consultationTime) }}</el-descriptions-item><el-descriptions-item label="服务评分"><el-rate v-model="currentFeedback.rating" disabled:colors="['#99A9BF', '#F7BA2A', '#FF9900']" /></el-descriptions-item><el-descriptions-item label="反馈内容">{{ currentFeedback.feedbackText }}</el-descriptions-item></el-descriptions></el-dialog></div></el-card></el-col><el-dialog title="专家详情" v-model="dialogVisible" width="50%" @close="dialogVisible = false" top="2%":append-to-body="true"><el-descriptions :column="1" border v-if="selectedExpert"><el-descriptions-item label="姓名">{{ selectedExpert.expertName }}</el-descriptions-item><el-descriptions-item label="性别">{{ selectedExpert.gender }}</el-descriptions-item><el-descriptions-item label="专业领域">{{ selectedExpert.expertise }}</el-descriptions-item><el-descriptions-item label="资质证书"><el-image style="width: 200px; height: 150px" :src="selectedExpert.certificate":preview-src-list="[selectedExpert.certificate]" fit="cover" /></el-descriptions-item><el-descriptions-item label="个人简介">{{ selectedExpert.bio }}</el-descriptions-item><el-descriptions-item label="综合评分"><el-rate v-model="selectedExpert.rating" disabled :colors="['#99A9BF', '#F7BA2A', '#FF9900']":max="5" /></el-descriptions-item></el-descriptions></el-dialog></el-row>
</template><script>
import { ref, reactive, onMounted, onUnmounted, getCurrentInstance, nextTick, computed } from 'vue';
import { ElMessage } from 'element-plus';
export default {name: 'StaffConsultationMainTop',
}
</script>
<script setup>
let socket = ref(null)
const instance = getCurrentInstance();
const user = reactive(JSON.parse(sessionStorage.getItem("user")) || {})
const userId = computed(() => user.userId);
const userName = computed(() => user.userName);
const users = ref([])
const experts = ref([]);const avatarNowUserUrl = new URL('@/assets/avatar/avatar_1.png', import.meta.url).href;
const avatarRemoteUserUrl = new URL('@/assets/avatar/avatar_2.png', import.meta.url).href;const chatUser = ref('')// 跟踪当前聊天专家
const text = ref('')
const content = ref('')// 新增响应式变量
const dialogVisible = ref(false);
const selectedExpert = ref(null);// 新增状态
const chatHistory = reactive({});
//添加未读计数
const unreadCount = reactive({});const activeConsultations = reactive({});const consultationIds = reactive({}); // 存储专家与咨询ID的映射 {userName: consultationId}// 专家分页状态管理
const currentExpertPage = ref(1);
const expertPageSize = ref(4); // 每页显示数量
const totalExperts = computed(() => experts.value.length);// 计算当前专家页的数据
const paginatedExperts = computed(() => {const start = (currentExpertPage.value - 1) * expertPageSize.value;const end = start + expertPageSize.value;return experts.value.slice(start, end);
});const handleExpertPageChange = (page) => {currentExpertPage.value = page;
}
// 新增显示模态框方法
const showModal = (expert) => {selectedExpert.value = expert;dialogVisible.value = true;
};onMounted(() => {const savedIds = sessionStorage.getItem('consultationIds');const savedConsultations = sessionStorage.getItem('activeConsultations');const savedHistory = sessionStorage.getItem('chatHistory');if (savedIds) {Object.assign(consultationIds, JSON.parse(savedIds));}if (savedConsultations) {Object.assign(activeConsultations, JSON.parse(savedConsultations));}if (savedHistory) {Object.assign(chatHistory, JSON.parse(savedHistory));}loadExperts();init();loadAppointments();loadFeedbackRecords(); // 需要添加await确保加载完成});const loadExperts = async () => {try {const res = await instance.proxy.$request.get('/expert/');experts.value = res;} catch (err) {ElMessage.error('加载专家列表失败!');}
};const handleConsult = async (row) => {// 设置咨询状态activeConsultations[row.userName] = true;// 切换专家时重置未读unreadCount[row.userName] = 0;chatUser.value = row.userName;// 初始化聊天记录if (!chatHistory[row.userName]) {chatHistory[row.userName] = [];}content.value = chatHistory[row.userName].join('');//创建新的线上咨询记录保存至数据库try {const consultationTime = new Date().toISOString()const res = await instance.proxy.$request.post('/consultation/create', {userId: userId.value,expertId: row.expertId,expertName: row.expertName,status: '未处理',consultationTime, // 使用 ISO 8601 格式的时间});await instance.proxy.$request.post('/feedback/create', {consultationType: '在线',consultationId: res.consultationId,consultationTime,userId: userId.value,userName: userName.value,expertId: row.expertId,expertName: row.expertName,feedbackStatus: '未反馈',})loadFeedbackRecords();// 存储consultationId(关键修改)consultationIds[row.userName] = res.consultationId;sessionStorage.setItem('consultationIds', JSON.stringify(consultationIds));} catch (error) {ElMessage.error('保存线上咨询记录失败!')}// 发送咨询请求const message = {type: 'consult_request',from: user.userName,to: row.userName,text: `${user.userName}发起了咨询请求`,consultationId: consultationIds[row.userName]};const html = createContent('system', null, null, message.text); // 传递消息类型chatHistory[row.userName].push(html);content.value = chatHistory[row.userName].join('');if (socket.value) {socket.value.send(JSON.stringify(message));}// 滚动到底部nextTick(() => {const container = document.querySelector('.chat-container div[style*="overflow"]');if (container) container.scrollTop = container.scrollHeight;});text.value = ''
};// 继续咨询处理
const handleContinueConsult = (row) => {//新增咨询ID验证if (!consultationIds[row.userName]) {ElMessage.warning('未找到有效的咨询记录');}// 设置咨询状态activeConsultations[row.userName] = true;unreadCount[row.userName] = 0;chatUser.value = row.userName;content.value = chatHistory[row.userName]?.join('') || '';// 滚动到底部nextTick(() => {const container = document.querySelector('.chat-container div[style*="overflow"]');if (container) container.scrollTop = container.scrollHeight;});text.value = ''
};// 结束咨询处理
const handleEndConsult = async () => {if (!chatUser.value)console.log("当前聊天专家不存在chatUser.value", chatUser);else {try {// 获取当前咨询ID(关键新增)const consultationId = consultationIds[chatUser.value];if (!consultationId) {ElMessage.error('未找到咨询记录ID');}// 发送结束请求到后端(新增接口调用)await instance.proxy.$request.put(`/consultation/end/${consultationId}`, {status: '已完成',endTime: new Date().toISOString()});// 发送结束通知const message = {type: 'consult_end',from: user.userName,to: chatUser.value,text: `${user.userName}结束了本次咨询`,consultationId: consultationId // 新增携带咨询ID(后续在后台显示)};socket.value.send(JSON.stringify(message));// 清除本地状态(新增清理consultationIds)delete consultationIds[chatUser.value];sessionStorage.setItem('consultationIds', JSON.stringify(consultationIds));// 清除状态delete activeConsultations[chatUser.value];chatUser.value = '';content.value = '';ElMessage.success('咨询已结束');} catch (err) {console.log(err);ElMessage.error('结束咨询失败');}}
};const send = () => {if (!chatUser.value || !text.value.trim()) {ElMessage.warning("请选择专家并输入内容");return;}const message = {from: user.userName,to: chatUser.value,text: text.value};// 发送消息socket.value.send(JSON.stringify(message));// 保存到本地记录const html = createContent(null, null, user.userName, text.value);chatHistory[chatUser.value].push(html);content.value = chatHistory[chatUser.value].join('');text.value = '';// 滚动到底部nextTick(() => {const container = document.querySelector('.chat-container div[style*="overflow"]');if (container) container.scrollTop = container.scrollHeight;});
};const createContent = (messageType, remoteUser, nowUser, text) => {let html = '';if (messageType === 'system') { // 新增系统消息类型html = `<div class="el-row" style="padding: 5px 0; margin: 2px; background-color: #f0f4ff; border: 4px solid #409eff; border-radius: 10px;"><div class="el-col el-col-22" style="text-align: center; font-weight: 700;"><div class="tip system">系统提示:${text}</div></div></div>`;}else if (nowUser) {html = `<div class="el-row" style="padding: 5px 0"><div class="el-col el-col-22" style="text-align: right; padding-right: 10px"><div class="tip left">${text}</div></div><div class="el-col el-col-2"><span class="el-avatar el-avatar--circle" style="height: 40px; width: 40px; line-height: 40px;"><img src="${avatarNowUserUrl}" style="object-fit: cover;"></span></div></div>`} else if (remoteUser) {html = `<div class="el-row" style="padding: 5px 0"><div class="el-col el-col-2" style="text-align: right"><span class="el-avatar el-avatar--circle" style="height: 40px; width: 40px; line-height: 40px;"><img src="${avatarRemoteUserUrl}" style="object-fit: cover;"></span></div><div class="el-col el-col-22" style="text-align: left; padding-left: 10px"><div class="tip right">${text}</div></div></div>`}return html;
}const init = () => {const userName = user.userNameif (typeof (WebSocket) == "undefined") {console.log("您的浏览器不支持WebSocket")return}const socketUrl = `ws://localhost:8080/server/${userName}`if (socket.value) {socket.value.close()socket.value = null}socket.value = new WebSocket(socketUrl)socket.value.onopen = () => {console.log("websocket已打开")}socket.value.onmessage = (msg) => {const data = JSON.parse(msg.data)if (data.users) {users.value = data.users.filter(u => u.userName !== userName)}//处理对方发起咨询请求else if (data.type === 'consult_request') {const fromUser = data.from;// 初始化记录if (!chatHistory[fromUser]) chatHistory[fromUser] = [];// 生成消息内容const html = createContent('system', null, null, data.text);chatHistory[fromUser].push(html);content.value = chatHistory[fromUser].join('');ElMessage.warning(`${data.text}`);}// 处理结束咨询消息else if (data.type === 'consult_end') {const fromUser = data.from;if (chatUser.value === fromUser) {chatUser.value = '';content.value = '';}activeConsultations[fromUser] = false;ElMessage.warning(`${fromUser}结束了咨询`);}else {const fromUser = data.from;// 初始化记录if (!chatHistory[fromUser]) chatHistory[fromUser] = [];// 生成消息内容const html = createContent(null, fromUser, null, data.text);chatHistory[fromUser].push(html);// 更新显示if (chatUser.value === fromUser) {content.value = chatHistory[fromUser].join('');} else {// 更新未读计数unreadCount[fromUser] = (unreadCount[fromUser] || 0) + 1;}}}socket.value.onclose = () => {console.log("websocket已关闭")}socket.value.onerror = () => {console.log("websocket发生了错误")}
}// 统一的关闭WebSocket方法
const closeSocket = () => {if (socket.value) {// 获取所有活跃咨询的专家用户名列表const activeExperts = Object.keys(activeConsultations).filter(userName => activeConsultations[userName]);// 发送最后一条关闭通知const message = {type: 'force_close',from: user.userName,to: activeExperts,text: `用户${user.userName}已离开页面`};socket.value.send(JSON.stringify(message));socket.value.close()  // 主动调用WebSocket的close方法socket.value = nullconsole.log('WebSocket连接已主动关闭')}
}// 组件卸载时
onUnmounted(async () => {sessionStorage.setItem('activeConsultations', JSON.stringify(activeConsultations));sessionStorage.setItem('chatHistory', JSON.stringify(chatHistory));// await handleEndConsult();closeSocket();
})const handleEnter = (event) => {event.preventDefault(); // 阻止默认换行行为send();
};// 格式化时间的方法
const formatDate = (time) => {if (!time) return '';const date = new Date(time);const year = date.getFullYear();const month = String(date.getMonth() + 1).padStart(2, '0');const day = String(date.getDate()).padStart(2, '0');const hours = String(date.getHours()).padStart(2, '0');const minutes = String(date.getMinutes()).padStart(2, '0');const seconds = String(date.getSeconds()).padStart(2, '0');return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};// 预约模态框状态
const appointmentModalVisible = ref(false);
const selectedExpertForAppointment = ref(null);// 禁止选择过去的时间
const disabledPastDates = (date) => {return date < Date.now() - 86400000; // 86400000ms = 1天
};// 预约表单数据
const appointmentForm = reactive({expertId: '',expertName: '',orchardId: '',orchardName: '',appointmentTime: '',contactMethod: '',appointmentContent: '',
});// 表单验证规则
const appointmentRules = {orchardId: [{ required: true, message: '请选择果园', trigger: 'change' }],expertId: [{ required: true, message: '请选择专家', trigger: 'change' }],appointmentTime: [{ required: true, message: '请选择预约时间', trigger: 'change' }],contactMethod: [{ required: true, message: '请输入手机号', trigger: 'blur' },{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式错误', trigger: 'blur' }],appointmentContent: [{ required: true, message: '请输入咨询内容', trigger: 'blur' },{ min: 10, message: '至少输入10个字符', trigger: 'blur' }]
};// 预约记录数据
const appointments = ref([]);
const currentAppointmentPage = ref(1);
const appointmentPageSize = ref(3);const totalAppointments = computed(() => appointments.value.length);// 计算属性 ------------------------------------------------------------
const paginatedAppointments = computed(() => {const start = (currentAppointmentPage.value - 1) * appointmentPageSize.value;const end = start + appointmentPageSize.value;return appointments.value.slice(start, end);
});const handleAppointmentPageChange = (page) => {currentAppointmentPage.value = page;
}const fetchOrchard = async () => {try {const response = await instance.proxy.$request.get(`/orchard/getOrchardByStaff/${userId.value}`)appointmentForm.orchardId = response.orchard.orchardId;appointmentForm.orchardName = response.orchard.orchardName;} catch (error) {ElMessage.error('获取果园数据失败')}
}// 方法 ---------------------------------------------------------------
// 打开预约模态框(修改原有预约按钮)
const handleAppointment = async (row) => {await fetchOrchard();selectedExpertForAppointment.value = row;appointmentForm.expertId = row.expertId;appointmentForm.expertName = row.expertName;appointmentForm.userName = userName.value;appointmentModalVisible.value = true;
};// 提交预约
const submitAppointment = async () => {try {const createTime = new Date().toISOString()// 调用创建预约接口const res = await instance.proxy.$request.post('/appointment/create', {...appointmentForm,userId: userId.value,createTime,status: '待确认' // 初始状态});await instance.proxy.$request.post('/feedback/create', {consultationType: '预约',consultationId: res.appointmentId,consultationTime: createTime,userId: userId.value,userName: userName.value,expertId: appointmentForm.expertId,expertName: appointmentForm.expertName,feedbackStatus: '未反馈',})// 刷新预约列表loadAppointments();loadFeedbackRecords();ElMessage.success('预约提交成功');appointmentModalVisible.value = false;} catch (err) {ElMessage.error('预约提交失败: ' + err.message);}
};// 专家选择事件
const handleExpertChange = (id) => {const expert = appointmentForm.experts.find(e => e.expertId === id);appointmentForm.expertName = expert?.expertName || '';
};// 加载预约记录
const loadAppointments = async () => {try {const res = await instance.proxy.$request.get(`/appointment/${userId.value}`)appointments.value = res.appointments;} catch (err) {ElMessage.error('加载预约记录失败');}
};// 状态标签样式
const statusTagType = (status) => {const map = {'待确认': 'warning','已确认': 'success','已取消': 'danger','已完成': ''};return map[status] || '';
};const appointmentContentDialog = ref(false)
const currentAppointment = ref(null)const showAppointmentContent = (appointment) => {appointmentContentDialog.value = truecurrentAppointment.value = appointment;
}const feedbackList = ref([])
const feedbackDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const submitting = ref(false)
const currentFeedback = reactive({expertName: '',consultationTime: '',rating: 0,feedbackText: ''
})const feedbackForm = reactive({consultationId: '',expertName: '',consultationTime: '',rating: 0,feedbackText: ''
})// 待反馈分页
const currentPendingPage = ref(1)
const pendingPageSize = ref(2)
const totalPending = computed(() => pendingFeedbacks.value.length)// 已反馈分页
const currentCompletedPage = ref(1)
const completedPageSize = ref(2)
const totalCompleted = computed(() => completedFeedbacks.value.length)// 分页数据计算
const paginatedPending = computed(() => {const start = (currentPendingPage.value - 1) * pendingPageSize.valueconst end = start + pendingPageSize.valuereturn pendingFeedbacks.value.slice(start, end)
})const paginatedCompleted = computed(() => {const start = (currentCompletedPage.value - 1) * completedPageSize.valueconst end = start + completedPageSize.valuereturn completedFeedbacks.value.slice(start, end)
})// 分页变更处理
const handlePendingPageChange = (page) => {currentPendingPage.value = page
}const handleCompletedPageChange = (page) => {currentCompletedPage.value = page
}const feedbackRules = {rating: [{ required: true, message: '请选择评分', trigger: 'change' }],feedbackText: [{ required: true, message: '请输入反馈内容', trigger: 'blur' },{ min: 10, message: '至少输入10个字符', trigger: 'blur' }]
}// 计算属性
const pendingFeedbacks = computed(() => {return feedbackList.value.filter(f => f.feedbackStatus === '未反馈')
})const completedFeedbacks = computed(() => {return feedbackList.value.filter(f => f.feedbackStatus === '已反馈')
})// 方法
const loadFeedbackRecords = async () => {try {const res = await instance.proxy.$request.get(`/feedback/${userId.value}`)feedbackList.value = res.feedbacks} catch (error) {ElMessage.error('加载反馈记录失败')}
}const openFeedbackDialog = (record) => {Object.assign(feedbackForm, {feedbackId: record.feedbackId,consultationId: record.consultationId,expertName: record.expertName,consultationTime: record.consultationTime,rating: 0,feedbackText: ''})feedbackDialogVisible.value = true
}const submitFeedback = async () => {submitting.value = truetry {await instance.proxy.$request.post(`/feedback/updateFeedback/${feedbackForm.feedbackId}`, {...feedbackForm,feedbackStatus: '已反馈',feedbackTime: new Date().toISOString(),})ElMessage.success('反馈提交成功')feedbackDialogVisible.value = falseawait loadFeedbackRecords()} catch (error) {ElMessage.error('反馈提交失败')} finally {submitting.value = false}
}const viewFeedbackDetail = (record) => {Object.assign(currentFeedback, record)detailDialogVisible.value = true
}</script><style scoped>
.el-card {--el-card-padding: 0;
}.chat-container {height: 500px;display: flex;flex-direction: column;
}.list-card {padding: 20px;background-color: #b0ffca;box-shadow: 10px 10px 10px #6dffff, -10px -10px 10px #f9f9c6 !important;border-radius: 20px;overflow: hidden;
}.chat-card {box-shadow: 10px 10px 10px #6dffff, -10px -10px 10px #f9f9c6 !important;border-radius: 20px;overflow: hidden;
}:deep(.el-card__header) {border-bottom: 0 !important;padding-bottom: 10px;
}.feedback-card {padding: 20px;background-color: #b0ffca;box-shadow: 10px 10px 10px #6dffff, -10px -10px 10px #f9f9c6 !important;border-radius: 20px;overflow: hidden;
}
</style>
  • 专家端
<template><el-row :gutter="20" style="margin-top: 20px;"><!-- 左侧在线咨询列表 --><el-col :span="12"><el-card class="list-card"><el-table :data="filteredUsers"><el-table-column prop="userId" label="用户ID" width="80" /><el-table-column prop="userName" label="用户名" width="80" /><el-table-column prop="role" label="用户角色" /><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag v-if="users.some(user => user.userName === row.userName)" type="success">在线</el-tag><el-tag v-else type="info">离线</el-tag></template></el-table-column><el-table-column label="操作" width="120"><template #default="{ row }"><el-buttonv-if="activeConsultations[row.userName] && consultationIds && consultationIds.hasOwnProperty(row.userName)"@click="handleContinueConsult(row)" type="warning" size="small"style="margin-left: 5px">继续咨询<el-badge v-if="unreadCount[row.userName]" :value="unreadCount[row.userName]":offset="[21, 0]" :max="10"></el-badge></el-button><el-button v-else-if="existConsultationIds &&existConsultationIds.hasOwnProperty(row.userName)&& users.some(user => user.userName === row.userName)" @click="handleConsult(row)"type="primary" size="small">接受咨询</el-button><el-button v-else type="info" size="small" disabled>未发起咨询</el-button></template></el-table-column></el-table></el-card></el-col><!-- 右侧聊天框 --><el-col :span="12"><el-card style="height: inherit;" class="chat-card"><div v-if="chatUser !== ''"><div class="chat-container"><div style="text-align: center; line-height: 50px;">聊天室({{ chatUser }}</div><div style="height: 350px; overflow:auto; border-top: 1px solid #ccc" v-html="content"></div><div style="height: 250px"><textarea v-model="text" style="height: 160px; width: 100%; padding: 20px; border: none; border-top: 1px solid #ccc;border-bottom: 1px solid #ccc; outline: none" @keydown.enter="handleEnter"></textarea><div style="text-align: right; padding-right: 10px"><el-button type="primary" size="small" @click="send">发送</el-button></div></div></div></div><div v-else style="background-color: #b0ffca;"><el-empty description="请从左侧选择一个在线聊天咨询进行服务" /></div></el-card></el-col></el-row>
</template><script>
import { ref, reactive, onMounted, getCurrentInstance, onUnmounted, nextTick, computed } from 'vue';
import { ElMessage } from 'element-plus';export default {name: 'ExpertConsultationMainTop',setup() {let socket = ref(null)const instance = getCurrentInstance();const user = reactive(JSON.parse(sessionStorage.getItem("user")) || {})const users = ref([])const notExpertUsers = ref([]);const avatarNowUserUrl = new URL('@/assets/avatar/avatar_1.png', import.meta.url).href;const avatarRemoteUserUrl = new URL('@/assets/avatar/avatar_2.png', import.meta.url).href;const chatUser = ref('')const text = ref('')const content = ref('')const chatHistory = reactive({}); // 新增:存储各用户的聊天记录// 添加未读计数const unreadCount = reactive({});const activeConsultations = reactive({});const currentChatUser = ref(''); // 跟踪当前聊天用户const consultationIds = reactive(sessionStorage.getItem('consultationIds')? JSON.parse(sessionStorage.getItem('consultationIds')): {}); // 存储用户与咨询ID的映射 {userName: consultationId}const existConsultationIds = reactive({}) //临时存储existConsultationIds,直到专家点击接受咨询后再赋值给consultationIdsonMounted(() => {const savedIds = sessionStorage.getItem('consultationIds');const savedConsultations = sessionStorage.getItem('activeConsultations');const savedHistory = sessionStorage.getItem('chatHistory');if (savedIds) {if (savedIds) {try {const parsed = JSON.parse(savedIds);if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {Object.keys(parsed).forEach(key => {consultationIds[key] = parsed[key];});} else {console.warn('Invalid consultationIds format:', parsed);}} catch (e) {console.error('Failed to parse consultationIds:', e);}}}if (savedConsultations) {Object.assign(activeConsultations, JSON.parse(savedConsultations));}if (savedHistory) {Object.assign(chatHistory, JSON.parse(savedHistory));}init();loadNotExpertUsers();});const loadNotExpertUsers = async () => {try {const res = await instance.proxy.$request.get('/user/notExpertUsers');notExpertUsers.value = res.notExpertUsers;} catch (err) {ElMessage.error('加载在线用户列表失败!');}};// 计算属性过滤数据源const filteredUsers = computed(() => {return notExpertUsers.value.filter(user =>users.value.some(u => u.userName === user.userName));});const handleConsult = async (row) => {// 设置接受咨询后的咨询状态activeConsultations[row.userName] = true;currentChatUser.value = row.userName;// 切换用户时重置未读unreadCount[row.userName] = 0;chatUser.value = row.userName;// 存储consultationId(关键修改)  该值应该在专家点击接收后才能赋值consultationIds[row.userName] = existConsultationIds[row.userName]sessionStorage.setItem('consultationIds', JSON.stringify(consultationIds));// 初始化聊天记录if (!chatHistory[row.userName]) {chatHistory[row.userName] = [];}content.value = chatHistory[row.userName].join('');// 发送接受咨询const message = {type: 'consult_request',from: user.userName,to: row.userName,text: `${user.userName}接受了咨询请求`,consultationId: consultationIds[row.userName]};const html = createContent('system', null, null, message.text); // 传递消息类型chatHistory[row.userName].push(html);content.value = chatHistory[row.userName].join('');if (socket.value) {socket.value.send(JSON.stringify(message));}// 滚动到底部nextTick(() => {const container = document.querySelector('.chat-container div[style*="overflow"]');if (container) container.scrollTop = container.scrollHeight;});text.value = ''//修改会话状态为 进行中 // *************await instance.proxy.$request.put(`/consultation/consulting/${consultationIds[row.userName]}`, {status: '进行中'});};// 继续咨询处理const handleContinueConsult = (row) => {//新增咨询ID验证if (!consultationIds[row.userName]) {ElMessage.warning('未找到有效的咨询记录');}// 设置咨询状态activeConsultations[row.userName] = true;currentChatUser.value = row.userName;unreadCount[row.userName] = 0;chatUser.value = row.userName;content.value = chatHistory[row.userName]?.join('') || '';// 滚动到底部nextTick(() => {const container = document.querySelector('.chat-container div[style*="overflow"]');if (container) container.scrollTop = container.scrollHeight;});text.value = ''};//接收到结束咨询的处理const handleEndConsult = async (from) => {if (!currentChatUser.value)console.log("当前聊天用户不存在currentChatUser.value", currentChatUser);try {// 获取当前咨询ID(关键新增)const consultationId = consultationIds[from];if (!consultationId) {ElMessage.error('未找到咨询记录ID');}// 发送结束通知(修改为只在后台发送通知,而不发给currentChatUser.value)// const message = {//     type: 'consult_end',//     from: user.userName,//     to: currentChatUser.value,//     text: `${user.userName}结束了本次咨询`,//     consultationId: consultationId // 新增携带咨询ID// };// socket.value.send(JSON.stringify(message));// 清除本地状态(新增清理consultationIds,existConsultationIds)delete consultationIds[from];delete existConsultationIds[from]sessionStorage.setItem('consultationIds', JSON.stringify(consultationIds));// 清除状态activeConsultations[from] = false;delete activeConsultations[from];currentChatUser.value = '';chatUser.value = '';content.value = '';} catch (err) {ElMessage.error('结束咨询失败');}};// 修改后的send方法const send = () => {if (!chatUser.value || !text.value.trim()) {ElMessage.warning("请选择专家并输入内容");return;}const message = {from: user.userName,to: chatUser.value,text: text.value};// 发送WebSocket消息socket.value.send(JSON.stringify(message));// 保存到本地记录const html = createContent(null, null, user.userName, text.value);chatHistory[chatUser.value].push(html);content.value = chatHistory[chatUser.value].join('');text.value = '';nextTick(() => {const container = document.querySelector('.chat-container div[style*="overflow"]');if (container) {container.scrollTop = container.scrollHeight;}});};const createContent = (messageType, remoteUser, nowUser, text) => {let html = '';if (messageType === 'system') { // 新增系统消息类型html = `<div class="el-row" style="padding: 5px 0; margin: 2px; background-color: #f0f4ff; border: 4px solid #409eff; border-radius: 10px;"><div class="el-col el-col-22" style="text-align: center; font-weight: 700;"><div class="tip system">系统提示:${text}</div></div></div>`;}else if (nowUser) {html = `<div class="el-row" style="padding: 5px 0"><div class="el-col el-col-22" style="text-align: right; padding-right: 10px"><div class="tip left">${text}</div></div><div class="el-col el-col-2"><span class="el-avatar el-avatar--circle" style="height: 40px; width: 40px; line-height: 40px;"><img src="${avatarRemoteUserUrl}" style="object-fit: cover;"></span></div></div>`} else if (remoteUser) {html = `<div class="el-row" style="padding: 5px 0"><div class="el-col el-col-2" style="text-align: right"><span class="el-avatar el-avatar--circle" style="height: 40px; width: 40px; line-height: 40px;"><img src="${avatarNowUserUrl}" style="object-fit: cover;"></span></div><div class="el-col el-col-22" style="text-align: left; padding-left: 10px"><div class="tip right">${text}</div></div></div>`}return html;}const init = () => {const userName = user.userNameif (typeof (WebSocket) == "undefined") {console.log("您的浏览器不支持WebSocket")return}const socketUrl = `ws://localhost:8080/server/${userName}`if (socket.value) {socket.value.close()socket.value = null}socket.value = new WebSocket(socketUrl)socket.value.onopen = () => {console.log("websocket已打开")}socket.value.onmessage = (msg) => {const data = JSON.parse(msg.data)if (data.users) {users.value = data.users.filter(u => u.userName !== userName)}//处理对方发起咨询请求else if (data.type === 'consult_request') {const fromUser = data.from;// 初始化记录if (!chatHistory[fromUser]) chatHistory[fromUser] = [];// 生成消息内容const html = createContent('system', null, null, data.text);chatHistory[fromUser].push(html);// 存储consultationId(关键修改)  该值应该在专家点击接收后才能赋值existConsultationIds[data.from] = data.consultationIdElMessage.warning(`${data.text}`);}// 处理结束咨询消息else if (data.type === 'consult_end') {const fromUser = data.from;if (chatUser.value === fromUser) {chatUser.value = '';content.value = '';}activeConsultations[fromUser] = false;handleEndConsult(data.from);ElMessage.warning(`${fromUser}结束了咨询`);}// 处理用户离开界面else if (data.type === 'force_close') {const fromUser = data.from;if (chatUser.value === fromUser) {chatUser.value = '';content.value = '';}activeConsultations[fromUser] = false;sessionStorage.setItem('activeConsultations', JSON.stringify(activeConsultations));ElMessage.warning(`${data.text}`);}//正常信息对话else {const fromUser = data.from;// 初始化记录if (!chatHistory[fromUser]) chatHistory[fromUser] = [];// 生成消息内容const html = createContent(null, fromUser, null, data.text);chatHistory[fromUser].push(html);// 更新显示if (chatUser.value === fromUser) {content.value = chatHistory[fromUser].join('');} else {// 更新未读计数unreadCount[fromUser] = (unreadCount[fromUser] || 0) + 1;}}}socket.value.onclose = () => {console.log("websocket已关闭")}socket.value.onerror = () => {console.log("websocket发生了错误")}}// 统一的关闭WebSocket方法const closeSocket = () => {if (socket.value) {socket.value.close()  // 主动调用WebSocket的close方法socket.value = nullconsole.log('WebSocket连接已主动关闭')}}// 组件卸载时onUnmounted(() => {sessionStorage.setItem('activeConsultations', JSON.stringify(activeConsultations));sessionStorage.setItem('chatHistory', JSON.stringify(chatHistory));closeSocket()})const handleEnter = (event) => {event.preventDefault(); // 阻止默认换行行为send();};return {user,users,filteredUsers,chatUser,text,content,send,avatarNowUserUrl,avatarRemoteUserUrl,handleConsult,handleContinueConsult,handleEnter,unreadCount,activeConsultations,existConsultationIds,consultationIds};},
};
</script><style scoped>
.el-card {--el-card-padding: 0;
}.chat-container {height: 500px;display: flex;flex-direction: column;
}.list-card {padding: 20px;background-color: #b0ffca;box-shadow: 10px 10px 10px #6dffff, -10px -10px 10px #f9f9c6 !important;border-radius: 20px;overflow: hidden;
}.chat-card {box-shadow: 10px 10px 10px #6dffff, -10px -10px 10px #f9f9c6 !important;border-radius: 20px;overflow: hidden;
}.time {font-size: 12px;color: #999;margin-top: 4px;
}/* 添加未读标记样式 */
.unread-badge {background: #f56c6c;color: white;border-radius: 11px;min-width: 10px;height: 18px;line-height: 18px;text-align: center;font-size: 10px;padding: 0 4px;margin: 0px 0px 0px 3px;
}/* 确保禁用按钮样式明显 */
.el-button.is-disabled {opacity: 0.6;cursor: not-allowed;
}
</style>
主要后端代码如下
package com.example.doms.component;import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;/*** @author websocket服务*/
@ServerEndpoint(value = "/server/{userName}")
@Component
public class WebSocketServer {private static final Logger log = LoggerFactory.getLogger(WebSocketServer.class);/*** 记录当前在线连接数*/public static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();/*** 连接建立成功调用的方法*/@OnOpenpublic void onOpen(Session session, @PathParam("userName") String userName) {sessionMap.put(userName, session);log.info("有新用户加入userName={},当前在线人数为:{}", userName, sessionMap.size());JSONObject result = new JSONObject();JSONArray array = new JSONArray();result.set("users", array);for (Object key : sessionMap.keySet()) {JSONObject jsonObject = new JSONObject();jsonObject.set("userName", key);// {"userName", "zhang", "userName": "admin"}array.add(jsonObject);}
//        {"users": [{"userName": "zhang"},{ "userName": "admin"}]}sendAllMessage(JSONUtil.toJsonStr(result));  // 后台发送消息给所有的客户端}/*** 连接关闭调用的方法*/@OnClosepublic void onClose(Session session, @PathParam("userName") String userName) {sessionMap.remove(userName);log.info("有一连接关闭,移除userName={}的用户session, 当前在线人数为:{}", userName, sessionMap.size());// 新增:通知所有客户端用户列表更新(可用在用户退出的时候,实时更新是否在线)JSONObject result = new JSONObject();JSONArray array = new JSONArray();result.set("users", array);for (Object key : sessionMap.keySet()) {JSONObject jsonObject = new JSONObject();jsonObject.set("userName", key);array.add(jsonObject);}sendAllMessage(JSONUtil.toJsonStr(result)); // 广播更新后的用户列表}/*** 收到客户端消息后调用的方法* 后台收到客户端发送过来的消息* onMessage 是一个消息的中转站* 接受 浏览器端 socket.send 发送过来的 json数据* @param message 客户端发送过来的消息*/@OnMessagepublic void onMessage(String message, Session session, @PathParam("userName") String userName) {log.info("服务端收到用户userName={}的消息:{}", userName, message);JSONObject obj = JSONUtil.parseObj(message);// 处理咨询请求(新增)if ("consult_request".equals(obj.getStr("type"))) {String touserName = obj.getStr("to");String text = obj.getStr("text");int consultationId = obj.getInt("consultationId");Session toSession = sessionMap.get(touserName);if (toSession != null) {JSONObject jsonObject = new JSONObject();jsonObject.set("type", "consult_request");jsonObject.set("from", userName);jsonObject.set("text", text);jsonObject.set("consultationId", consultationId);sendMessage(jsonObject.toString(), toSession);log.info("已向专家{}发送咨询请求通知(咨询对话ID:{})", touserName,consultationId);}return; // 结束处理}// 结束对话处理:if ("consult_end".equals(obj.getStr("type"))) {String touserName = obj.getStr("to");String text = obj.getStr("text");Session toSession = sessionMap.get(touserName);if (toSession != null) {JSONObject endMsg = new JSONObject();endMsg.set("type", "consult_end");endMsg.set("from", userName);endMsg.set("text", text);sendMessage(endMsg.toString(), toSession);log.info("用户userName={}已结束对专家userName={}咨询", userName,touserName);}return;}//用户离开页面if ("force_close".equals(obj.getStr("type"))) {String fromUserName = obj.getStr("from");JSONArray toUserNames = obj.getJSONArray("to"); // 接收专家列表String text = obj.getStr("text");// 1. 防御性检查:确保 to 字段是有效数组if (toUserNames == null || toUserNames.isEmpty()) {log.info("用户 {} 离开页面", fromUserName);return;}// 遍历所有关联专家for (int i = 0; i < toUserNames.size(); i++) {String expertUserName = toUserNames.getStr(i);Session expertSession = sessionMap.get(expertUserName);if (expertSession != null) {JSONObject endMsg = new JSONObject();endMsg.set("type", "force_close");endMsg.set("from", fromUserName);endMsg.set("text", text);sendMessage(endMsg.toString(), expertSession);log.info("用户 {} 离开页面,已通知专家 {}", fromUserName, expertUserName);}}return;}String touserName = obj.getStr("to"); // to表示发送给哪个用户,比如 adminString text = obj.getStr("text"); // 发送的消息文本  hello// {"to": "admin", "text": "聊天文本"}Session toSession = sessionMap.get(touserName); // 根据 to用户名来获取 session,再通过session发送消息文本if (toSession != null) {// 服务器端 再把消息组装一下,组装后的消息包含发送人和发送的文本内容// {"from": "zhang", "text": "hello"}JSONObject jsonObject = new JSONObject();jsonObject.set("from", userName);  // from 是 zhangjsonObject.set("text", text);  // text 同上面的textthis.sendMessage(jsonObject.toString(), toSession);log.info("发送给用户userName={},消息:{}", touserName, jsonObject.toString());} else {log.info("发送失败,未找到用户userName={}的session", touserName);}}@OnErrorpublic void onError(Session session, Throwable error) {log.error("发生错误");error.printStackTrace();}/*** 服务端发送消息给客户端*/private void sendMessage(String message, Session toSession) {try {log.info("服务端给客户端[{}]发送消息{}", toSession.getId(), message);toSession.getBasicRemote().sendText(message);} catch (Exception e) {log.error("服务端发送消息给客户端失败", e);}}/*** 服务端发送消息给所有客户端*/private void sendAllMessage(String message) {try {for (Session session : sessionMap.values()) {log.info("服务端给客户端[{}]发送消息{}", session.getId(), message);session.getBasicRemote().sendText(message);}} catch (Exception e) {log.error("服务端发送消息给客户端失败", e);}}
}
  • 本文仅作个人学习笔记使用,无商业用途。
  • 如若转载,请先声明。

相关文章:

  • Hive JOIN 优化策略详解
  • jMeter压测环境部署JDK+Groovy+JMeter+Proto+IntelliJ IDEA
  • MySQL 从入门到精通(二):DML 数据操作与 DQL 数据查询详解
  • Hive表JOIN性能问
  • K8S服务的请求访问转发原理
  • 【程序员AI入门:开发】11.从零构建智能问答引擎:LangChain + RAG 实战手册
  • Unreal 从入门到精通之VR常用操作
  • Java SpringBoot基于协同过滤算法的电影推荐系统,附源码+文档说明
  • 更换内存条会影响电脑的IP地址吗?——全面解析
  • 全国青少年信息素养大赛 Python编程挑战赛初赛 内部集训模拟试卷六及详细答案解析
  • 如何修改进程优先级?
  • 以影像为笔,劳润智在世界舞台上书写艺术之路
  • vim 查看复杂的宏扩展
  • 人形机器人:主控芯片
  • JGL066生活垃圾滚筒筛分选机实验装置
  • 车载电子电器架构 --- 汽车网关概述
  • 《P7167 [eJOI 2020] Fountain (Day1)》
  • RK3568-OpenHarmony(1) : OpenHarmony 5.1的编译
  • 湖仓一体架构在金融典型数据分析场景中的实践
  • WPF中如何自定义控件
  • 跨越时空的“精神返乡”,叶灵凤藏书票捐赠上海文学馆
  • 欧盟委员会计划对950亿欧元美国进口产品采取反制措施
  • 大风暴雨致湖南岳阳县6户房屋倒塌、100多户受损
  • 两部门发布外汇领域行刑反向衔接案例,织密金融安全“防护网”
  • 山东滕州车祸致6人遇难,醉驾肇事司机已被刑事拘留
  • “五一”假期文旅热度创近3年新高,入境游订单飙升130%