文章目录
技术栈
- Spring Boot
- Vue3
- MyBatis
- ECharts
- Tensorflow
- ElementUI
主要功能
- 害虫识别与定位功能:利用Tensorflow深度学习框架,在预训练模型MobileNetV2的基础上进行训练优化,对训练集9840张,测试集2513张害虫图片进行训练,每种害虫大体上按照4:1的比例划分训练集和测试集,并生成可直接部署的模型文件并编写预测脚本。利用高德API对每种害虫出现地点进行标记定位并提供相关定位功能(步行、驾车、害虫定位、当前定位等)。
- 专家咨询功能:利用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_names
raw_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 = 5
total_epochs = 50 # 总训练周期
if epoch < warmup_epochs:
return LEARNING_RATE * (epoch + 1) / warmup_epochs
progress = (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 = False
inputs = 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) # 分层设置Dropout
outputs = 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 = False
for layer in base_model.layers[unfreeze_from:]:
layer.trainable = True
# 微调阶段
model.layers[2].trainable = True
unfreeze_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.optimizer
optimizer.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 {
@Autowired
private 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();
}
}
}
害虫识别过程展示
- 害虫识别初始显示:通过点击左侧蓝色按钮“点击上传害虫图片”进行上传图片进行上传,右侧将展示识别三个识别结果(由模型识别准确率前三决定)
- 上传图片
- 展示害虫识别结果。从左到右依次为模型认为准确率前三的害虫类别。
害虫定位实现
害虫定位代码
基于高德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" background
style="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>
害虫定位过程展示
- 一开始地图将展示当前定位位置,右侧害虫分布将展示系统内数据库所存在的所有害虫上报的位置信息(包括有经纬度和无经纬度信息的)
- 点击对应害虫的步行/驾车按钮效果如下图所示:
- 点击对应害虫的“定位”按钮,将会把地图中心移至害虫标记所在位置,如图所示:
- 点击“清除路线”按钮,将清除地图上所有的路线规划,并且地图中心将回到当前定位位置。
- “所属果园害虫分布”按钮是为系统角色“工作人员”专设,将为“工作人员”展示所属果园的害虫分布,方便“工作人员”进行定位处理,点击“所有果园害虫分布”按钮将展示系统内所保存的所有果园的害虫分布信息。
专家咨询功能
在线咨询聊天室
用户可以对所有在线的专家发起咨询请求,并进行聊天室的咨询对话。如图所示:
- 选择对应专家进行“立即咨询”操作
- 专家接受咨询
- 用户与专家聊天室进行咨询
主要前端代码如下
- 工作人员端
<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" background
style="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" background
style="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;">
<div
style="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" background
style="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;">
<div
style="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" background
style="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.userName
if (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 = null
console.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 = true
currentAppointment.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.value
const end = start + pendingPageSize.value
return pendingFeedbacks.value.slice(start, end)
})
const paginatedCompleted = computed(() => {
const start = (currentCompletedPage.value - 1) * completedPageSize.value
const end = start + completedPageSize.value
return 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 = true
try {
await instance.proxy.$request.post(`/feedback/updateFeedback/${feedbackForm.feedbackId}`, {
...feedbackForm,
feedbackStatus: '已反馈',
feedbackTime: new Date().toISOString(),
})
ElMessage.success('反馈提交成功')
feedbackDialogVisible.value = false
await 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-button
v-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,直到专家点击接受咨询后再赋值给consultationIds
onMounted(() => {
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.userName
if (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.consultationId
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;
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 = null
console.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<>();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public 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)); // 后台发送消息给所有的客户端
}
/**
* 连接关闭调用的方法
*/
@OnClose
public 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 客户端发送过来的消息
*/
@OnMessage
public 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表示发送给哪个用户,比如 admin
String 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 是 zhang
jsonObject.set("text", text); // text 同上面的text
this.sendMessage(jsonObject.toString(), toSession);
log.info("发送给用户userName={},消息:{}", touserName, jsonObject.toString());
} else {
log.info("发送失败,未找到用户userName={}的session", touserName);
}
}
@OnError
public 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);
}
}
}
- 本文仅作个人学习笔记使用,无商业用途。
- 如若转载,请先声明。