02_28_version_1

This commit is contained in:
root 2026-02-28 07:15:26 +08:00
parent 86c65ece9f
commit 1810d40c01
11 changed files with 537 additions and 615 deletions

39
backend/Dockerfile Normal file
View File

@ -0,0 +1,39 @@
# ============================================
# 农产品直销平台 - 后端 Dockerfile
# 多阶段构建: Maven编译 → JRE运行
# ============================================
# ---------- 第一阶段Maven构建 ----------
FROM maven:3.8-eclipse-temurin-17 AS builder
WORKDIR /build
# 配置Maven阿里云镜像加速依赖下载
RUN mkdir -p /root/.m2 && \
echo '<?xml version="1.0" encoding="UTF-8"?><settings><mirrors><mirror><id>aliyun</id><mirrorOf>*</mirrorOf><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /root/.m2/settings.xml
# 先复制pom.xml利用Docker缓存层加速依赖不变时跳过下载
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 复制源代码并打包
COPY src ./src
RUN mvn clean package -DskipTests
# ---------- 第二阶段:运行时 ----------
FROM eclipse-temurin:17-jre
WORKDIR /app
# 设置时区
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 创建日志目录
RUN mkdir -p /app/logs
# 从构建阶段复制JAR包
COPY --from=builder /build/target/sunny-farm-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
# 启动命令支持通过JAVA_OPTS传入JVM参数
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

View File

@ -2,7 +2,6 @@ package com.sunnyfarm.dto.express;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 物流综合信息DTO包含轨迹和地图数据
@ -10,103 +9,45 @@ import java.util.Map;
@Data
public class ExpressInfoDTO {
/**
* 快递单号
*/
/** 快递单号 */
private String trackingNumber;
/**
* 快递公司名称
*/
/** 快递公司名称 */
private String companyName;
/**
* 快递公司代码
*/
/** 快递公司代码 */
private String companyCode;
/**
* 物流状态
*/
/** 物流状态 */
private String status;
/**
* 当前位置
*/
/** 当前位置 */
private String currentLocation;
/**
* 更新时间
*/
/** 更新时间 */
private String updateTime;
/**
* 物流轨迹列表
*/
private List<TraceItem> traces;
/** 运输时长 */
private String takeTime;
/**
* 地图路线数据
*/
private MapRouteData mapRoute;
/** 快递员电话 */
private String courierPhone;
/** 物流轨迹地图URL可直接iframe嵌入 */
private String mapUrl;
/** 物流轨迹列表 */
private List<TraceItem> traces;
@Data
public static class TraceItem {
/**
* 时间
*/
/** 时间 */
private String time;
/**
* 地点
*/
/** 地点 */
private String location;
/**
* 状态描述
*/
/** 状态描述 */
private String status;
}
@Data
public static class MapRouteData {
/**
* 城市列表用于标记
*/
private List<CityPoint> cities;
/**
* 路线坐标点如果有路径规划
*/
private List<List<Double>> routePoints;
/**
* 路线信息
*/
private String distance;
private String duration;
}
@Data
public static class CityPoint {
/**
* 城市名称
*/
private String name;
/**
* 经度
*/
private Double lng;
/**
* 纬度
*/
private Double lat;
/**
* 序号
*/
private Integer index;
/** 动作类型1揽收,2运输,202派件,211投柜,311签收 */
private String action;
}
}

View File

@ -1,8 +1,6 @@
package com.sunnyfarm.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sunnyfarm.config.properties.AliyunExpressProperties;
import com.sunnyfarm.config.properties.AmapProperties;
import com.sunnyfarm.dto.express.ExpressInfoDTO;
import com.sunnyfarm.dto.express.ExpressTraceDTO;
import com.sunnyfarm.entity.Order;
@ -13,16 +11,19 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 物流查询服务实现
* 使用两个API
* 1. 旧APIwuliu.market.alicloudapi.com查询物流轨迹信息
* 2. 新APIlhkdwlgjdt.market.alicloudapi.com查询物流轨迹地图
*/
@Slf4j
@Service
@ -30,57 +31,46 @@ import java.util.stream.Collectors;
public class ExpressServiceImpl implements ExpressService {
private final AliyunExpressProperties expressProperties;
private final AmapProperties amapProperties;
private final OrderMapper orderMapper;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
// 预设主要城市坐标
private static final Map<String, double[]> CITY_COORDINATES = new HashMap<>();
static {
CITY_COORDINATES.put("广州市", new double[]{113.264385, 23.129163});
CITY_COORDINATES.put("深圳市", new double[]{114.057868, 22.543099});
CITY_COORDINATES.put("北京市", new double[]{116.407526, 39.904030});
CITY_COORDINATES.put("上海市", new double[]{121.473701, 31.230416});
CITY_COORDINATES.put("杭州市", new double[]{120.153576, 30.287459});
CITY_COORDINATES.put("南京市", new double[]{118.796877, 32.060255});
CITY_COORDINATES.put("成都市", new double[]{104.065735, 30.659462});
CITY_COORDINATES.put("武汉市", new double[]{114.305393, 30.593099});
CITY_COORDINATES.put("西安市", new double[]{108.939621, 34.341568});
CITY_COORDINATES.put("重庆市", new double[]{106.551557, 29.563009});
CITY_COORDINATES.put("天津市", new double[]{117.200983, 39.084158});
CITY_COORDINATES.put("苏州市", new double[]{120.585315, 31.298886});
CITY_COORDINATES.put("东莞市", new double[]{113.751765, 23.020673});
CITY_COORDINATES.put("佛山市", new double[]{113.121416, 23.021548});
CITY_COORDINATES.put("惠州市", new double[]{114.416196, 23.111847});
}
// 物流轨迹地图API地址
private static final String TRACE_MAP_URL = "https://lhkdwlgjdt.market.alicloudapi.com/express/trace-map/v2";
@Override
public ExpressInfoDTO queryExpressInfo(String trackingNumber, String companyCode) {
log.info("🔍 查询物流信息 - 单号: {}, 公司代码: {}", trackingNumber, companyCode);
try {
// 1. 调用阿里云物流查询API
ExpressTraceDTO traceData = queryExpressTrace(trackingNumber, companyCode);
if (traceData == null || !"0".equals(traceData.getStatus())) {
throw new BusinessException("物流信息查询失败");
}
// 1. 用旧API查询物流轨迹
String oldApiCode = convertToOldApiCode(companyCode);
ExpressTraceDTO traceData = queryExpressTrace(trackingNumber, oldApiCode);
// 2. 解析物流轨迹数据
ExpressInfoDTO result = parseExpressData(traceData);
ExpressInfoDTO result = parseExpressData(traceData, trackingNumber);
// 3. 提取城市并规划路线
List<String> cities = extractCities(traceData);
if (!cities.isEmpty()) {
ExpressInfoDTO.MapRouteData routeData = planRoute(cities);
result.setMapRoute(routeData);
// 3. 用新API查询物流地图不影响主流程失败也不报错
try {
String mapApiCode = convertToMapApiCode(companyCode);
// 从trackingNumber中提取手机尾号如果有的话
String phone = null;
String pureTrackingNumber = trackingNumber;
if (trackingNumber.contains(":")) {
String[] parts = trackingNumber.split(":");
pureTrackingNumber = parts[0];
phone = parts[1];
}
String mapUrl = queryTraceMap(pureTrackingNumber, mapApiCode, phone);
result.setMapUrl(mapUrl);
} catch (Exception e) {
log.warn("⚠️ 物流地图查询失败(不影响轨迹展示): {}", e.getMessage());
}
log.info("✅ 物流信息查询成功 - 单号: {}", trackingNumber);
return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("❌ 物流信息查询失败", e);
throw new BusinessException("物流信息查询失败: " + e.getMessage());
@ -100,14 +90,56 @@ public class ExpressServiceImpl implements ExpressService {
throw new BusinessException("订单暂无物流信息");
}
// 将订单的物流公司名称转换为代码
String companyCode = convertCompanyNameToCode(order.getShipCompany());
String trackingNumber = order.getShipNo();
return queryExpressInfo(order.getShipNo(), companyCode);
// 顺丰需要拼接手机尾号到trackingNumber旧API需要
String phone = null;
if ("SF".equals(companyCode) && order.getPhone() != null && order.getPhone().length() >= 4) {
phone = order.getPhone().substring(order.getPhone().length() - 4);
if (!trackingNumber.contains(":")) {
trackingNumber = trackingNumber + ":" + phone;
log.info("📱 顺丰快递自动拼接手机尾号: {} -> {}", order.getShipNo(), trackingNumber);
}
}
// 对于新地图API中通也需要手机号
if ("ZTO".equals(companyCode) && phone == null && order.getPhone() != null && order.getPhone().length() >= 4) {
phone = order.getPhone().substring(order.getPhone().length() - 4);
}
try {
// 1. 用旧API查询物流轨迹
String oldApiCode = convertToOldApiCode(companyCode);
ExpressTraceDTO traceData = queryExpressTrace(trackingNumber, oldApiCode);
// 2. 解析物流轨迹数据
ExpressInfoDTO result = parseExpressData(traceData, order.getShipNo());
// 3. 用新API查询物流地图
try {
String mapApiCode = convertToMapApiCode(companyCode);
String mapUrl = queryTraceMap(order.getShipNo(), mapApiCode, phone);
result.setMapUrl(mapUrl);
} catch (Exception e) {
log.warn("⚠️ 物流地图查询失败(不影响轨迹展示): {}", e.getMessage());
}
log.info("✅ 物流信息查询成功 - 单号: {}", order.getShipNo());
return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("❌ 物流信息查询失败", e);
throw new BusinessException("物流信息查询失败: " + e.getMessage());
}
}
// ==================== 旧API查询物流轨迹 ====================
/**
* 调用阿里云物流查询API
* 调用旧API查询物流轨迹
*/
private ExpressTraceDTO queryExpressTrace(String trackingNumber, String companyCode) {
try {
@ -123,56 +155,58 @@ public class ExpressServiceImpl implements ExpressService {
HttpEntity<String> entity = new HttpEntity<>(headers);
log.info("📡 调用阿里云物流API: {}", url);
log.info("📡 调用物流轨迹API: {}", url);
ResponseEntity<ExpressTraceDTO> response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
ExpressTraceDTO.class
);
url, HttpMethod.GET, entity, ExpressTraceDTO.class);
log.info("✅ 阿里云物流API响应成功");
return response.getBody();
ExpressTraceDTO body = response.getBody();
log.info("✅ 物流轨迹API响应 - status={}, msg={}",
body != null ? body.getStatus() : "null",
body != null ? body.getMsg() : "null");
if (body == null || (!"0".equals(body.getStatus()) && !"ok".equals(body.getMsg()))) {
String msg = body != null ? body.getMsg() : "返回为空";
throw new BusinessException("物流信息查询失败: " + msg);
}
return body;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("❌ 调用阿里云物流API失败", e);
log.error("❌ 调用物流轨迹API失败", e);
throw new BusinessException("物流查询服务异常");
}
}
/**
* 解析物流数据
* 解析物流轨迹数据
*/
private ExpressInfoDTO parseExpressData(ExpressTraceDTO traceData) {
private ExpressInfoDTO parseExpressData(ExpressTraceDTO traceData, String trackingNumber) {
ExpressInfoDTO result = new ExpressInfoDTO();
ExpressTraceDTO.ExpressResult data = traceData.getResult();
result.setTrackingNumber(data.getNumber());
result.setTrackingNumber(trackingNumber);
result.setCompanyName(data.getExpName());
result.setCompanyCode(data.getType());
result.setStatus(getStatusText(data.getDeliverystatus()));
result.setUpdateTime(data.getUpdateTime());
result.setCourierPhone(data.getCourierPhone());
// 转换物流轨迹
if (data.getList() != null && !data.getList().isEmpty()) {
List<ExpressInfoDTO.TraceItem> traces = data.getList().stream()
.map(trace -> {
ExpressInfoDTO.TraceItem item = new ExpressInfoDTO.TraceItem();
item.setTime(trace.getTime());
item.setStatus(trace.getStatus());
// 提取地点信息
String location = extractLocation(trace.getStatus());
item.setLocation(location);
item.setLocation(extractLocation(trace.getStatus()));
return item;
})
.collect(Collectors.toList());
result.setTraces(traces);
// 设置当前位置最新的轨迹
if (!traces.isEmpty()) {
result.setCurrentLocation(traces.get(0).getLocation());
}
@ -181,201 +215,114 @@ public class ExpressServiceImpl implements ExpressService {
return result;
}
/**
* 提取城市列表
*/
private List<String> extractCities(ExpressTraceDTO traceData) {
Set<String> citySet = new LinkedHashSet<>();
// ==================== 新API查询物流轨迹地图 ====================
if (traceData.getResult() != null && traceData.getResult().getList() != null) {
for (ExpressTraceDTO.ExpressTrace trace : traceData.getResult().getList()) {
String location = extractLocation(trace.getStatus());
if (location != null && !location.isEmpty()) {
// 尝试匹配城市名称
for (String city : CITY_COORDINATES.keySet()) {
if (location.contains(city.replace("", ""))) {
citySet.add(city);
break;
}
}
}
}
/**
* 调用新API查询物流轨迹地图返回mapUrl
*/
@SuppressWarnings("unchecked")
private String queryTraceMap(String trackingNumber, String companyCode, String phone) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "APPCODE " + expressProperties.getAppCode());
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("shipperCode", companyCode);
body.add("logisticCode", trackingNumber);
body.add("sort", "asc");
if (phone != null && !phone.isEmpty()) {
body.add("phone", phone);
}
List<String> cities = new ArrayList<>(citySet);
// 反转列表使其按照物流顺序排列
Collections.reverse(cities);
log.info("📡 调用物流地图API - 单号: {}, 公司: {}, phone: {}", trackingNumber, companyCode, phone);
log.info("🏙️ 提取到的城市列表: {}", cities);
return cities;
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
Map<String, Object> response = restTemplate.postForObject(TRACE_MAP_URL, request, Map.class);
if (response == null || !Boolean.TRUE.equals(response.get("success"))) {
String msg = response != null ? (String) response.get("msg") : "返回为空";
log.warn("⚠️ 物流地图API返回失败: {}", msg);
return null;
}
Map<String, Object> data = (Map<String, Object>) response.get("data");
if (data != null) {
String mapUrl = (String) data.get("mapUrl");
log.info("🗺️ 获取到物流地图URL: {}", mapUrl);
return mapUrl;
}
return null;
}
// ==================== 编码转换 ====================
/**
* 统一内部编码基于快递鸟标准
*/
private String convertCompanyNameToCode(String companyName) {
if (companyName == null) return null;
Map<String, String> companyMap = new LinkedHashMap<>();
companyMap.put("顺丰", "SF");
companyMap.put("圆通", "YTO");
companyMap.put("中通", "ZTO");
companyMap.put("韵达", "YD");
companyMap.put("申通", "STO");
companyMap.put("邮政", "EMS");
companyMap.put("EMS", "EMS");
companyMap.put("京东", "JD");
companyMap.put("极兔", "JTSD");
for (Map.Entry<String, String> entry : companyMap.entrySet()) {
if (companyName.contains(entry.getKey())) {
return entry.getValue();
}
}
return null;
}
/**
* 提取地点信息
* 转换为旧APIwuliu的编码
*/
private String convertToOldApiCode(String code) {
if (code == null) return null;
Map<String, String> map = new HashMap<>();
map.put("SF", "SFEXPRESS");
map.put("YTO", "YTO");
map.put("ZTO", "ZTO");
map.put("YD", "YUNDA");
map.put("STO", "STO");
map.put("EMS", "EMS");
map.put("JD", "JD");
map.put("JTSD", "JTSD");
return map.getOrDefault(code, code);
}
/**
* 转换为新API快递鸟地图的编码
*/
private String convertToMapApiCode(String code) {
// 新API用的就是快递鸟标准编码和内部编码一致
return code;
}
// ==================== 工具方法 ====================
private String extractLocation(String status) {
if (status == null) return "";
// 匹配城市格式
Pattern pattern = Pattern.compile("【(.+?)】");
Matcher matcher = pattern.matcher(status);
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("【(.+?)】");
java.util.regex.Matcher matcher = pattern.matcher(status);
if (matcher.find()) {
return matcher.group(1);
}
return "";
}
/**
* 规划路线
*/
private ExpressInfoDTO.MapRouteData planRoute(List<String> cities) {
ExpressInfoDTO.MapRouteData routeData = new ExpressInfoDTO.MapRouteData();
// 添加城市标记点
List<ExpressInfoDTO.CityPoint> cityPoints = new ArrayList<>();
for (int i = 0; i < cities.size(); i++) {
String city = cities.get(i);
double[] coords = CITY_COORDINATES.get(city);
if (coords != null) {
ExpressInfoDTO.CityPoint point = new ExpressInfoDTO.CityPoint();
point.setName(city);
point.setLng(coords[0]);
point.setLat(coords[1]);
point.setIndex(i);
cityPoints.add(point);
}
}
routeData.setCities(cityPoints);
// 如果有多个城市规划路线
if (cityPoints.size() >= 2) {
try {
// 调用高德驾车路径规划API
ExpressInfoDTO.CityPoint origin = cityPoints.get(0);
ExpressInfoDTO.CityPoint destination = cityPoints.get(cityPoints.size() - 1);
String waypoints = null;
if (cityPoints.size() > 2) {
// 中间途经点
waypoints = cityPoints.subList(1, cityPoints.size() - 1).stream()
.map(p -> p.getLng() + "," + p.getLat())
.collect(Collectors.joining(";"));
}
Map<String, Object> routeResult = queryDrivingRoute(
origin.getLng() + "," + origin.getLat(),
destination.getLng() + "," + destination.getLat(),
waypoints
);
// 解析路线数据
if (routeResult != null && "1".equals(routeResult.get("status"))) {
Map<String, Object> route = (Map<String, Object>) routeResult.get("route");
if (route != null) {
List<Map<String, Object>> paths = (List<Map<String, Object>>) route.get("paths");
if (paths != null && !paths.isEmpty()) {
Map<String, Object> path = paths.get(0);
routeData.setDistance(path.get("distance").toString());
routeData.setDuration(path.get("duration").toString());
// 提取路径点
List<List<Double>> routePoints = extractRoutePoints(path);
routeData.setRoutePoints(routePoints);
}
}
}
} catch (Exception e) {
log.warn("⚠️ 路径规划失败,使用直线连接: {}", e.getMessage());
}
}
return routeData;
}
/**
* 调用高德驾车路径规划API
*/
private Map<String, Object> queryDrivingRoute(String origin, String destination, String waypoints) {
try {
// 构建URL确保坐标格式正确
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.append(amapProperties.getWebApi().getBaseUrl())
.append("/v3/direction/driving")
.append("?key=").append(amapProperties.getWebApi().getKey())
.append("&origin=").append(origin)
.append("&destination=").append(destination)
.append("&output=json");
if (waypoints != null && !waypoints.isEmpty()) {
urlBuilder.append("&waypoints=").append(waypoints);
}
String url = urlBuilder.toString();
log.info("🚗 调用高德路径规划API: {}", url);
// 验证坐标格式
if (!origin.matches("\\d+\\.\\d+,\\d+\\.\\d+")) {
log.error("❌ 起点坐标格式错误: {}", origin);
return null;
}
if (!destination.matches("\\d+\\.\\d+,\\d+\\.\\d+")) {
log.error("❌ 终点坐标格式错误: {}", destination);
return null;
}
Map<String, Object> result = restTemplate.getForObject(url, Map.class);
log.info("✅ 高德路径规划API响应成功");
return result;
} catch (Exception e) {
log.error("❌ 高德路径规划API调用失败", e);
return null;
}
}
/**
* 提取路径点
*/
private List<List<Double>> extractRoutePoints(Map<String, Object> path) {
List<List<Double>> points = new ArrayList<>();
try {
List<Map<String, Object>> steps = (List<Map<String, Object>>) path.get("steps");
if (steps != null) {
for (Map<String, Object> step : steps) {
String polyline = (String) step.get("polyline");
if (polyline != null) {
String[] coords = polyline.split(";");
for (String coord : coords) {
String[] lngLat = coord.split(",");
if (lngLat.length == 2) {
points.add(Arrays.asList(
Double.parseDouble(lngLat[0]),
Double.parseDouble(lngLat[1])
));
}
}
}
}
}
} catch (Exception e) {
log.warn("⚠️ 解析路径点失败: {}", e.getMessage());
}
return points;
}
/**
* 获取状态文本
*/
private String getStatusText(String status) {
if (status == null) return "未知";
switch (status) {
case "0": return "在途中";
case "1": return "已揽收";
@ -387,33 +334,4 @@ public class ExpressServiceImpl implements ExpressService {
default: return "未知";
}
}
/**
* 转换物流公司名称为代码
*/
private String convertCompanyNameToCode(String companyName) {
if (companyName == null) return null;
Map<String, String> companyMap = new HashMap<>();
companyMap.put("韵达", "yunda");
companyMap.put("韵达快递", "yunda");
companyMap.put("顺丰", "shunfeng");
companyMap.put("顺丰快递", "shunfeng");
companyMap.put("圆通", "yuantong");
companyMap.put("圆通快递", "yuantong");
companyMap.put("中通", "zhongtong");
companyMap.put("中通快递", "zhongtong");
companyMap.put("申通", "shentong");
companyMap.put("申通快递", "shentong");
companyMap.put("EMS", "ems");
companyMap.put("邮政", "ems");
for (Map.Entry<String, String> entry : companyMap.entrySet()) {
if (companyName.contains(entry.getKey())) {
return entry.getValue();
}
}
return null;
}
}

View File

@ -136,8 +136,8 @@
<select id="getSalesTrendByMerchant" parameterType="long" resultType="com.sunnyfarm.dto.MerchantDashboardDTO$SalesTrendDTO">
SELECT
DATE(created_at) as date,
IFNULL(SUM(actual_amount), 0) as amount,
COUNT(*) as orderCount
IFNULL(SUM(actual_amount), 0) as sales,
COUNT(*) as orders
FROM orders
WHERE merchant_id = #{merchantId}
AND status IN (2, 3, 4)

42
docker-compose.yml Normal file
View File

@ -0,0 +1,42 @@
version: '3.8'
services:
# ===== 后端服务 - Spring Boot =====
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: sunnyfarm-backend
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod,docker
- TZ=Asia/Shanghai
- JAVA_OPTS=-Xms256m -Xmx512m
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./logs/backend:/app/logs
restart: always
networks:
- sunnyfarm-net
# ===== 前端服务 - Nginx =====
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: sunnyfarm-frontend
ports:
- "53921:80"
environment:
- TZ=Asia/Shanghai
restart: always
depends_on:
- backend
networks:
- sunnyfarm-net
networks:
sunnyfarm-net:
driver: bridge

36
frontend/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# ============================================
# 农产品直销平台 - 前端 Dockerfile
# 多阶段构建: Node编译 → Nginx部署
# ============================================
# ---------- 第一阶段Node构建 ----------
FROM node:18-alpine AS builder
WORKDIR /app
# 配置npm镜像加速依赖下载
RUN npm config set registry https://registry.npmmirror.com
# 先复制package文件利用Docker缓存层
COPY package*.json ./
RUN npm install
# 复制源代码并构建
COPY . .
RUN npm run build
# ---------- 第二阶段Nginx部署 ----------
FROM nginx:1.25-alpine
# 设置时区
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 复制自定义Nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 从构建阶段复制静态文件
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

32
frontend/nginx.conf Normal file
View File

@ -0,0 +1,32 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_vary on;
# Vue Router History模式 - SPA路由支持
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源长期缓存Vite构建带hash
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# 图片等资源缓存
location ~* \.(ico|png|jpg|jpeg|gif|svg|webp)$ {
expires 7d;
add_header Cache-Control "public";
access_log off;
}
}

View File

@ -36,10 +36,14 @@
<div class="update-time" v-if="expressInfo.updateTime">
更新时间{{ expressInfo.updateTime }}
</div>
<div class="courier-phone" v-if="expressInfo.courierPhone">
<el-icon><Phone /></el-icon>
快递员电话{{ expressInfo.courierPhone }}
</div>
</div>
<!-- 物流地图 -->
<div class="express-map" v-if="showMap && expressInfo.mapRoute">
<!-- 物流地图 - iframe嵌入 -->
<div class="express-map" v-if="showMap && expressInfo.mapUrl">
<div class="map-header">
<span class="map-title">
<el-icon><MapLocation /></el-icon>
@ -51,7 +55,13 @@
</div>
<div v-show="mapExpanded" class="map-container">
<div id="express-map" ref="mapContainer"></div>
<iframe
:src="expressInfo.mapUrl"
class="map-iframe"
frameborder="0"
allowfullscreen
scrolling="no"
></iframe>
</div>
</div>
@ -68,7 +78,7 @@
:key="index"
:timestamp="trace.time"
placement="top"
:type="index === 0 ? 'primary' : 'info'"
:type="getTimelineType(trace, index)"
:size="index === 0 ? 'large' : 'normal'"
:hollow="index !== 0"
>
@ -87,7 +97,7 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
Loading,
@ -95,7 +105,8 @@ import {
CopyDocument,
Location,
MapLocation,
Clock
Clock,
Phone
} from '@element-plus/icons-vue'
import request from '../utils/request'
@ -114,8 +125,6 @@ const loading = ref(true)
const error = ref(null)
const expressInfo = ref(null)
const mapExpanded = ref(true)
const map = ref(null)
const mapContainer = ref(null)
//
const loadExpressInfo = async () => {
@ -125,261 +134,17 @@ const loadExpressInfo = async () => {
try {
const response = await request.get(`/express/order/${props.orderId}`)
expressInfo.value = response
//
if (props.showMap && expressInfo.value.mapRoute && mapExpanded.value) {
// DOM
await nextTick()
//
setTimeout(() => {
if (mapContainer.value) {
initMap()
} else {
console.warn('⚠️ 地图容器仍未就绪500ms后重试')
setTimeout(() => {
if (mapContainer.value) {
initMap()
}
}, 500)
}
}, 100)
}
} catch (err) {
console.error('❌ 加载物流信息失败:', err)
error.value = err.response?.data?.message || '加载物流信息失败'
error.value = err.response?.data?.message || err.message || '加载物流信息失败'
} finally {
loading.value = false
}
}
//
const initMap = () => {
if (!window.AMap) {
console.warn('⚠️ 高德地图API未加载')
return
}
if (!mapContainer.value) {
console.warn('⚠️ 地图容器未找到')
return
}
try {
//
map.value = new AMap.Map(mapContainer.value, {
zoom: 8,
mapStyle: 'amap://styles/normal',
viewMode: '3D'
})
const routeData = expressInfo.value.mapRoute
//
if (routeData.cities && routeData.cities.length > 0) {
const totalCities = routeData.cities.length
routeData.cities.forEach((city, index) => {
const isStart = index === 0
const isEnd = index === totalCities - 1
//
let markerColor, labelBg, labelColor, labelText, borderColor
if (isStart) {
// - 绿
markerColor = '#66BB6A'
labelBg = '#E8F5E9'
labelColor = '#2E7D32'
borderColor = '#66BB6A'
labelText = '发'
} else if (isEnd) {
// -
markerColor = '#42A5F5'
labelBg = '#E3F2FD'
labelColor = '#1976D2'
borderColor = '#42A5F5'
labelText = '收'
} else {
// -
markerColor = '#FFA726'
labelBg = '#FFF3E0'
labelColor = '#E65100'
borderColor = '#FFA726'
labelText = index.toString()
}
//
const marker = new AMap.CircleMarker({
center: [city.lng, city.lat],
radius: 12,
strokeColor: markerColor,
strokeWeight: 3,
fillColor: markerColor,
fillOpacity: 0.8,
zIndex: 100
})
//
const text = new AMap.Text({
text: isStart || isEnd ? `${labelText}${city.name}` : `${labelText}·${city.name}`,
position: [city.lng, city.lat],
offset: new AMap.Pixel(0, -35),
style: {
'background': labelBg,
'border': `2px solid ${borderColor}`,
'color': labelColor,
'font-size': '13px',
'font-weight': 'bold',
'padding': '6px 12px',
'border-radius': '16px',
'box-shadow': '0 2px 8px rgba(0,0,0,0.15)',
'white-space': 'nowrap'
}
})
map.value.add([marker, text])
})
// 线
if (routeData.routePoints && routeData.routePoints.length > 0) {
// 线
//
const borderLine = new AMap.Polyline({
path: routeData.routePoints,
strokeColor: '#FFFFFF',
strokeWeight: 10,
strokeOpacity: 0.8,
strokeStyle: 'solid',
lineJoin: 'round',
lineCap: 'round',
zIndex: 49
})
//
const polyline = new AMap.Polyline({
path: routeData.routePoints,
strokeColor: '#8B5CF6',
strokeWeight: 6,
strokeOpacity: 1,
strokeStyle: 'solid',
lineJoin: 'round',
lineCap: 'round',
zIndex: 50,
showDir: true
})
map.value.add([borderLine, polyline])
} else {
// 使线
const path = routeData.cities.map(city => [city.lng, city.lat])
//
const borderLine = new AMap.Polyline({
path: path,
strokeColor: '#FFFFFF',
strokeWeight: 10,
strokeOpacity: 0.8,
strokeStyle: 'solid',
lineJoin: 'round',
lineCap: 'round',
zIndex: 49
})
//
const polyline = new AMap.Polyline({
path: path,
strokeColor: '#8B5CF6',
strokeWeight: 6,
strokeOpacity: 1,
strokeStyle: 'solid',
lineJoin: 'round',
showDir: true,
zIndex: 50
})
map.value.add([borderLine, polyline])
}
//
const currentLocation = expressInfo.value.currentLocation
if (currentLocation && routeData.cities.length > 0) {
//
let currentCity = null
for (const city of routeData.cities) {
if (currentLocation.includes(city.name.replace('市', ''))) {
currentCity = city
break
}
}
//
if (currentCity) {
const truckIcon = new AMap.Marker({
position: [currentCity.lng, currentCity.lat],
content: `<div style="
background: #FF6B35;
color: white;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.5);
border: 3px solid white;
animation: pulse 2s infinite;
">🚚</div>
<style>
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
</style>`,
offset: new AMap.Pixel(-20, -20),
zIndex: 200
})
//
const currentText = new AMap.Text({
text: '当前位置',
position: [currentCity.lng, currentCity.lat],
offset: new AMap.Pixel(0, 25),
style: {
'background': '#FF6B35',
'border': 'none',
'color': 'white',
'font-size': '12px',
'font-weight': 'bold',
'padding': '4px 10px',
'border-radius': '12px',
'box-shadow': '0 2px 6px rgba(255, 107, 53, 0.4)'
}
})
map.value.add([truckIcon, currentText])
}
}
//
map.value.setFitView()
}
} catch (err) {
console.error('❌ 初始化地图失败:', err)
}
}
// /
const toggleMap = async () => {
const toggleMap = () => {
mapExpanded.value = !mapExpanded.value
if (mapExpanded.value && !map.value) {
await nextTick()
initMap()
}
}
//
@ -398,14 +163,23 @@ const getStatusType = (status) => {
'已签收': 'success',
'派件中': 'primary',
'已揽收': 'info',
'在途中': 'warning',
'疑难': 'danger',
'退签': 'danger',
'退回': 'danger'
'运输中': 'warning',
'问题件': 'danger',
'转寄': 'warning',
'清关中': 'info'
}
return typeMap[status] || 'info'
}
// 线
const getTimelineType = (trace, index) => {
if (index === 0) return 'primary'
if (trace.action === '311') return 'success'
if (trace.action === '202' || trace.action === '211') return 'primary'
if (trace.action === '1') return 'info'
return 'info'
}
// orderId
watch(() => props.orderId, () => {
if (props.orderId) {
@ -418,12 +192,6 @@ onMounted(() => {
loadExpressInfo()
}
})
onUnmounted(() => {
if (map.value) {
map.value.destroy()
}
})
</script>
<style scoped>
@ -482,7 +250,8 @@ onUnmounted(() => {
}
.current-location,
.update-time {
.update-time,
.courier-phone {
display: flex;
align-items: center;
gap: 6px;
@ -514,13 +283,14 @@ onUnmounted(() => {
}
.map-container {
height: 400px;
height: 450px;
background: #f5f5f5;
}
#express-map {
.map-iframe {
width: 100%;
height: 100%;
border: none;
}
.express-timeline {
@ -575,7 +345,7 @@ onUnmounted(() => {
@media (max-width: 768px) {
.map-container {
height: 300px;
height: 350px;
}
.express-header {

View File

@ -270,7 +270,7 @@ export default {
if (item.date) {
const date = new Date(item.date)
dates.push(`${date.getMonth() + 1}/${date.getDate()}`)
amounts.push(parseFloat(item.amount) || 0)
amounts.push(parseFloat(item.sales) || 0)
}
})
} else {

View File

@ -92,6 +92,21 @@
>
确认收货
</el-button>
<el-button
v-if="order.status === 4 && !reviewed"
type="success"
size="large"
@click="handleReviewOrder"
>
去评价
</el-button>
<el-tag
v-if="order.status === 4 && reviewed"
type="success"
size="large"
>
已评价
</el-tag>
</div>
</div>
</el-card>
@ -226,6 +241,15 @@
</div>
</div>
</div>
<!-- 评价弹窗 -->
<ReviewModal
:visible="reviewVisible"
@update:visible="reviewVisible = $event"
:product="reviewProduct"
:orderId="reviewOrderId"
@success="onReviewSuccess"
/>
</div>
</template>
@ -233,6 +257,8 @@
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import ReviewModal from '../../components/ReviewModal.vue'
import { checkUserReview } from '../../api/review'
import {
ArrowDown,
Document,
@ -253,6 +279,7 @@ import ExpressTracking from '../../components/ExpressTracking.vue'
export default {
name: 'OrderDetail',
components: {
ReviewModal,
ArrowDown,
Document,
Location,
@ -271,6 +298,10 @@ export default {
//
const loading = ref(true)
const reviewVisible = ref(false)
const reviewed = ref(false)
const reviewProduct = ref({})
const reviewOrderId = ref(0)
const payLoading = ref(false)
const order = ref(null)
@ -345,6 +376,19 @@ export default {
order.value = response
console.log('订单详情完整数据:', JSON.stringify(response, null, 2))
//
if (response.status === 4) {
try {
const items = response.orderItems || response.orderDetails || []
if (items.length > 0) {
const res = await checkUserReview(items[0].productId, response.id)
reviewed.value = res === true || res?.data === true
}
} catch (e) {
reviewed.value = false
}
}
console.log('创建时间字段检查:')
console.log('- createdAt:', response.createdAt)
console.log('- created_at:', response.created_at)
@ -530,6 +574,28 @@ export default {
router.push('/')
}
const handleReviewOrder = () => {
const items = order.value.orderItems || order.value.orderDetails || []
if (items.length > 0) {
const item = items[0]
reviewProduct.value = {
id: item.productId,
name: item.productName,
price: item.price,
image: item.productImage,
merchantId: order.value.merchantId
}
reviewOrderId.value = order.value.id
reviewVisible.value = true
}
}
const onReviewSuccess = () => {
reviewVisible.value = false
reviewed.value = true
ElMessage.success('评价成功!')
}
const goToOrders = () => {
router.push('/orders')
}
@ -578,6 +644,12 @@ export default {
return {
//
loading,
reviewVisible,
reviewed,
reviewProduct,
reviewOrderId,
handleReviewOrder,
onReviewSuccess,
payLoading,
order,
route,

View File

@ -128,6 +128,21 @@
>
确认收货
</el-button>
<el-button
v-if="order.status === 4 && !order.reviewed"
type="success"
size="small"
@click="handleReviewOrder(order)"
>
去评价
</el-button>
<el-tag
v-if="order.status === 4 && order.reviewed"
type="success"
size="small"
>
已评价
</el-tag>
<el-button
v-if="[4, 5, 6].includes(order.status)"
size="small"
@ -162,12 +177,23 @@
</div>
</div>
</div>
<!-- 评价弹窗 -->
<ReviewModal
:visible="reviewVisible"
@update:visible="reviewVisible = $event"
:product="reviewProduct"
:orderId="reviewOrderId"
@success="onReviewSuccess"
/>
</template>
<script>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import ReviewModal from '../../components/ReviewModal.vue'
import { checkUserReview } from '../../api/review'
import { Document, ArrowDown } from '@element-plus/icons-vue'
import { userStore } from '../../store/user'
import request from '../../utils/request'
@ -175,6 +201,7 @@ import request from '../../utils/request'
export default {
name: 'Orders',
components: {
ReviewModal,
Document,
ArrowDown
},
@ -182,6 +209,9 @@ export default {
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const reviewVisible = ref(false)
const reviewProduct = ref({})
const reviewOrderId = ref(0)
const orders = ref([])
const activeStatus = ref('all')
const currentPage = ref(1)
@ -262,6 +292,21 @@ export default {
}
console.log('📋 订单列表处理完成:', orders.value.length, '条订单')
//
for (const order of orders.value) {
if (order.status === 4) {
try {
const items = order.orderItems || order.orderDetails || []
if (items.length > 0) {
const res = await checkUserReview(items[0].productId, order.id)
order.reviewed = res === true || res?.data === true
}
} catch (e) {
order.reviewed = false
}
}
}
} catch (error) {
console.error('❌ 加载订单失败:', error)
ElMessage.error('加载订单失败')
@ -347,6 +392,28 @@ export default {
}).catch(() => {})
}
const handleReviewOrder = (order) => {
const items = order.orderItems || order.orderDetails || []
if (items.length > 0) {
const item = items[0]
reviewProduct.value = {
id: item.productId,
name: item.productName,
price: item.price,
image: item.productImage,
merchantId: order.merchantId
}
reviewOrderId.value = order.id
reviewVisible.value = true
}
}
const onReviewSuccess = () => {
reviewVisible.value = false
ElMessage.success('评价成功!')
loadOrders()
}
const handleDeleteOrder = (order) => {
ElMessageBox.confirm('确认删除该订单?', '确认删除', {
confirmButtonText: '确认',
@ -453,6 +520,11 @@ export default {
return {
userStore,
loading,
reviewVisible,
reviewProduct,
reviewOrderId,
handleReviewOrder,
onReviewSuccess,
orders,
activeStatus,
currentPage,