This commit is contained in:
superlishunqin 2025-10-10 23:22:52 +08:00
parent af926257b9
commit 364b7acbb7
32 changed files with 5452 additions and 402 deletions

View File

@ -0,0 +1,24 @@
package com.sunnyfarm.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 阿里云物流轨迹地图API配置属性
*/
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.express-map")
public class AliyunExpressMapProperties {
/**
* AppCode
*/
private String appCode;
/**
* API基础URL
*/
private String baseUrl;
}

View File

@ -0,0 +1,24 @@
package com.sunnyfarm.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 阿里云物流API配置属性
*/
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.express")
public class AliyunExpressProperties {
/**
* AppCode
*/
private String appCode;
/**
* API基础URL
*/
private String baseUrl;
}

View File

@ -0,0 +1,36 @@
package com.sunnyfarm.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 高德地图配置属性
*/
@Data
@Component
@ConfigurationProperties(prefix = "amap")
public class AmapProperties {
/**
* Web服务API配置后端调用
*/
private WebApi webApi;
/**
* JS API配置前端使用
*/
private JsApi jsApi;
@Data
public static class WebApi {
private String key;
private String baseUrl;
}
@Data
public static class JsApi {
private String key;
private String securityKey;
}
}

View File

@ -23,6 +23,7 @@ import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import java.util.Map;
@RestController
@ -60,6 +61,9 @@ public class AdminController {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private ProductImageService productImageService;
/**
* 管理员登录
*/
@ -341,8 +345,16 @@ public class AdminController {
Page<Product> result = productService.page(pageParam, queryWrapper);
// 批量加载商品图片
List<Product> products = result.getRecords();
if (products != null && !products.isEmpty()) {
for (Product product : products) {
loadProductImages(product);
}
}
PageResult<Product> pageResult = new PageResult<>();
pageResult.setRecords(result.getRecords());
pageResult.setRecords(products);
pageResult.setTotal(result.getTotal());
pageResult.setCurrent(result.getCurrent());
pageResult.setSize(result.getSize());
@ -380,6 +392,43 @@ public class AdminController {
}
}
/**
* 管理员获取商品详情不限制状态
*/
@GetMapping("/products/{id}")
public Result<Product> getProductDetail(@PathVariable Long id) {
// 管理员端不限制商品状态可以查看所有商品
Product product = productService.getById(id);
if (product == null) {
return Result.error("商品不存在");
}
// 加载商品图片
loadProductImages(product);
return Result.success(product);
}
/**
* 加载商品图片列表
*/
private void loadProductImages(Product product) {
if (product != null) {
QueryWrapper<ProductImage> imageQueryWrapper = new QueryWrapper<>();
imageQueryWrapper.eq("product_id", product.getId())
.orderByAsc("sort_order");
List<ProductImage> images = productImageService.list(imageQueryWrapper);
if (images != null && !images.isEmpty()) {
List<String> imageList = images.stream()
.map(ProductImage::getImageUrl)
.collect(Collectors.toList());
product.setImageList(imageList);
}
}
}
/**
* 获取订单列表
*/
@ -418,6 +467,44 @@ public class AdminController {
return Result.success(pageResult);
}
/**
* 管理员获取订单详情
*/
@GetMapping("/orders/{id}")
public Result<Order> getOrderDetail(@PathVariable Long id) {
try {
// 管理员可以查看所有订单不需要验证用户身份
Order order = orderService.getOrderDetail(id);
if (order == null) {
return Result.error("订单不存在");
}
// 设置状态名称
order.setStatusName(getOrderStatusName(order.getStatus()));
return Result.success(order);
} catch (Exception e) {
return Result.error("获取订单详情失败: " + e.getMessage());
}
}
/**
* 获取订单状态名称
*/
private String getOrderStatusName(Integer status) {
switch (status) {
case 1: return "待支付";
case 2: return "已支付";
case 3: return "已发货";
case 4: return "已完成";
case 5: return "已取消";
case 6: return "已退款";
default: return "未知状态";
}
}
/**
* 获取最新动态
*/

View File

@ -0,0 +1,55 @@
package com.sunnyfarm.controller;
import com.sunnyfarm.common.Result;
import com.sunnyfarm.dto.express.ExpressInfoDTO;
import com.sunnyfarm.service.ExpressService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 物流查询Controller
*/
@Slf4j
@RestController
@RequestMapping("/api/express")
@RequiredArgsConstructor
public class ExpressController {
private final ExpressService expressService;
/**
* 根据快递单号查询物流信息
*/
@GetMapping("/query")
public Result<ExpressInfoDTO> queryExpress(
@RequestParam String trackingNumber,
@RequestParam(required = false) String companyCode
) {
log.info("📦 查询物流信息 - 单号: {}, 公司: {}", trackingNumber, companyCode);
try {
ExpressInfoDTO result = expressService.queryExpressInfo(trackingNumber, companyCode);
return Result.success(result);
} catch (Exception e) {
log.error("❌ 查询物流信息失败", e);
return Result.error(e.getMessage());
}
}
/**
* 根据订单ID查询物流信息
*/
@GetMapping("/order/{orderId}")
public Result<ExpressInfoDTO> queryExpressByOrder(@PathVariable Long orderId) {
log.info("📦 根据订单查询物流 - 订单ID: {}", orderId);
try {
ExpressInfoDTO result = expressService.queryExpressByOrderId(orderId);
return Result.success(result);
} catch (Exception e) {
log.error("❌ 查询物流信息失败", e);
return Result.error(e.getMessage());
}
}
}

View File

@ -0,0 +1,80 @@
package com.sunnyfarm.dto.express;
import lombok.Data;
import java.util.List;
/**
* 高德路径规划DTO
*/
@Data
public class AmapRouteDTO {
/**
* 状态码1=成功
*/
private String status;
/**
* 返回信息
*/
private String info;
/**
* 路线方案
*/
private Route route;
@Data
public static class Route {
/**
* 起点坐标
*/
private String origin;
/**
* 终点坐标
*/
private String destination;
/**
* 路径列表
*/
private List<Path> paths;
}
@Data
public static class Path {
/**
* 距离
*/
private String distance;
/**
* 时间
*/
private String duration;
/**
* 路段列表
*/
private List<Step> steps;
}
@Data
public static class Step {
/**
* 路段坐标点串
*/
private String polyline;
/**
* 道路名称
*/
private String road;
/**
* 距离
*/
private String distance;
}
}

View File

@ -0,0 +1,112 @@
package com.sunnyfarm.dto.express;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 物流综合信息DTO包含轨迹和地图数据
*/
@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 MapRouteData mapRoute;
@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;
}
}

View File

@ -0,0 +1,54 @@
package com.sunnyfarm.dto.express;
import lombok.Data;
import java.util.List;
/**
* 物流轨迹地图DTO
*/
@Data
public class ExpressMapDTO {
/**
* 是否成功
*/
private Boolean Success;
/**
* 原因
*/
private String Reason;
/**
* 当前位置
*/
private String Location;
/**
* 轨迹列表
*/
private List<Trace> Traces;
@Data
public static class Trace {
/**
* 接收时间
*/
private String AcceptTime;
/**
* 接收站点
*/
private String AcceptStation;
/**
* 位置
*/
private String Location;
/**
* 操作
*/
private String Action;
}
}

View File

@ -0,0 +1,92 @@
package com.sunnyfarm.dto.express;
import lombok.Data;
import java.util.List;
/**
* 物流轨迹DTO
*/
@Data
public class ExpressTraceDTO {
/**
* 状态码0=成功
*/
private String status;
/**
* 消息
*/
private String msg;
/**
* 结果数据
*/
private ExpressResult result;
@Data
public static class ExpressResult {
/**
* 快递单号
*/
private String number;
/**
* 快递公司代码
*/
private String type;
/**
* 快递公司名称
*/
private String expName;
/**
* 官网
*/
private String expSite;
/**
* 客服电话
*/
private String courierPhone;
/**
* 物流状态0=在途1=揽收2=疑难3=签收4=退签5=派件6=退回
*/
private String deliverystatus;
/**
* 是否签收0=未签收1=已签收
*/
private String issign;
/**
* 更新时间
*/
private String updateTime;
/**
* 运输时长
*/
private String takeTime;
/**
* 物流轨迹列表
*/
private List<ExpressTrace> list;
}
@Data
public static class ExpressTrace {
/**
* 时间
*/
private String time;
/**
* 状态描述
*/
private String status;
}
}

View File

@ -1,11 +1,13 @@
package com.sunnyfarm.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@ -21,4 +23,8 @@ public class ProductImage extends BaseEntity {
private Boolean isMain = false; // 是否主图
private Integer sortOrder = 0; // 排序
// product_images表没有updated_at字段覆盖BaseEntity中的updateTime字段并标记为不存在
@TableField(exist = false)
private LocalDateTime updateTime; // 注意 updateTime不是 updatedAt
}

View File

@ -0,0 +1,26 @@
package com.sunnyfarm.service;
import com.sunnyfarm.dto.express.ExpressInfoDTO;
/**
* 物流查询服务接口
*/
public interface ExpressService {
/**
* 查询物流信息包含轨迹和地图数据
*
* @param trackingNumber 快递单号
* @param companyCode 快递公司代码可选如果不传会自动识别
* @return 物流综合信息
*/
ExpressInfoDTO queryExpressInfo(String trackingNumber, String companyCode);
/**
* 根据订单ID查询物流信息
*
* @param orderId 订单ID
* @return 物流综合信息
*/
ExpressInfoDTO queryExpressByOrderId(Long orderId);
}

View File

@ -108,4 +108,9 @@ public interface ProductService extends IService<Product> {
* 获取商家热销商品排行
*/
List<MerchantDashboardDTO.ProductSalesDTO> getTopProductsByMerchant(Long merchantId, Integer limit);
/**
* 增加商品销量
*/
void increaseSalesCount(Long productId, Integer quantity);
}

View File

@ -0,0 +1,419 @@
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;
import com.sunnyfarm.exception.BusinessException;
import com.sunnyfarm.mapper.OrderMapper;
import com.sunnyfarm.service.ExpressService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
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;
/**
* 物流查询服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
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});
}
@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("物流信息查询失败");
}
// 2. 解析物流轨迹数据
ExpressInfoDTO result = parseExpressData(traceData);
// 3. 提取城市并规划路线
List<String> cities = extractCities(traceData);
if (!cities.isEmpty()) {
ExpressInfoDTO.MapRouteData routeData = planRoute(cities);
result.setMapRoute(routeData);
}
log.info("✅ 物流信息查询成功 - 单号: {}", trackingNumber);
return result;
} catch (Exception e) {
log.error("❌ 物流信息查询失败", e);
throw new BusinessException("物流信息查询失败: " + e.getMessage());
}
}
@Override
public ExpressInfoDTO queryExpressByOrderId(Long orderId) {
log.info("🔍 根据订单ID查询物流 - 订单ID: {}", orderId);
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BusinessException("订单不存在");
}
if (order.getShipNo() == null || order.getShipNo().isEmpty()) {
throw new BusinessException("订单暂无物流信息");
}
// 将订单的物流公司名称转换为代码
String companyCode = convertCompanyNameToCode(order.getShipCompany());
return queryExpressInfo(order.getShipNo(), companyCode);
}
/**
* 调用阿里云物流查询API
*/
private ExpressTraceDTO queryExpressTrace(String trackingNumber, String companyCode) {
try {
String url = UriComponentsBuilder
.fromHttpUrl(expressProperties.getBaseUrl() + "/kdi")
.queryParam("no", trackingNumber)
.queryParam("type", companyCode != null ? companyCode : "")
.build()
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "APPCODE " + expressProperties.getAppCode());
HttpEntity<String> entity = new HttpEntity<>(headers);
log.info("📡 调用阿里云物流API: {}", url);
ResponseEntity<ExpressTraceDTO> response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
ExpressTraceDTO.class
);
log.info("✅ 阿里云物流API响应成功");
return response.getBody();
} catch (Exception e) {
log.error("❌ 调用阿里云物流API失败", e);
throw new BusinessException("物流查询服务异常");
}
}
/**
* 解析物流数据
*/
private ExpressInfoDTO parseExpressData(ExpressTraceDTO traceData) {
ExpressInfoDTO result = new ExpressInfoDTO();
ExpressTraceDTO.ExpressResult data = traceData.getResult();
result.setTrackingNumber(data.getNumber());
result.setCompanyName(data.getExpName());
result.setCompanyCode(data.getType());
result.setStatus(getStatusText(data.getDeliverystatus()));
result.setUpdateTime(data.getUpdateTime());
// 转换物流轨迹
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);
return item;
})
.collect(Collectors.toList());
result.setTraces(traces);
// 设置当前位置最新的轨迹
if (!traces.isEmpty()) {
result.setCurrentLocation(traces.get(0).getLocation());
}
}
return result;
}
/**
* 提取城市列表
*/
private List<String> extractCities(ExpressTraceDTO traceData) {
Set<String> citySet = new LinkedHashSet<>();
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;
}
}
}
}
}
List<String> cities = new ArrayList<>(citySet);
// 反转列表使其按照物流顺序排列
Collections.reverse(cities);
log.info("🏙️ 提取到的城市列表: {}", cities);
return cities;
}
/**
* 提取地点信息
*/
private String extractLocation(String status) {
if (status == null) return "";
// 匹配城市格式
Pattern pattern = Pattern.compile("【(.+?)】");
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 "已揽收";
case "2": return "疑难";
case "3": return "已签收";
case "4": return "退签";
case "5": return "派件中";
case "6": return "退回";
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

@ -28,6 +28,7 @@ import java.util.*;
import java.util.ArrayList;
import java.util.stream.Collectors;
@Slf4j
@Service
@Transactional
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {
@ -468,6 +469,20 @@ public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements
boolean success = this.updateById(order);
// 更新商品销量
if (success) {
try {
List<OrderDetail> orderDetails = orderDetailService.getByOrderId(orderId);
for (OrderDetail detail : orderDetails) {
productService.increaseSalesCount(detail.getProductId(), detail.getQuantity());
}
log.info("订单支付成功,已更新商品销量: 订单ID={}", orderId);
} catch (Exception e) {
log.error("更新商品销量失败: {}", e.getMessage(), e);
// 销量更新失败不影响支付流程
}
}
if (success) {
try {
systemLogService.logOrderPay(order.getOrderNo(), order.getUserId());

View File

@ -367,4 +367,23 @@ public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> impl
public List<MerchantDashboardDTO.ProductSalesDTO> getTopProductsByMerchant(Long merchantId, Integer limit) {
return new ArrayList<>();
}
@Override
@Transactional
public void increaseSalesCount(Long productId, Integer quantity) {
if (productId == null || quantity == null || quantity <= 0) {
return;
}
try {
Product product = getById(productId);
if (product != null) {
int currentSales = product.getSalesCount() != null ? product.getSalesCount() : 0;
product.setSalesCount(currentSales + quantity);
updateById(product);
}
} catch (Exception e) {
// 销量更新失败不影响主流程
}
}
}

129
export_copyright_code.sh Executable file
View File

@ -0,0 +1,129 @@
#!/bin/bash
# 软著代码导出脚本
# 目标路径
OUTPUT_DIR="/Users/lishunqin/Desktop/桃金娘/软著申请相关/德育大模型"
OUTPUT_FILE="$OUTPUT_DIR/sunnyfarm.txt"
# 创建输出目录
mkdir -p "$OUTPUT_DIR"
# 清空或创建输出文件
> "$OUTPUT_FILE"
echo "======================================"
echo "开始导出软著代码"
echo "======================================"
# 函数:添加文件到输出,删除空白行
add_file() {
local file=$1
local relative_path=${file#./}
echo "" >> "$OUTPUT_FILE"
echo "// ==================== $relative_path ====================" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
# 删除空白行并追加到输出文件
grep -v '^[[:space:]]*$' "$file" >> "$OUTPUT_FILE"
echo "✓ 已添加: $relative_path"
}
# 1. 导出后端Java代码
echo ""
echo "【第一部分后端Java代码】"
echo "------------------------------"
# 导出Java源码文件
find ./backend/src/main/java -type f -name "*.java" | sort | while read file; do
add_file "$file"
done
# 2. 导出后端配置文件
echo ""
echo "【第二部分:后端配置文件】"
echo "------------------------------"
# 导出yml配置文件排除备份文件
find ./backend/src/main/resources -type f \( -name "*.yml" -o -name "*.yaml" \) \
! -name "*.bak*" ! -name "*.backup*" ! -name "*.example" ! -name "*.template" | sort | while read file; do
add_file "$file"
done
# 导出XML映射文件
find ./backend/src/main/resources/mapper -type f -name "*.xml" 2>/dev/null | sort | while read file; do
add_file "$file"
done
# 导出pom.xml
if [ -f "./backend/pom.xml" ]; then
add_file "./backend/pom.xml"
fi
# 3. 导出前端代码
echo ""
echo "【第三部分前端Vue/JS代码】"
echo "------------------------------"
# 导出Vue组件
find ./frontend/src -type f -name "*.vue" \
! -name "*.bak*" ! -name "*.backup*" | sort | while read file; do
add_file "$file"
done
# 导出JS文件
find ./frontend/src -type f -name "*.js" | sort | while read file; do
add_file "$file"
done
# 4. 导出前端配置文件
echo ""
echo "【第四部分:前端配置文件】"
echo "------------------------------"
# package.json
if [ -f "./frontend/package.json" ]; then
add_file "./frontend/package.json"
fi
# vite.config.js
if [ -f "./frontend/vite.config.js" ]; then
add_file "./frontend/vite.config.js"
fi
# index.html
if [ -f "./frontend/index.html" ]; then
add_file "./frontend/index.html"
fi
# 统计信息
echo ""
echo "======================================"
echo "导出完成!"
echo "======================================"
echo "输出文件: $OUTPUT_FILE"
echo ""
# 统计代码行数
total_lines=$(wc -l < "$OUTPUT_FILE")
echo "总行数: $total_lines"
# 统计文件大小
file_size=$(du -h "$OUTPUT_FILE" | cut -f1)
echo "文件大小: $file_size"
# 统计各部分文件数量
java_count=$(grep -c "\.java ==" "$OUTPUT_FILE" || echo "0")
vue_count=$(grep -c "\.vue ==" "$OUTPUT_FILE" || echo "0")
js_count=$(grep -c "\.js ==" "$OUTPUT_FILE" || echo "0")
xml_count=$(grep -c "\.xml ==" "$OUTPUT_FILE" || echo "0")
echo ""
echo "文件统计:"
echo " Java文件: $java_count"
echo " Vue文件: $vue_count"
echo " JS文件: $js_count"
echo " XML文件: $xml_count"
echo ""
echo "✅ 代码导出成功!"

View File

@ -45,6 +45,11 @@ export const getProducts = (params) => {
return request.get('/admin/products', { params })
}
// 获取商品详情
export const getProductDetail = (id) => {
return request.get(`/admin/products/${id}`)
}
// 审核商品
export const auditProduct = (id, status) => {
return request.put(`/admin/products/${id}/audit`, null, { params: { status } })
@ -55,6 +60,11 @@ export const getOrders = (params) => {
return request.get('/admin/orders', { params })
}
// 获取订单详情
export const getOrderDetail = (id) => {
return request.get(`/admin/orders/${id}`)
}
// 获取最新动态
export const getRecentActivities = () => {
return request.get('/admin/activities')

View File

@ -0,0 +1,20 @@
import request from '../utils/request'
/**
* 根据快递单号查询物流信息
*/
export function queryExpress(trackingNumber, companyCode) {
return request.get('/express/query', {
params: {
trackingNumber,
companyCode
}
})
}
/**
* 根据订单ID查询物流信息
*/
export function queryExpressByOrder(orderId) {
return request.get(`/express/order/${orderId}`)
}

View File

@ -0,0 +1,589 @@
<template>
<div class="express-tracking">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载物流信息中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-icon><WarningFilled /></el-icon>
<span>{{ error }}</span>
<el-button type="primary" size="small" @click="loadExpressInfo">重试</el-button>
</div>
<!-- 物流信息展示 -->
<div v-else-if="expressInfo" class="express-content">
<!-- 物流基本信息 -->
<div class="express-header">
<div class="company-info">
<span class="company-name">{{ expressInfo.companyName }}</span>
<el-tag :type="getStatusType(expressInfo.status)" size="small">
{{ expressInfo.status }}
</el-tag>
</div>
<div class="tracking-number">
运单号{{ expressInfo.trackingNumber }}
<el-button type="text" size="small" @click="copyTrackingNumber">
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
<div class="current-location" v-if="expressInfo.currentLocation">
<el-icon><Location /></el-icon>
当前位置{{ expressInfo.currentLocation }}
</div>
<div class="update-time" v-if="expressInfo.updateTime">
更新时间{{ expressInfo.updateTime }}
</div>
</div>
<!-- 物流地图 -->
<div class="express-map" v-if="showMap && expressInfo.mapRoute">
<div class="map-header">
<span class="map-title">
<el-icon><MapLocation /></el-icon>
物流轨迹地图
</span>
<el-button type="text" @click="toggleMap">
{{ mapExpanded ? '收起' : '展开' }}
</el-button>
</div>
<div v-show="mapExpanded" class="map-container">
<div id="express-map" ref="mapContainer"></div>
</div>
</div>
<!-- 物流轨迹时间线 -->
<div class="express-timeline">
<div class="timeline-header">
<el-icon><Clock /></el-icon>
物流轨迹
</div>
<el-timeline>
<el-timeline-item
v-for="(trace, index) in expressInfo.traces"
:key="index"
:timestamp="trace.time"
placement="top"
:type="index === 0 ? 'primary' : 'info'"
:size="index === 0 ? 'large' : 'normal'"
:hollow="index !== 0"
>
<div class="timeline-content">
<div class="trace-location" v-if="trace.location">
<el-icon><Location /></el-icon>
{{ trace.location }}
</div>
<div class="trace-status">{{ trace.status }}</div>
</div>
</el-timeline-item>
</el-timeline>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import {
Loading,
WarningFilled,
CopyDocument,
Location,
MapLocation,
Clock
} from '@element-plus/icons-vue'
import request from '../utils/request'
const props = defineProps({
orderId: {
type: Number,
required: true
},
showMap: {
type: Boolean,
default: true
}
})
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 () => {
loading.value = true
error.value = null
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 || '加载物流信息失败'
} 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 () => {
mapExpanded.value = !mapExpanded.value
if (mapExpanded.value && !map.value) {
await nextTick()
initMap()
}
}
//
const copyTrackingNumber = async () => {
try {
await navigator.clipboard.writeText(expressInfo.value.trackingNumber)
ElMessage.success('运单号已复制')
} catch (err) {
ElMessage.error('复制失败')
}
}
//
const getStatusType = (status) => {
const typeMap = {
'已签收': 'success',
'派件中': 'primary',
'已揽收': 'info',
'在途中': 'warning',
'疑难': 'danger',
'退签': 'danger',
'退回': 'danger'
}
return typeMap[status] || 'info'
}
// orderId
watch(() => props.orderId, () => {
if (props.orderId) {
loadExpressInfo()
}
})
onMounted(() => {
if (props.orderId) {
loadExpressInfo()
}
})
onUnmounted(() => {
if (map.value) {
map.value.destroy()
}
})
</script>
<style scoped>
.express-tracking {
background: white;
border-radius: 12px;
overflow: hidden;
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
color: #666;
}
.error-container {
color: #f56c6c;
}
.express-content {
padding: 0;
}
.express-header {
padding: 20px;
background: linear-gradient(135deg, #F1F8E9 0%, #E8F5E8 100%);
border-bottom: 2px solid #C8E6C9;
}
.company-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.company-name {
font-size: 18px;
font-weight: bold;
color: #2E7D32;
}
.tracking-number {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #4CAF50;
margin-bottom: 8px;
font-family: monospace;
}
.current-location,
.update-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #81C784;
margin-top: 8px;
}
.express-map {
border-bottom: 1px solid #E8F5E8;
}
.map-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #f8fff8;
border-bottom: 1px solid #E8F5E8;
}
.map-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #2E7D32;
font-size: 16px;
}
.map-container {
height: 400px;
background: #f5f5f5;
}
#express-map {
width: 100%;
height: 100%;
}
.express-timeline {
padding: 20px;
}
.timeline-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #2E7D32;
font-size: 16px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #C8E6C9;
}
.timeline-content {
padding: 8px 0;
}
.trace-location {
display: flex;
align-items: center;
gap: 6px;
color: #4CAF50;
font-weight: 600;
font-size: 14px;
margin-bottom: 6px;
}
.trace-status {
color: #666;
font-size: 14px;
line-height: 1.6;
}
.el-timeline {
padding-left: 0;
}
:deep(.el-timeline-item__timestamp) {
color: #81C784;
font-size: 13px;
}
:deep(.el-timeline-item__node--large) {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.map-container {
height: 300px;
}
.express-header {
padding: 16px;
}
.company-name {
font-size: 16px;
}
}
</style>

View File

@ -336,7 +336,7 @@ export default {
}
};
// - API
// - API
const searchAddress = async () => {
if (!searchKeyword.value.trim()) {
searchResults.value = [];
@ -430,7 +430,7 @@ export default {
};
//
const saveAddress = () => {
const saveAddress = async () => {
if (!selectedAddress.value) {
ElMessage.error('请在地图上选择地址');
return;
@ -456,21 +456,63 @@ export default {
return;
}
const addressData = {
consignee: consigneeName.value,
phone: phoneNumber.value,
province: selectedAddress.value.province,
city: selectedAddress.value.city,
district: selectedAddress.value.district,
address: detailAddress.value,
isDefault: useDefaultAddress.value,
tag: selectedTag.value,
lng: selectedAddress.value.lng,
lat: selectedAddress.value.lat,
formattedAddress: selectedAddress.value.formattedAddress
};
saving.value = true;
emit('save', addressData);
try {
//
let province = selectedAddress.value.province;
let city = selectedAddress.value.city;
let district = selectedAddress.value.district;
//
if (!province || !city || !district) {
console.log('📍 省市区信息不完整,调用后端逆地理编码接口...');
const result = await addressAPI.reverseGeocode(
selectedAddress.value.lng,
selectedAddress.value.lat
);
if (result && result.province && result.city && result.district) {
province = result.province;
city = result.city;
district = result.district;
// selectedAddress
selectedAddress.value.province = province;
selectedAddress.value.city = city;
selectedAddress.value.district = district;
console.log('✅ 逆地理编码成功:', { province, city, district });
} else {
saving.value = false;
ElMessage.error('无法获取地址信息,请重新选择位置');
return;
}
}
const addressData = {
consignee: consigneeName.value,
phone: phoneNumber.value,
province: province,
city: city,
district: district,
address: detailAddress.value,
isDefault: useDefaultAddress.value,
tag: selectedTag.value,
lng: selectedAddress.value.lng,
lat: selectedAddress.value.lat,
formattedAddress: selectedAddress.value.formattedAddress
};
console.log('💾 准备保存地址:', addressData);
emit('save', addressData);
} catch (error) {
console.error('❌ 保存地址失败:', error);
ElMessage.error('保存失败: ' + (error.message || '未知错误'));
saving.value = false;
}
};
//

View File

@ -2,12 +2,64 @@
<div class="merchant-management">
<!-- 页面标题 -->
<div class="page-header">
<h2>商家管理</h2>
<p>管理平台商家入驻申请和审核</p>
<div>
<h2>商家管理</h2>
<p>管理平台商家入驻申请和审核</p>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-container">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stat-card pending">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">待审核</div>
<div class="stat-value">{{ stats.pending }}</div>
</div>
<el-icon class="stat-icon"><Warning /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card approved">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">已通过</div>
<div class="stat-value">{{ stats.approved }}</div>
</div>
<el-icon class="stat-icon"><SuccessFilled /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card rejected">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">已拒绝</div>
<div class="stat-value">{{ stats.rejected }}</div>
</div>
<el-icon class="stat-icon"><CircleCloseFilled /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card total">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">商家总数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<el-icon class="stat-icon"><Shop /></el-icon>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-card class="search-card">
<el-form :inline="true" class="search-form">
<el-form-item>
<el-input
@ -16,10 +68,11 @@
prefix-icon="Search"
clearable
@clear="handleSearch"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-select v-model="searchForm.status" placeholder="审核状态" clearable>
<el-select v-model="searchForm.status" placeholder="审核状态" clearable style="width: 150px">
<el-option label="全部" value="" />
<el-option label="待审核" :value="0" />
<el-option label="已通过" :value="1" />
@ -31,49 +84,68 @@
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<!-- 商家表格 -->
<div class="table-container">
<el-card class="table-card">
<el-table :data="merchantList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="shopName" label="店铺名称" width="150" />
<el-table-column prop="legalPerson" label="法人" width="120" />
<el-table-column prop="contactPhone" label="联系电话" width="130" />
<el-table-column prop="businessScope" label="经营范围" width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="shopName" label="店铺名称" min-width="180" show-overflow-tooltip>
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
<div class="shop-name">
<span class="name-text">{{ scope.row.shopName }}</span>
<el-tag v-if="scope.row.status === 0" type="warning" size="small" class="status-badge">待审核</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="legalPerson" label="法人" width="120" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="140" align="center" />
<el-table-column prop="businessScope" label="经营范围" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)" effect="dark">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="申请时间" width="160" />
<el-table-column prop="verifyTime" label="审核时间" width="160" />
<el-table-column label="操作" width="200" fixed="right">
<el-table-column prop="createTime" label="申请时间" width="180" align="center" />
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="scope">
<el-button
v-if="scope.row.status === 0"
type="success"
size="small"
@click="handleAudit(scope.row, 1)"
>
通过
</el-button>
<el-button
v-if="scope.row.status === 0"
type="danger"
size="small"
@click="handleAudit(scope.row, 2)"
>
拒绝
</el-button>
<el-button
size="small"
@click="handleViewDetail(scope.row)"
>
查看详情
</el-button>
<div class="glass-action-buttons">
<!-- 待审核状态显示审核按钮 -->
<template v-if="scope.row.status === 0">
<button
class="glass-btn glass-btn-success"
@click="handleAudit(scope.row, 1)"
>
<el-icon class="btn-icon"><Check /></el-icon>
<span>通过</span>
</button>
<button
class="glass-btn glass-btn-danger"
@click="handleAudit(scope.row, 2)"
>
<el-icon class="btn-icon"><Close /></el-icon>
<span>拒绝</span>
</button>
</template>
<!-- 查看详情按钮 -->
<button
class="glass-btn glass-btn-primary"
@click="handleViewDetail(scope.row)"
>
<el-icon class="btn-icon"><View /></el-icon>
<span>详情</span>
</button>
</div>
</template>
</el-table-column>
</el-table>
@ -90,7 +162,7 @@
@current-change="handleCurrentChange"
/>
</div>
</div>
</el-card>
<!-- 商家详情对话框 -->
<el-dialog
@ -99,90 +171,85 @@
width="800px"
:close-on-click-modal="false"
>
<div class="merchant-detail" v-if="selectedMerchant">
<div class="merchant-detail" v-if="selectedMerchant" v-loading="detailLoading">
<!-- 基本信息 -->
<div class="detail-section">
<h3>基本信息</h3>
<div class="info-grid">
<div class="info-item">
<label>商家ID</label>
<span>{{ selectedMerchant.id }}</span>
</div>
<div class="info-item">
<label>店铺名称</label>
<span>{{ selectedMerchant.shopName }}</span>
</div>
<div class="info-item">
<label>法人姓名</label>
<span>{{ selectedMerchant.legalPerson }}</span>
</div>
<div class="info-item">
<label>联系电话</label>
<span>{{ selectedMerchant.contactPhone }}</span>
</div>
<div class="info-item">
<label>经营地址</label>
<span>{{ selectedMerchant.address }}</span>
</div>
<div class="info-item">
<label>营业执照</label>
<span>{{ selectedMerchant.businessLicense || '未上传' }}</span>
</div>
<div class="section-title">
<el-icon><InfoFilled /></el-icon>
<span>基本信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="商家ID">{{ selectedMerchant.id }}</el-descriptions-item>
<el-descriptions-item label="店铺名称">{{ selectedMerchant.shopName }}</el-descriptions-item>
<el-descriptions-item label="法人姓名">{{ selectedMerchant.legalPerson }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ selectedMerchant.contactPhone }}</el-descriptions-item>
<el-descriptions-item label="经营地址" :span="2">
{{ selectedMerchant.address || '-' }}
</el-descriptions-item>
<el-descriptions-item label="营业执照" :span="2">
{{ selectedMerchant.businessLicense || '未上传' }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 经营信息 -->
<div class="detail-section">
<h3>经营信息</h3>
<div class="info-grid">
<div class="info-item full-width">
<label>经营范围</label>
<span>{{ selectedMerchant.businessScope }}</span>
</div>
<div class="section-title">
<el-icon><Shop /></el-icon>
<span>经营信息</span>
</div>
<el-descriptions :column="1" border>
<el-descriptions-item label="经营范围">
<div class="business-scope">{{ selectedMerchant.businessScope || '-' }}</div>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 审核信息 -->
<div class="detail-section">
<h3>审核信息</h3>
<div class="info-grid">
<div class="info-item">
<label>审核状态</label>
<el-tag :type="getStatusType(selectedMerchant.status)">
<div class="section-title">
<el-icon><DocumentChecked /></el-icon>
<span>审核信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="审核状态">
<el-tag :type="getStatusType(selectedMerchant.status)" effect="dark">
{{ getStatusText(selectedMerchant.status) }}
</el-tag>
</div>
<div class="info-item">
<label>申请时间</label>
<span>{{ selectedMerchant.createdAt }}</span>
</div>
<div class="info-item">
<label>审核时间</label>
<span>{{ selectedMerchant.verifyTime || '未审核' }}</span>
</div>
</div>
</div>
<!-- 审核操作区 -->
<div class="detail-section" v-if="selectedMerchant.status === 0">
<h3>审核操作</h3>
<div class="audit-actions">
<el-button
type="success"
@click="handleDetailAudit(selectedMerchant, 1)"
>
通过审核
</el-button>
<el-button
type="danger"
@click="handleDetailAudit(selectedMerchant, 2)"
>
拒绝审核
</el-button>
</div>
</el-descriptions-item>
<el-descriptions-item label="申请时间">
{{ selectedMerchant.createTime }}
</el-descriptions-item>
<el-descriptions-item label="审核时间" :span="2">
{{ selectedMerchant.verifyTime || '未审核' }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="detailDialogVisible = false">关闭</el-button>
<el-button @click="detailDialogVisible = false" size="large">关闭</el-button>
<template v-if="selectedMerchant && selectedMerchant.status === 0">
<el-button
type="success"
@click="handleAuditInDialog(1)"
size="large"
class="audit-btn"
>
<el-icon class="btn-icon"><Check /></el-icon>
<span>审核通过</span>
</el-button>
<el-button
type="danger"
@click="handleAuditInDialog(2)"
size="large"
class="audit-btn"
>
<el-icon class="btn-icon"><Close /></el-icon>
<span>审核拒绝</span>
</el-button>
</template>
</div>
</template>
</el-dialog>
@ -192,12 +259,21 @@
<script>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Warning, SuccessFilled, CircleCloseFilled, Shop, Search, Refresh,
Check, Close, View, InfoFilled, DocumentChecked
} from '@element-plus/icons-vue'
import { getMerchants, auditMerchant } from '../../../api/admin'
export default {
name: 'MerchantManagement',
components: {
Warning, SuccessFilled, CircleCloseFilled, Shop, Search, Refresh,
Check, Close, View, InfoFilled, DocumentChecked
},
setup() {
const loading = ref(false)
const detailLoading = ref(false)
const merchantList = ref([])
const detailDialogVisible = ref(false)
const selectedMerchant = ref(null)
@ -213,6 +289,22 @@ export default {
total: 0
})
//
const stats = reactive({
pending: 0,
approved: 0,
rejected: 0,
total: 0
})
//
const calculateStats = (merchants) => {
stats.pending = merchants.filter(m => m.status === 0).length
stats.approved = merchants.filter(m => m.status === 1).length
stats.rejected = merchants.filter(m => m.status === 2).length
stats.total = merchants.length
}
const loadMerchants = async () => {
loading.value = true
try {
@ -226,7 +318,11 @@ export default {
const result = await getMerchants(params)
merchantList.value = result.records
pagination.total = result.total
//
calculateStats(result.records)
} catch (error) {
console.error('加载商家列表失败:', error)
ElMessage.error('加载商家列表失败')
} finally {
loading.value = false
@ -272,27 +368,26 @@ export default {
await auditMerchant(merchant.id, status)
ElMessage.success(`商家审核${action}成功`)
loadMerchants()
//
if (detailDialogVisible.value) {
detailDialogVisible.value = false
}
} catch (error) {
if (error !== 'cancel') {
console.error('审核失败:', error)
ElMessage.error(`商家审核${action}失败`)
}
}
}
const handleAuditInDialog = async (status) => {
if (!selectedMerchant.value) return
await handleAudit(selectedMerchant.value, status)
detailDialogVisible.value = false
}
const handleViewDetail = (merchant) => {
selectedMerchant.value = merchant
detailDialogVisible.value = true
}
const handleDetailAudit = async (merchant, status) => {
await handleAudit(merchant, status)
}
const getStatusType = (status) => {
const types = { 0: 'warning', 1: 'success', 2: 'danger' }
return types[status] || ''
@ -309,9 +404,11 @@ export default {
return {
loading,
detailLoading,
merchantList,
searchForm,
pagination,
stats,
detailDialogVisible,
selectedMerchant,
handleSearch,
@ -319,8 +416,8 @@ export default {
handleSizeChange,
handleCurrentChange,
handleAudit,
handleAuditInDialog,
handleViewDetail,
handleDetailAudit,
getStatusType,
getStatusText
}
@ -331,114 +428,343 @@ export default {
<style scoped>
.merchant-management {
padding: 20px;
background: #f0f2f5;
min-height: calc(100vh - 60px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0 0 5px 0;
color: #333;
color: #1f2937;
font-size: 24px;
font-weight: 600;
}
.page-header p {
margin: 0;
color: #666;
color: #6b7280;
font-size: 14px;
}
.search-bar {
background: white;
padding: 20px;
border-radius: 8px;
/* 统计卡片样式 */
.stats-container {
margin-bottom: 20px;
}
.stat-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.stat-info {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #1f2937;
}
.stat-icon {
font-size: 48px;
opacity: 0.2;
}
.stat-card.pending .stat-value {
color: #f59e0b;
}
.stat-card.pending .stat-icon {
color: #f59e0b;
}
.stat-card.approved .stat-value {
color: #10b981;
}
.stat-card.approved .stat-icon {
color: #10b981;
}
.stat-card.rejected .stat-value {
color: #ef4444;
}
.stat-card.rejected .stat-icon {
color: #ef4444;
}
.stat-card.total .stat-value {
color: #3b82f6;
}
.stat-card.total .stat-icon {
color: #3b82f6;
}
/* 搜索卡片 */
.search-card {
margin-bottom: 20px;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-form {
margin: 0;
}
/* 表格卡片 */
.table-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* 店铺名称 */
.shop-name {
display: flex;
align-items: center;
gap: 8px;
}
.name-text {
flex: 1;
font-weight: 500;
color: #1f2937;
}
.status-badge {
flex-shrink: 0;
}
/* ==================== 玻璃质感按钮样式 ==================== */
.glass-action-buttons {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.table-container {
background: white;
padding: 20px;
.glass-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.glass-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
transition: left 0.5s;
}
.glass-btn:hover::before {
left: 100%;
}
.glass-btn .btn-icon {
font-size: 16px;
transition: transform 0.3s;
}
.glass-btn:hover .btn-icon {
transform: scale(1.2) rotate(5deg);
}
.glass-btn:active {
transform: scale(0.95);
}
/* 通过按钮 - 绿色玻璃 */
.glass-btn-success {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.8) 0%,
rgba(5, 150, 105, 0.8) 100%
);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-btn-success:hover {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.9) 0%,
rgba(5, 150, 105, 0.9) 100%
);
box-shadow: 0 8px 16px -4px rgba(16, 185, 129, 0.4),
0 4px 6px -2px rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
/* 拒绝按钮 - 红色玻璃 */
.glass-btn-danger {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.8) 0%,
rgba(220, 38, 38, 0.8) 100%
);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-btn-danger:hover {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.9) 0%,
rgba(220, 38, 38, 0.9) 100%
);
box-shadow: 0 8px 16px -4px rgba(239, 68, 68, 0.4),
0 4px 6px -2px rgba(239, 68, 68, 0.2);
transform: translateY(-2px);
}
/* 详情按钮 - 蓝色玻璃 */
.glass-btn-primary {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.15) 0%,
rgba(37, 99, 235, 0.15) 100%
);
color: #3b82f6;
border: 1.5px solid rgba(59, 130, 246, 0.5);
}
.glass-btn-primary:hover {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.25) 0%,
rgba(37, 99, 235, 0.25) 100%
);
border-color: rgba(59, 130, 246, 0.8);
box-shadow: 0 8px 16px -4px rgba(59, 130, 246, 0.3),
0 4px 6px -2px rgba(59, 130, 246, 0.15),
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
/* 分页 */
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
/* 详情对话框样式 */
/* 商家详情样式 */
.merchant-detail {
max-height: 600px;
max-height: 70vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 30px;
margin-bottom: 24px;
}
.detail-section h3 {
margin: 0 0 15px 0;
color: #333;
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
border-bottom: 2px solid #409EFF;
padding-bottom: 5px;
color: #1f2937;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
.section-title .el-icon {
color: #3b82f6;
}
.info-item {
display: flex;
align-items: flex-start;
}
.info-item.full-width {
grid-column: 1 / -1;
}
.info-item label {
min-width: 100px;
font-weight: 500;
color: #666;
margin-right: 10px;
}
.info-item span {
color: #333;
/* 经营范围样式 */
.business-scope {
line-height: 1.6;
color: #4b5563;
white-space: pre-wrap;
word-break: break-word;
}
.audit-actions {
display: flex;
gap: 10px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
text-align: right;
}
/* 响应式设计 */
/* 对话框底部审核按钮样式 */
.dialog-footer .audit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 120px;
}
.dialog-footer .audit-btn .btn-icon {
font-size: 16px;
display: flex;
align-items: center;
}
/* 响应式 */
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
.stats-container :deep(.el-col) {
margin-bottom: 12px;
}
.info-item.full-width {
grid-column: 1;
.glass-action-buttons {
flex-direction: column;
}
.glass-btn {
width: 100%;
}
}
</style>

View File

@ -2,12 +2,64 @@
<div class="order-management">
<!-- 页面标题 -->
<div class="page-header">
<h2>订单管理</h2>
<p>管理平台所有订单信息</p>
<div>
<h2>订单管理</h2>
<p>管理平台所有订单信息</p>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-container">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stat-card pending">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">待支付</div>
<div class="stat-value">{{ stats.pending }}</div>
</div>
<el-icon class="stat-icon"><Clock /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card paid">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">已支付</div>
<div class="stat-value">{{ stats.paid }}</div>
</div>
<el-icon class="stat-icon"><Wallet /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card shipped">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">已发货</div>
<div class="stat-value">{{ stats.shipped }}</div>
</div>
<el-icon class="stat-icon"><Van /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card total">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">订单总数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<el-icon class="stat-icon"><Document /></el-icon>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-card class="search-card">
<el-form :inline="true" class="search-form">
<el-form-item>
<el-input
@ -16,10 +68,11 @@
prefix-icon="Search"
clearable
@clear="handleSearch"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-select v-model="searchForm.status" placeholder="订单状态" clearable>
<el-select v-model="searchForm.status" placeholder="订单状态" clearable style="width: 150px">
<el-option label="全部" value="" />
<el-option label="待支付" :value="1" />
<el-option label="已支付" :value="2" />
@ -33,41 +86,60 @@
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<!-- 订单表格 -->
<div class="table-container">
<el-card class="table-card">
<el-table :data="orderList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="orderNo" label="订单号" width="160" />
<el-table-column prop="consignee" label="收件人" width="100" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="totalAmount" label="订单金额" width="100">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="orderNo" label="订单号" width="180" align="center">
<template #default="scope">
¥{{ scope.row.totalAmount }}
<span class="order-no">{{ scope.row.orderNo }}</span>
</template>
</el-table-column>
<el-table-column prop="actualAmount" label="实付金额" width="100">
<el-table-column prop="consignee" label="收件人" width="120" align="center" />
<el-table-column prop="phone" label="电话" width="130" align="center" />
<el-table-column prop="totalAmount" label="订单金额" width="120" align="center">
<template #default="scope">
¥{{ scope.row.actualAmount }}
<span class="amount-text">¥{{ scope.row.totalAmount }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="actualAmount" label="实付金额" width="120" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
<span class="price">¥{{ scope.row.actualAmount }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)" effect="dark">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160" />
<el-table-column label="操作" width="120" fixed="right">
<el-table-column prop="createTime" label="创建时间" width="180" align="center">
<template #default="scope">
<el-button
size="small"
@click="handleViewDetail(scope.row)"
>
查看详情
</el-button>
{{ formatTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="scope">
<div class="glass-action-buttons">
<button
class="glass-btn glass-btn-primary"
@click="handleViewDetail(scope.row)"
>
<el-icon class="btn-icon"><View /></el-icon>
<span>详情</span>
</button>
</div>
</template>
</el-table-column>
</el-table>
@ -84,20 +156,182 @@
@current-change="handleCurrentChange"
/>
</div>
</div>
</el-card>
<!-- 订单详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="订单详情"
width="900px"
:close-on-click-modal="false"
>
<div v-if="currentOrder" class="order-detail" v-loading="detailLoading">
<!-- 订单基本信息 -->
<div class="detail-section">
<div class="section-title">
<el-icon><InfoFilled /></el-icon>
<span>订单信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="订单号">
<span class="order-no">{{ currentOrder.orderNo }}</span>
</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag :type="getStatusType(currentOrder.status)" effect="dark">
{{ getStatusText(currentOrder.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="下单时间">
{{ formatTime(currentOrder.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="支付时间">
{{ formatTime(currentOrder.payTime) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 收货信息 -->
<div class="detail-section">
<div class="section-title">
<el-icon><Location /></el-icon>
<span>收货信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="收件人">{{ currentOrder.consignee }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ currentOrder.phone }}</el-descriptions-item>
<el-descriptions-item label="收货地址" :span="2">
{{ currentOrder.address }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 订单商品 -->
<div class="detail-section">
<div class="section-title">
<el-icon><ShoppingCart /></el-icon>
<span>订单商品</span>
</div>
<el-table :data="currentOrder.orderDetails" border>
<el-table-column label="商品" min-width="200">
<template #default="scope">
<div class="product-info">
<el-image
v-if="scope.row.productImage"
:src="scope.row.productImage"
fit="cover"
style="width: 60px; height: 60px; border-radius: 6px;"
>
<template #error>
<div class="image-slot">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
<span class="product-name">{{ scope.row.productName }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="price" label="单价" width="120" align="center">
<template #default="scope">
<span class="price">¥{{ scope.row.price }}</span>
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="100" align="center">
<template #default="scope">
<el-tag type="info">x{{ scope.row.quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="totalAmount" label="小计" width="120" align="center">
<template #default="scope">
<span class="price">¥{{ scope.row.totalAmount }}</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- 金额信息 -->
<div class="detail-section">
<div class="section-title">
<el-icon><Wallet /></el-icon>
<span>金额信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="商品总额">
<span class="amount-text">¥{{ currentOrder.totalAmount }}</span>
</el-descriptions-item>
<el-descriptions-item label="优惠金额">
<span class="discount-text">-¥{{ currentOrder.discountAmount || 0 }}</span>
</el-descriptions-item>
<el-descriptions-item label="实付金额" :span="2">
<span class="price-large">¥{{ currentOrder.actualAmount }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 物流信息 -->
<div class="detail-section" v-if="currentOrder.status >= 3">
<div class="section-title">
<el-icon><Van /></el-icon>
<span>物流信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="物流公司">
{{ currentOrder.shipCompany || '-' }}
</el-descriptions-item>
<el-descriptions-item label="运单号">
<span class="tracking-no">{{ currentOrder.shipNo || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="发货时间">
{{ formatTime(currentOrder.shipTime) }}
</el-descriptions-item>
<el-descriptions-item label="收货时间">
{{ formatTime(currentOrder.receiveTime) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 备注信息 -->
<div class="detail-section" v-if="currentOrder.remark">
<div class="section-title">
<el-icon><ChatDotRound /></el-icon>
<span>备注信息</span>
</div>
<div class="remark-content">
{{ currentOrder.remark }}
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="detailDialogVisible = false" size="large">关闭</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getOrders } from '../../../api/admin'
import {
Clock, Wallet, Van, Document, Search, Refresh, View,
InfoFilled, Location, ShoppingCart, Picture, ChatDotRound
} from '@element-plus/icons-vue'
import { getOrders, getOrderDetail } from '../../../api/admin'
export default {
name: 'OrderManagement',
components: {
Clock, Wallet, Van, Document, Search, Refresh, View,
InfoFilled, Location, ShoppingCart, Picture, ChatDotRound
},
setup() {
const loading = ref(false)
const detailLoading = ref(false)
const orderList = ref([])
const detailDialogVisible = ref(false)
const currentOrder = ref(null)
const searchForm = reactive({
keyword: '',
@ -110,6 +344,22 @@ export default {
total: 0
})
//
const stats = reactive({
pending: 0,
paid: 0,
shipped: 0,
total: 0
})
//
const calculateStats = (orders) => {
stats.pending = orders.filter(o => o.status === 1).length
stats.paid = orders.filter(o => o.status === 2).length
stats.shipped = orders.filter(o => o.status === 3).length
stats.total = orders.length
}
const loadOrders = async () => {
loading.value = true
try {
@ -123,7 +373,11 @@ export default {
const result = await getOrders(params)
orderList.value = result.records
pagination.total = result.total
//
calculateStats(result.records)
} catch (error) {
console.error('加载订单列表失败:', error)
ElMessage.error('加载订单列表失败')
} finally {
loading.value = false
@ -152,9 +406,20 @@ export default {
loadOrders()
}
const handleViewDetail = (order) => {
//
console.log('查看订单详情:', order)
const handleViewDetail = async (order) => {
detailDialogVisible.value = true
detailLoading.value = true
try {
const detail = await getOrderDetail(order.id)
currentOrder.value = detail
} catch (error) {
console.error('获取订单详情失败:', error)
ElMessage.error('获取订单详情失败')
detailDialogVisible.value = false
} finally {
detailLoading.value = false
}
}
const getStatusType = (status) => {
@ -163,7 +428,8 @@ export default {
2: 'primary',
3: 'success',
4: 'success',
5: 'danger'
5: 'danger',
6: 'info'
}
return types[status] || ''
}
@ -174,27 +440,38 @@ export default {
2: '已支付',
3: '已发货',
4: '已完成',
5: '已取消'
5: '已取消',
6: '已退款'
}
return texts[status] || '未知'
}
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
onMounted(() => {
loadOrders()
})
return {
loading,
detailLoading,
orderList,
searchForm,
pagination,
stats,
detailDialogVisible,
currentOrder,
handleSearch,
resetSearch,
handleSizeChange,
handleCurrentChange,
handleViewDetail,
getStatusType,
getStatusText
getStatusText,
formatTime
}
}
}
@ -203,47 +480,333 @@ export default {
<style scoped>
.order-management {
padding: 20px;
background: #f0f2f5;
min-height: calc(100vh - 60px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0 0 5px 0;
color: #333;
color: #1f2937;
font-size: 24px;
font-weight: 600;
}
.page-header p {
margin: 0;
color: #666;
color: #6b7280;
font-size: 14px;
}
.search-bar {
background: white;
padding: 20px;
border-radius: 8px;
/* 统计卡片样式 */
.stats-container {
margin-bottom: 20px;
}
.stat-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.stat-info {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #1f2937;
}
.stat-icon {
font-size: 48px;
opacity: 0.2;
}
.stat-card.pending .stat-value {
color: #f59e0b;
}
.stat-card.pending .stat-icon {
color: #f59e0b;
}
.stat-card.paid .stat-value {
color: #3b82f6;
}
.stat-card.paid .stat-icon {
color: #3b82f6;
}
.stat-card.shipped .stat-value {
color: #10b981;
}
.stat-card.shipped .stat-icon {
color: #10b981;
}
.stat-card.total .stat-value {
color: #8b5cf6;
}
.stat-card.total .stat-icon {
color: #8b5cf6;
}
/* 搜索卡片 */
.search-card {
margin-bottom: 20px;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-form {
margin: 0;
}
/* 表格卡片 */
.table-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* 订单号样式 */
.order-no {
font-family: 'Courier New', monospace;
color: #3b82f6;
font-weight: 500;
font-size: 13px;
}
/* 金额样式 */
.amount-text {
color: #6b7280;
font-weight: 500;
}
.price {
color: #f59e0b;
font-weight: 600;
font-size: 15px;
}
.price-large {
color: #ef4444;
font-weight: 700;
font-size: 22px;
}
.discount-text {
color: #10b981;
font-weight: 600;
}
/* ==================== 玻璃质感按钮样式 ==================== */
.glass-action-buttons {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.table-container {
background: white;
padding: 20px;
.glass-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.glass-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
transition: left 0.5s;
}
.glass-btn:hover::before {
left: 100%;
}
.glass-btn .btn-icon {
font-size: 16px;
transition: transform 0.3s;
}
.glass-btn:hover .btn-icon {
transform: scale(1.2) rotate(5deg);
}
.glass-btn:active {
transform: scale(0.95);
}
/* 详情按钮 - 蓝色玻璃 */
.glass-btn-primary {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.15) 0%,
rgba(37, 99, 235, 0.15) 100%
);
color: #3b82f6;
border: 1.5px solid rgba(59, 130, 246, 0.5);
}
.glass-btn-primary:hover {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.25) 0%,
rgba(37, 99, 235, 0.25) 100%
);
border-color: rgba(59, 130, 246, 0.8);
box-shadow: 0 8px 16px -4px rgba(59, 130, 246, 0.3),
0 4px 6px -2px rgba(59, 130, 246, 0.15),
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
/* 分页 */
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
/* 订单详情样式 */
.order-detail {
max-height: 70vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 24px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.section-title .el-icon {
color: #3b82f6;
}
/* 商品信息样式 */
.product-info {
display: flex;
align-items: center;
gap: 12px;
}
.product-name {
flex: 1;
font-weight: 500;
color: #1f2937;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
background-color: #f5f7fa;
border-radius: 6px;
color: #909399;
font-size: 24px;
}
/* 运单号样式 */
.tracking-no {
font-family: 'Courier New', monospace;
color: #10b981;
font-weight: 500;
}
/* 备注内容 */
.remark-content {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
color: #4b5563;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.dialog-footer {
text-align: right;
}
/* 响应式 */
@media (max-width: 768px) {
.stats-container :deep(.el-col) {
margin-bottom: 12px;
}
.glass-action-buttons {
flex-direction: column;
}
.glass-btn {
width: 100%;
}
}
</style>

View File

@ -2,12 +2,64 @@
<div class="product-management">
<!-- 页面标题 -->
<div class="page-header">
<h2>商品管理</h2>
<p>管理平台所有商品和审核</p>
<div>
<h2>商品管理</h2>
<p>管理和审核平台所有商品</p>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-container">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stat-card pending">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">待审核</div>
<div class="stat-value">{{ stats.pending }}</div>
</div>
<el-icon class="stat-icon"><Warning /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card approved">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">已通过</div>
<div class="stat-value">{{ stats.approved }}</div>
</div>
<el-icon class="stat-icon"><SuccessFilled /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card offline">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">已下架</div>
<div class="stat-value">{{ stats.offline }}</div>
</div>
<el-icon class="stat-icon"><RemoveFilled /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card total">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">商品总数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<el-icon class="stat-icon"><Goods /></el-icon>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-card class="search-card">
<el-form :inline="true" class="search-form">
<el-form-item>
<el-input
@ -16,10 +68,11 @@
prefix-icon="Search"
clearable
@clear="handleSearch"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-select v-model="searchForm.status" placeholder="商品状态" clearable>
<el-select v-model="searchForm.status" placeholder="商品状态" clearable style="width: 150px">
<el-option label="全部" value="" />
<el-option label="审核中" :value="0" />
<el-option label="已上架" :value="1" />
@ -31,53 +84,104 @@
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<!-- 商品表格 -->
<div class="table-container">
<el-card class="table-card">
<el-table :data="productList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="商品名称" width="200" show-overflow-tooltip />
<el-table-column prop="price" label="价格" width="100">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column label="商品图片" width="100" align="center">
<template #default="scope">
¥{{ scope.row.price }}
<div class="product-image">
<el-image
v-if="scope.row.imageList && scope.row.imageList.length > 0"
:src="scope.row.imageList[0]"
:alt="scope.row.name"
fit="cover"
style="width: 60px; height: 60px; border-radius: 6px; cursor: pointer;"
:preview-src-list="scope.row.imageList"
:initial-index="0"
preview-teleported
>
<template #error>
<div class="image-slot">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
<div v-else class="no-image">
<el-icon><Picture /></el-icon>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="origin" label="产地" width="120" />
<el-table-column prop="unit" label="单位" width="80" />
<el-table-column prop="salesCount" label="销量" width="80" />
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip>
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
<div class="product-name">
<span class="name-text">{{ scope.row.name }}</span>
<el-tag v-if="scope.row.status === 0" type="warning" size="small" class="status-badge">待审核</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="price" label="价格" width="120" align="center">
<template #default="scope">
<span class="price">¥{{ scope.row.price }}</span>
</template>
</el-table-column>
<el-table-column prop="origin" label="产地" width="120" align="center" />
<el-table-column prop="unit" label="单位" width="80" align="center" />
<el-table-column prop="salesCount" label="销量" width="100" align="center">
<template #default="scope">
<el-tag type="success" effect="plain">{{ scope.row.salesCount || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)" effect="dark">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160" />
<el-table-column label="操作" width="200" fixed="right">
<el-table-column prop="createTime" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="scope">
<el-button
v-if="scope.row.status === 0"
type="success"
size="small"
@click="handleAudit(scope.row, 1)"
>
通过
</el-button>
<el-button
v-if="scope.row.status === 0"
type="danger"
size="small"
@click="handleAudit(scope.row, 2)"
>
拒绝
</el-button>
<el-button
size="small"
@click="handleViewDetail(scope.row)"
>
查看详情
</el-button>
<div class="glass-action-buttons">
<!-- 审核中状态显示审核按钮 -->
<template v-if="scope.row.status === 0">
<button
class="glass-btn glass-btn-success"
@click="handleAudit(scope.row, 1)"
>
<el-icon class="btn-icon"><Check /></el-icon>
<span>通过</span>
</button>
<button
class="glass-btn glass-btn-danger"
@click="handleAudit(scope.row, 2)"
>
<el-icon class="btn-icon"><Close /></el-icon>
<span>拒绝</span>
</button>
</template>
<!-- 查看详情按钮 -->
<button
class="glass-btn glass-btn-primary"
@click="handleViewDetail(scope.row)"
>
<el-icon class="btn-icon"><View /></el-icon>
<span>详情</span>
</button>
</div>
</template>
</el-table-column>
</el-table>
@ -94,20 +198,149 @@
@current-change="handleCurrentChange"
/>
</div>
</div>
</el-card>
<!-- 商品详情对话框 -->
<el-dialog
v-model="detailVisible"
title="商品详情"
width="800px"
:close-on-click-modal="false"
>
<div v-if="currentProduct" class="product-detail" v-loading="detailLoading">
<!-- 商品图片 -->
<div class="detail-section">
<div class="section-title">
<el-icon><Picture /></el-icon>
<span>商品图片</span>
</div>
<div class="image-gallery">
<div
v-for="(image, index) in currentProduct.imageList"
:key="index"
class="gallery-item"
>
<el-image
:src="image"
fit="cover"
style="width: 120px; height: 120px; border-radius: 8px;"
:preview-src-list="currentProduct.imageList"
:initial-index="index"
preview-teleported
/>
<el-tag v-if="index === 0" size="small" type="warning" class="main-tag">主图</el-tag>
</div>
<div v-if="!currentProduct.imageList || currentProduct.imageList.length === 0" class="no-images">
<el-icon><Picture /></el-icon>
<span>暂无图片</span>
</div>
</div>
</div>
<!-- 基本信息 -->
<div class="detail-section">
<div class="section-title">
<el-icon><InfoFilled /></el-icon>
<span>基本信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="商品ID">{{ currentProduct.id }}</el-descriptions-item>
<el-descriptions-item label="商品名称">{{ currentProduct.name }}</el-descriptions-item>
<el-descriptions-item label="商品价格">
<span class="price-large">¥{{ currentProduct.price }}</span>
</el-descriptions-item>
<el-descriptions-item label="原价">
<span v-if="currentProduct.originPrice" class="origin-price">¥{{ currentProduct.originPrice }}</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="产地">{{ currentProduct.origin || '-' }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ currentProduct.unit || '-' }}</el-descriptions-item>
<el-descriptions-item label="重量">{{ currentProduct.weight || '-' }}</el-descriptions-item>
<el-descriptions-item label="保质期">{{ currentProduct.shelfLife || '-' }}</el-descriptions-item>
<el-descriptions-item label="储存方式" :span="2">
{{ currentProduct.storageMethod || '-' }}
</el-descriptions-item>
<el-descriptions-item label="商品状态">
<el-tag :type="getStatusType(currentProduct.status)">
{{ getStatusText(currentProduct.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="销量">
<el-tag type="success">{{ currentProduct.salesCount || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="浏览量">
<el-tag type="info">{{ currentProduct.viewCount || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="分类ID">{{ currentProduct.categoryId }}</el-descriptions-item>
<el-descriptions-item label="商家ID">{{ currentProduct.merchantId }}</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ formatDate(currentProduct.createTime) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 商品描述 -->
<div class="detail-section">
<div class="section-title">
<el-icon><Document /></el-icon>
<span>商品描述</span>
</div>
<div class="description-content">
{{ currentProduct.description || '暂无描述' }}
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="detailVisible = false" size="large">关闭</el-button>
<template v-if="currentProduct && currentProduct.status === 0">
<el-button
type="success"
@click="handleAuditInDialog(1)"
size="large"
class="audit-btn"
>
<el-icon class="btn-icon"><Check /></el-icon>
<span>审核通过</span>
</el-button>
<el-button
type="danger"
@click="handleAuditInDialog(2)"
size="large"
class="audit-btn"
>
<el-icon class="btn-icon"><Close /></el-icon>
<span>审核拒绝</span>
</el-button>
</template>
</div>
</template>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getProducts, auditProduct } from '../../../api/admin'
import {
Warning, SuccessFilled, RemoveFilled, Goods, Search, Refresh,
Picture, View, Check, Close, InfoFilled, Document
} from '@element-plus/icons-vue'
import { getProducts, getProductDetail, auditProduct } from '../../../api/admin'
export default {
name: 'ProductManagement',
components: {
Warning, SuccessFilled, RemoveFilled, Goods, Search, Refresh,
Picture, View, Check, Close, InfoFilled, Document
},
setup() {
const loading = ref(false)
const detailLoading = ref(false)
const productList = ref([])
const detailVisible = ref(false)
const currentProduct = ref(null)
const searchForm = reactive({
keyword: '',
@ -120,6 +353,23 @@ export default {
total: 0
})
//
const stats = reactive({
pending: 0,
approved: 0,
offline: 0,
total: 0
})
//
const calculateStats = (products) => {
stats.pending = products.filter(p => p.status === 0).length
stats.approved = products.filter(p => p.status === 1).length
stats.offline = products.filter(p => p.status === 2).length
stats.total = products.length
}
//
const loadProducts = async () => {
loading.value = true
try {
@ -133,18 +383,24 @@ export default {
const result = await getProducts(params)
productList.value = result.records
pagination.total = result.total
//
calculateStats(result.records)
} catch (error) {
console.error('加载商品列表失败:', error)
ElMessage.error('加载商品列表失败')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
loadProducts()
}
//
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
@ -152,6 +408,7 @@ export default {
loadProducts()
}
//
const handleSizeChange = (size) => {
pagination.size = size
loadProducts()
@ -162,6 +419,7 @@ export default {
loadProducts()
}
//
const handleAudit = async (product, status) => {
const action = status === 1 ? '通过' : '拒绝'
@ -181,43 +439,78 @@ export default {
loadProducts()
} catch (error) {
if (error !== 'cancel') {
console.error('审核失败:', error)
ElMessage.error(`商品审核${action}失败`)
}
}
}
const handleViewDetail = (product) => {
//
console.log('查看商品详情:', product)
//
const handleAuditInDialog = async (status) => {
if (!currentProduct.value) return
await handleAudit(currentProduct.value, status)
detailVisible.value = false
}
//
const handleViewDetail = async (product) => {
detailVisible.value = true
detailLoading.value = true
try {
const detail = await getProductDetail(product.id)
currentProduct.value = detail
} catch (error) {
console.error('获取商品详情失败:', error)
ElMessage.error('获取商品详情失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
//
const getStatusType = (status) => {
const types = { 0: 'warning', 1: 'success', 2: 'info' }
return types[status] || ''
}
//
const getStatusText = (status) => {
const texts = { 0: '审核中', 1: '已上架', 2: '已下架' }
return texts[status] || '未知'
}
//
const formatDate = (dateTime) => {
if (!dateTime) return '-'
return new Date(dateTime).toLocaleString('zh-CN')
}
onMounted(() => {
loadProducts()
})
return {
loading,
detailLoading,
productList,
searchForm,
pagination,
stats,
detailVisible,
currentProduct,
handleSearch,
resetSearch,
handleSizeChange,
handleCurrentChange,
handleAudit,
handleAuditInDialog,
handleViewDetail,
getStatusType,
getStatusText
getStatusText,
formatDate
}
}
}
@ -226,47 +519,431 @@ export default {
<style scoped>
.product-management {
padding: 20px;
background: #f0f2f5;
min-height: calc(100vh - 60px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0 0 5px 0;
color: #333;
color: #1f2937;
font-size: 24px;
font-weight: 600;
}
.page-header p {
margin: 0;
color: #666;
color: #6b7280;
font-size: 14px;
}
.search-bar {
background: white;
padding: 20px;
border-radius: 8px;
/* 统计卡片样式 */
.stats-container {
margin-bottom: 20px;
}
.stat-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.stat-info {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #1f2937;
}
.stat-icon {
font-size: 48px;
opacity: 0.2;
}
.stat-card.pending .stat-value {
color: #f59e0b;
}
.stat-card.pending .stat-icon {
color: #f59e0b;
}
.stat-card.approved .stat-value {
color: #10b981;
}
.stat-card.approved .stat-icon {
color: #10b981;
}
.stat-card.offline .stat-value {
color: #6b7280;
}
.stat-card.offline .stat-icon {
color: #6b7280;
}
.stat-card.total .stat-value {
color: #3b82f6;
}
.stat-card.total .stat-icon {
color: #3b82f6;
}
/* 搜索卡片 */
.search-card {
margin-bottom: 20px;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-form {
margin: 0;
}
/* 表格卡片 */
.table-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* 商品图片 */
.product-image {
display: flex;
align-items: center;
justify-content: center;
}
.no-image,
.image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
background-color: #f5f7fa;
border-radius: 6px;
color: #909399;
font-size: 24px;
}
/* 商品名称 */
.product-name {
display: flex;
align-items: center;
gap: 8px;
}
.name-text {
flex: 1;
}
.status-badge {
flex-shrink: 0;
}
/* 价格样式 */
.price {
color: #f59e0b;
font-weight: 600;
font-size: 16px;
}
.price-large {
color: #f59e0b;
font-weight: 700;
font-size: 20px;
}
.origin-price {
color: #9ca3af;
text-decoration: line-through;
}
/* ==================== 玻璃质感按钮样式 ==================== */
.glass-action-buttons {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.table-container {
background: white;
padding: 20px;
.glass-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.glass-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
transition: left 0.5s;
}
.glass-btn:hover::before {
left: 100%;
}
.glass-btn .btn-icon {
font-size: 16px;
transition: transform 0.3s;
}
.glass-btn:hover .btn-icon {
transform: scale(1.2) rotate(5deg);
}
.glass-btn:active {
transform: scale(0.95);
}
/* 通过按钮 - 绿色玻璃 */
.glass-btn-success {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.8) 0%,
rgba(5, 150, 105, 0.8) 100%
);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-btn-success:hover {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.9) 0%,
rgba(5, 150, 105, 0.9) 100%
);
box-shadow: 0 8px 16px -4px rgba(16, 185, 129, 0.4),
0 4px 6px -2px rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
/* 拒绝按钮 - 红色玻璃 */
.glass-btn-danger {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.8) 0%,
rgba(220, 38, 38, 0.8) 100%
);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-btn-danger:hover {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.9) 0%,
rgba(220, 38, 38, 0.9) 100%
);
box-shadow: 0 8px 16px -4px rgba(239, 68, 68, 0.4),
0 4px 6px -2px rgba(239, 68, 68, 0.2);
transform: translateY(-2px);
}
/* 详情按钮 - 蓝色玻璃 */
.glass-btn-primary {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.15) 0%,
rgba(37, 99, 235, 0.15) 100%
);
color: #3b82f6;
border: 1.5px solid rgba(59, 130, 246, 0.5);
}
.glass-btn-primary:hover {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.25) 0%,
rgba(37, 99, 235, 0.25) 100%
);
border-color: rgba(59, 130, 246, 0.8);
box-shadow: 0 8px 16px -4px rgba(59, 130, 246, 0.3),
0 4px 6px -2px rgba(59, 130, 246, 0.15),
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
/* 按钮禁用状态 */
.glass-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.glass-btn:disabled:hover {
transform: none;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* 分页 */
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
/* 商品详情样式 */
.product-detail {
max-height: 70vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 24px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.section-title .el-icon {
color: #3b82f6;
}
/* 图片画廊 */
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 16px;
}
.gallery-item {
position: relative;
}
.main-tag {
position: absolute;
top: 4px;
left: 4px;
}
.no-images {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
background: #f9fafb;
border-radius: 8px;
color: #9ca3af;
}
.no-images .el-icon {
font-size: 48px;
margin-bottom: 8px;
}
/* 描述内容 */
.description-content {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
color: #4b5563;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.dialog-footer {
text-align: right;
}
/* 对话框底部审核按钮样式 */
.dialog-footer .audit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 120px;
}
.dialog-footer .audit-btn .btn-icon {
font-size: 16px;
display: flex;
align-items: center;
}
/* 响应式 */
@media (max-width: 768px) {
.stats-container :deep(.el-col) {
margin-bottom: 12px;
}
.glass-action-buttons {
flex-direction: column;
}
.glass-btn {
width: 100%;
}
}
</style>

View File

@ -2,12 +2,64 @@
<div class="user-management">
<!-- 页面标题 -->
<div class="page-header">
<h2>用户管理</h2>
<p>管理平台所有用户账号</p>
<div>
<h2>用户管理</h2>
<p>管理平台所有用户账号</p>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-container">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stat-card total">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">用户总数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<el-icon class="stat-icon"><User /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card verified">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">已认证</div>
<div class="stat-value">{{ stats.verified }}</div>
</div>
<el-icon class="stat-icon"><CircleCheck /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card active">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">正常用户</div>
<div class="stat-value">{{ stats.active }}</div>
</div>
<el-icon class="stat-icon"><SuccessFilled /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card disabled">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">禁用用户</div>
<div class="stat-value">{{ stats.disabled }}</div>
</div>
<el-icon class="stat-icon"><CircleClose /></el-icon>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-card class="search-card">
<el-form :inline="true" class="search-form">
<el-form-item>
<el-input
@ -16,55 +68,118 @@
prefix-icon="Search"
clearable
@clear="handleSearch"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-select v-model="searchForm.status" placeholder="用户状态" clearable>
<el-select v-model="searchForm.status" placeholder="用户状态" clearable style="width: 150px">
<el-option label="全部" value="" />
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-select v-model="searchForm.isVerified" placeholder="认证状态" clearable style="width: 150px">
<el-option label="全部" value="" />
<el-option label="已认证" :value="1" />
<el-option label="未认证" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" icon="Search">搜索</el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<!-- 用户表格 -->
<div class="table-container">
<el-card class="table-card">
<el-table :data="userList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="nickname" label="昵称" width="120" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="email" label="邮箱" width="180" />
<el-table-column prop="isVerified" label="实名认证" width="100">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column label="用户信息" min-width="200">
<template #default="scope">
<el-tag :type="scope.row.isVerified ? 'success' : 'warning'">
{{ scope.row.isVerified ? '已认证' : '未认证' }}
</el-tag>
<div class="user-info">
<el-avatar :size="50" :src="scope.row.avatar" class="user-avatar">
<el-icon><UserFilled /></el-icon>
</el-avatar>
<div class="user-details">
<div class="user-name">
<span class="username">{{ scope.row.username }}</span>
<el-tag v-if="scope.row.isVerified" type="success" size="small" class="verified-badge">
<el-icon><CircleCheck /></el-icon> 已认证
</el-tag>
</div>
<div class="user-nickname">{{ scope.row.nickname || '未设置昵称' }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="phone" label="手机号" width="140" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
<span class="phone-number">{{ maskPhone(scope.row.phone) }}</span>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" width="200" align="center" show-overflow-tooltip>
<template #default="scope">
<span class="email-text">{{ scope.row.email || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="gender" label="性别" width="80" align="center">
<template #default="scope">
<el-tag v-if="scope.row.gender === 1" type="primary" effect="plain"></el-tag>
<el-tag v-else-if="scope.row.gender === 2" type="danger" effect="plain"></el-tag>
<el-tag v-else type="info" effect="plain">未知</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'" effect="dark">
{{ scope.row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="注册时间" width="160" />
<el-table-column prop="lastLoginTime" label="最后登录" width="160" />
<el-table-column label="操作" width="120" fixed="right">
<el-table-column prop="createTime" label="注册时间" width="180" align="center">
<template #default="scope">
<el-button
:type="scope.row.status === 1 ? 'danger' : 'success'"
size="small"
@click="handleStatusChange(scope.row)"
>
{{ scope.row.status === 1 ? '禁用' : '启用' }}
</el-button>
{{ formatTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right" align="center">
<template #default="scope">
<div class="glass-action-buttons">
<!-- 启用/禁用按钮 -->
<button
v-if="scope.row.status === 1"
class="glass-btn glass-btn-danger"
@click="handleStatusChange(scope.row)"
>
<el-icon class="btn-icon"><Lock /></el-icon>
<span>禁用</span>
</button>
<button
v-else
class="glass-btn glass-btn-success"
@click="handleStatusChange(scope.row)"
>
<el-icon class="btn-icon"><Unlock /></el-icon>
<span>启用</span>
</button>
<!-- 查看详情按钮 -->
<button
class="glass-btn glass-btn-primary"
@click="handleViewDetail(scope.row)"
>
<el-icon class="btn-icon"><View /></el-icon>
<span>详情</span>
</button>
</div>
</template>
</el-table-column>
</el-table>
@ -81,24 +196,183 @@
@current-change="handleCurrentChange"
/>
</div>
</div>
</el-card>
<!-- 用户详情对话框 -->
<el-dialog
v-model="detailVisible"
title="用户详情"
width="800px"
:close-on-click-modal="false"
>
<div v-if="currentUser" class="user-detail" v-loading="detailLoading">
<!-- 基本信息 -->
<div class="detail-section">
<div class="section-title">
<el-icon><InfoFilled /></el-icon>
<span>基本信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID">{{ currentUser.id }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ currentUser.username }}</el-descriptions-item>
<el-descriptions-item label="昵称">
{{ currentUser.nickname || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="性别">
<el-tag v-if="currentUser.gender === 1" type="primary" effect="plain"></el-tag>
<el-tag v-else-if="currentUser.gender === 2" type="danger" effect="plain"></el-tag>
<el-tag v-else type="info" effect="plain">未知</el-tag>
</el-descriptions-item>
<el-descriptions-item label="生日">
{{ currentUser.birthday || '-' }}
</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="currentUser.status === 1 ? 'success' : 'danger'" effect="dark">
{{ currentUser.status === 1 ? '正常' : '禁用' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 联系方式 -->
<div class="detail-section">
<div class="section-title">
<el-icon><Phone /></el-icon>
<span>联系方式</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="手机号">
<span class="phone-number">{{ currentUser.phone }}</span>
</el-descriptions-item>
<el-descriptions-item label="邮箱">
<span class="email-text">{{ currentUser.email || '未绑定' }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 实名认证信息 -->
<div class="detail-section">
<div class="section-title">
<el-icon><CreditCard /></el-icon>
<span>实名认证信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="认证状态">
<el-tag :type="currentUser.isVerified ? 'success' : 'warning'" effect="dark">
{{ currentUser.isVerified ? '已认证' : '未认证' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="真实姓名">
{{ currentUser.realName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="身份证号" :span="2">
{{ maskIdCard(currentUser.idCard) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 登录信息 -->
<div class="detail-section">
<div class="section-title">
<el-icon><Clock /></el-icon>
<span>登录信息</span>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="注册时间">
{{ formatTime(currentUser.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="最后登录">
{{ formatTime(currentUser.lastLoginTime) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ formatTime(currentUser.updateTime) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 用户头像 -->
<div class="detail-section" v-if="currentUser.avatar">
<div class="section-title">
<el-icon><Picture /></el-icon>
<span>用户头像</span>
</div>
<div class="avatar-container">
<el-image
:src="currentUser.avatar"
fit="cover"
style="width: 150px; height: 150px; border-radius: 50%;"
:preview-src-list="[currentUser.avatar]"
preview-teleported
>
<template #error>
<div class="image-slot">
<el-icon><UserFilled /></el-icon>
</div>
</template>
</el-image>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="detailVisible = false" size="large">关闭</el-button>
<template v-if="currentUser">
<el-button
v-if="currentUser.status === 1"
type="danger"
@click="handleStatusChangeInDialog(0)"
size="large"
class="action-btn"
>
<el-icon class="btn-icon"><Lock /></el-icon>
<span>禁用用户</span>
</el-button>
<el-button
v-else
type="success"
@click="handleStatusChangeInDialog(1)"
size="large"
class="action-btn"
>
<el-icon class="btn-icon"><Unlock /></el-icon>
<span>启用用户</span>
</el-button>
</template>
</div>
</template>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
User, CircleCheck, SuccessFilled, CircleClose, Search, Refresh,
UserFilled, Lock, Unlock, View, InfoFilled, Phone, CreditCard,
Clock, Picture
} from '@element-plus/icons-vue'
import { getUsers, updateUserStatus } from '../../../api/admin'
export default {
name: 'UserManagement',
components: {
User, CircleCheck, SuccessFilled, CircleClose, Search, Refresh,
UserFilled, Lock, Unlock, View, InfoFilled, Phone, CreditCard,
Clock, Picture
},
setup() {
const loading = ref(false)
const detailLoading = ref(false)
const userList = ref([])
const detailVisible = ref(false)
const currentUser = ref(null)
const searchForm = reactive({
keyword: '',
status: ''
status: '',
isVerified: ''
})
const pagination = reactive({
@ -107,6 +381,22 @@ export default {
total: 0
})
//
const stats = reactive({
total: 0,
verified: 0,
active: 0,
disabled: 0
})
//
const calculateStats = (users) => {
stats.verified = users.filter(u => u.isVerified === 1).length
stats.active = users.filter(u => u.status === 1).length
stats.disabled = users.filter(u => u.status === 0).length
stats.total = users.length
}
const loadUsers = async () => {
loading.value = true
try {
@ -114,13 +404,18 @@ export default {
page: pagination.page,
size: pagination.size,
keyword: searchForm.keyword,
status: searchForm.status
status: searchForm.status,
isVerified: searchForm.isVerified
}
const result = await getUsers(params)
userList.value = result.records
pagination.total = result.total
//
calculateStats(result.records)
} catch (error) {
console.error('加载用户列表失败:', error)
ElMessage.error('加载用户列表失败')
} finally {
loading.value = false
@ -135,6 +430,7 @@ export default {
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
searchForm.isVerified = ''
pagination.page = 1
loadUsers()
}
@ -169,25 +465,66 @@ export default {
loadUsers()
} catch (error) {
if (error !== 'cancel') {
console.error('操作失败:', error)
ElMessage.error(`用户${action}失败`)
}
}
}
const handleStatusChangeInDialog = async (newStatus) => {
if (!currentUser.value) return
const user = { ...currentUser.value, status: currentUser.value.status }
currentUser.value.status = newStatus
await handleStatusChange({ ...user, status: currentUser.value.status === 1 ? 0 : 1 })
detailVisible.value = false
}
const handleViewDetail = (user) => {
currentUser.value = user
detailVisible.value = true
}
//
const maskPhone = (phone) => {
if (!phone) return '-'
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
//
const maskIdCard = (idCard) => {
if (!idCard) return '-'
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
}
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
onMounted(() => {
loadUsers()
})
return {
loading,
detailLoading,
userList,
searchForm,
pagination,
stats,
detailVisible,
currentUser,
handleSearch,
resetSearch,
handleSizeChange,
handleCurrentChange,
handleStatusChange
handleStatusChange,
handleStatusChangeInDialog,
handleViewDetail,
maskPhone,
maskIdCard,
formatTime
}
}
}
@ -196,47 +533,394 @@ export default {
<style scoped>
.user-management {
padding: 20px;
background: #f0f2f5;
min-height: calc(100vh - 60px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0 0 5px 0;
color: #333;
color: #1f2937;
font-size: 24px;
font-weight: 600;
}
.page-header p {
margin: 0;
color: #666;
color: #6b7280;
font-size: 14px;
}
.search-bar {
background: white;
padding: 20px;
border-radius: 8px;
/* 统计卡片样式 */
.stats-container {
margin-bottom: 20px;
}
.stat-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.stat-info {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #1f2937;
}
.stat-icon {
font-size: 48px;
opacity: 0.2;
}
.stat-card.total .stat-value {
color: #3b82f6;
}
.stat-card.total .stat-icon {
color: #3b82f6;
}
.stat-card.verified .stat-value {
color: #10b981;
}
.stat-card.verified .stat-icon {
color: #10b981;
}
.stat-card.active .stat-value {
color: #8b5cf6;
}
.stat-card.active .stat-icon {
color: #8b5cf6;
}
.stat-card.disabled .stat-value {
color: #ef4444;
}
.stat-card.disabled .stat-icon {
color: #ef4444;
}
/* 搜索卡片 */
.search-card {
margin-bottom: 20px;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-form {
margin: 0;
}
/* 表格卡片 */
.table-card {
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* 用户信息样式 */
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
flex-shrink: 0;
border: 2px solid #e5e7eb;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-name {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.username {
font-weight: 600;
color: #1f2937;
font-size: 15px;
}
.verified-badge {
display: inline-flex;
align-items: center;
gap: 4px;
}
.user-nickname {
color: #6b7280;
font-size: 13px;
}
/* 手机号和邮箱样式 */
.phone-number {
font-family: 'Courier New', monospace;
color: #6b7280;
font-weight: 500;
}
.email-text {
color: #6b7280;
}
/* ==================== 玻璃质感按钮样式 ==================== */
.glass-action-buttons {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.table-container {
background: white;
padding: 20px;
.glass-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.glass-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
transition: left 0.5s;
}
.glass-btn:hover::before {
left: 100%;
}
.glass-btn .btn-icon {
font-size: 16px;
transition: transform 0.3s;
}
.glass-btn:hover .btn-icon {
transform: scale(1.2) rotate(5deg);
}
.glass-btn:active {
transform: scale(0.95);
}
/* 启用按钮 - 绿色玻璃 */
.glass-btn-success {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.8) 0%,
rgba(5, 150, 105, 0.8) 100%
);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-btn-success:hover {
background: linear-gradient(
135deg,
rgba(16, 185, 129, 0.9) 0%,
rgba(5, 150, 105, 0.9) 100%
);
box-shadow: 0 8px 16px -4px rgba(16, 185, 129, 0.4),
0 4px 6px -2px rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
/* 禁用按钮 - 红色玻璃 */
.glass-btn-danger {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.8) 0%,
rgba(220, 38, 38, 0.8) 100%
);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-btn-danger:hover {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.9) 0%,
rgba(220, 38, 38, 0.9) 100%
);
box-shadow: 0 8px 16px -4px rgba(239, 68, 68, 0.4),
0 4px 6px -2px rgba(239, 68, 68, 0.2);
transform: translateY(-2px);
}
/* 详情按钮 - 蓝色玻璃 */
.glass-btn-primary {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.15) 0%,
rgba(37, 99, 235, 0.15) 100%
);
color: #3b82f6;
border: 1.5px solid rgba(59, 130, 246, 0.5);
}
.glass-btn-primary:hover {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.25) 0%,
rgba(37, 99, 235, 0.25) 100%
);
border-color: rgba(59, 130, 246, 0.8);
box-shadow: 0 8px 16px -4px rgba(59, 130, 246, 0.3),
0 4px 6px -2px rgba(59, 130, 246, 0.15),
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
/* 分页 */
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
/* 用户详情样式 */
.user-detail {
max-height: 70vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 24px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.section-title .el-icon {
color: #3b82f6;
}
/* 头像容器 */
.avatar-container {
display: flex;
justify-content: center;
padding: 20px;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 150px;
height: 150px;
background-color: #f5f7fa;
border-radius: 50%;
color: #909399;
font-size: 48px;
}
.dialog-footer {
text-align: right;
}
/* 对话框底部操作按钮样式 */
.dialog-footer .action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 120px;
}
.dialog-footer .action-btn .btn-icon {
font-size: 16px;
display: flex;
align-items: center;
}
/* 响应式 */
@media (max-width: 768px) {
.stats-container :deep(.el-col) {
margin-bottom: 12px;
}
.glass-action-buttons {
flex-direction: column;
}
.glass-btn {
width: 100%;
}
.user-info {
flex-direction: column;
text-align: center;
}
}
</style>

View File

@ -190,7 +190,7 @@
<el-descriptions-item label="邮箱">{{ selectedCustomer.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ getGenderText(selectedCustomer.gender) }}</el-descriptions-item>
<el-descriptions-item label="生日">{{ selectedCustomer.birthday || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatTime(selectedCustomer.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatTime(selectedCustomer.createTime) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ formatTime(selectedCustomer.lastLoginTime) }}</el-descriptions-item>
<el-descriptions-item label="订单总数">{{ selectedCustomer.orderCount }}</el-descriptions-item>
<el-descriptions-item label="总消费金额">¥{{ selectedCustomer.totalAmount.toFixed(2) }}</el-descriptions-item>
@ -216,8 +216,8 @@
<el-tag :type="getOrderStatusType(row.status)">{{ row.statusName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="下单时间" width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
<el-table-column prop="createTime" label="下单时间" width="180">
<template #default="{ row }">{{ formatTime(row.createTime) }}</template>
</el-table-column>
<el-table-column label="商品数量" width="100">
<template #default="{ row }">{{ row.orderDetails?.length || 0 }}</template>
@ -285,8 +285,8 @@ export default {
totalAmount: 0,
validOrderCount: 0, //
orders: [],
firstOrderTime: order.createdAt,
lastOrderTime: order.createdAt
firstOrderTime: order.createTime,
lastOrderTime: order.createTime
})
}
@ -301,11 +301,11 @@ export default {
}
//
if (new Date(order.createdAt) < new Date(customer.firstOrderTime)) {
customer.firstOrderTime = order.createdAt
if (new Date(order.createTime) < new Date(customer.firstOrderTime)) {
customer.firstOrderTime = order.createTime
}
if (new Date(order.createdAt) > new Date(customer.lastOrderTime)) {
customer.lastOrderTime = order.createdAt
if (new Date(order.createTime) > new Date(customer.lastOrderTime)) {
customer.lastOrderTime = order.createTime
}
})

View File

@ -80,9 +80,9 @@
</template>
</el-table-column>
<el-table-column prop="salesCount" label="销量" width="80" />
<el-table-column prop="createdAt" label="创建时间" width="180">
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
{{ formatDate(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">

View File

@ -297,39 +297,43 @@
</div>
</div>
<!-- 右下角聊天悬浮球 -->
<div
v-if="product && product.merchantId"
class="chat-fab"
:style="{
left: fabPos.x + 'px',
top: fabPos.y + 'px'
}"
@mousedown="startDrag"
@touchstart="startDragTouch"
title="联系商家"
@click="openChat"
>
<span class="chat-fab-icon">💬</span>
</div>
<!-- 右下角聊天悬浮球 - 使用Teleport挂载到body -->
<Teleport to="body">
<div
v-if="product && product.merchantId"
class="chat-fab"
:style="{
left: fabPos.x + 'px',
top: fabPos.y + 'px'
}"
@mousedown="startDrag"
@touchstart="startDragTouch"
title="联系商家"
@click="openChat"
>
<span class="chat-fab-icon">💬</span>
</div>
</Teleport>
<!-- 聊天窗口 -->
<ChatWindow
v-if="product && product.merchantId && showChatWindow"
:visible="showChatWindow"
:merchant-id="product.merchantId"
:merchant-name="product.merchantName || '商家'"
user-type="user"
@close="closeChatWindow"
@minimize="minimizeChatWindow"
:anchor-bottom="anchorBottom"
:anchor-right="anchorRight"
/>
<!-- 聊天窗口 - 使用Teleport挂载到body -->
<Teleport to="body">
<ChatWindow
v-if="product && product.merchantId && showChatWindow"
:visible="showChatWindow"
:merchant-id="product.merchantId"
:merchant-name="product.merchantName || '商家'"
user-type="user"
@close="closeChatWindow"
@minimize="minimizeChatWindow"
:anchor-bottom="anchorBottom"
:anchor-right="anchorRight"
/>
</Teleport>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getProductDetail, getHotProducts } from '../../api/product'
@ -378,7 +382,10 @@ export default {
const reviewPageSize = ref(10)
//
const fabPos = ref({ x: window.innerWidth - 96, y: Math.max(120, window.innerHeight - 280) })
const fabPos = ref({
x: typeof window !== 'undefined' ? window.innerWidth - 80 : 0,
y: typeof window !== 'undefined' ? window.innerHeight - 80 : 0
})
const dragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
@ -710,12 +717,33 @@ export default {
const goToProduct = (productId) => {
router.push(`/product/${productId}`)
loadProductDetail()
loadRecommendedProducts()
loadCartCount()
loadReviews()
}
//
watch(() => route.params.id, (newId, oldId) => {
if (newId && newId !== oldId) {
//
loading.value = true
product.value = null
currentImage.value = ''
quantity.value = 1
isFavorite.value = false
activeTab.value = 'description'
reviewPage.value = 1
//
loadProductDetail().then(() => {
checkFavoriteStatus()
})
loadRecommendedProducts()
loadCartCount()
loadReviews()
//
window.scrollTo({ top: 0, behavior: 'smooth' })
}
})
//
onMounted(() => {
loadProductDetail().then(() => {
@ -731,10 +759,36 @@ export default {
if (saved) {
const pos = JSON.parse(saved)
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
fabPos.value = pos
fabPos.value = clampPos(pos.x, pos.y)
}
}
} catch (e) {}
//
const handleResize = () => {
//
const rightDistance = window.innerWidth - fabPos.value.x - 56
const bottomDistance = window.innerHeight - fabPos.value.y - 56
if (rightDistance < 100) {
//
fabPos.value.x = window.innerWidth - rightDistance - 56
}
if (bottomDistance < 100) {
//
fabPos.value.y = window.innerHeight - bottomDistance - 56
}
//
fabPos.value = clampPos(fabPos.value.x, fabPos.value.y)
}
window.addEventListener('resize', handleResize)
//
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
})
return {
@ -853,6 +907,16 @@ export default {
gap: 20px;
}
.cart-container {
position: relative;
display: flex;
align-items: center;
}
.cart-badge {
display: flex;
}
.cart-container {
position: relative;
}
@ -940,7 +1004,7 @@ export default {
.main-image {
width: 100%;
height: 480px;
height: 490px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
@ -1460,7 +1524,7 @@ export default {
}
.chat-fab {
position: fixed;
position: fixed !important;
width: 56px;
height: 56px;
border-radius: 50%;
@ -1471,9 +1535,11 @@ export default {
justify-content: center;
color: white;
cursor: grab;
z-index: 2100;
z-index: 9999;
transition: box-shadow 0.2s ease, transform 0.1s ease-out;
user-select: none;
will-change: left, top;
pointer-events: auto;
}
.chat-fab:active {
@ -1545,16 +1611,23 @@ export default {
}
.cart-badge :deep(.el-badge__content) {
top: -6px;
right: -6px;
position: absolute;
top: 0;
right: 0;
transform: translate(25%, -25%);
background: #ff4757;
border: 2px solid white;
font-size: 10px;
font-weight: 700;
min-width: 16px;
height: 16px;
line-height: 12px;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(255, 71, 87, 0.3);
min-width: 18px;
height: 18px;
line-height: 14px;
padding: 0 4px;
border-radius: 9px;
box-shadow: 0 2px 4px rgba(255, 71, 87, 0.4);
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -220,26 +220,8 @@
</div>
</template>
<div class="logistics-info">
<div class="logistics-row">
<span class="label">物流公司</span>
<span class="value">{{ order.shipCompany }}</span>
</div>
<div class="logistics-row" v-if="order.shipNo">
<span class="label">运单号</span>
<span class="value">
{{ order.shipNo }}
<el-button
type="primary"
text
size="small"
@click="copyTrackingNo"
>
复制
</el-button>
</span>
</div>
</div>
<!-- 物流轨迹组件 -->
<ExpressTracking :order-id="order.id" :show-map="true" />
</el-card>
</div>
</div>
@ -266,6 +248,7 @@ import {
import { userStore } from '../../store/user'
import request from '../../utils/request'
import orderApi from '../../api/order'
import ExpressTracking from '../../components/ExpressTracking.vue'
export default {
name: 'OrderDetail',
@ -280,7 +263,8 @@ export default {
Close,
CircleCheck,
RefreshRight
},
,
ExpressTracking},
setup() {
const router = useRouter()
const route = useRoute()

477
tmp/ExpressMapTest.java Normal file
View File

@ -0,0 +1,477 @@
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.io.File;
/**
* 阿里云物流轨迹地图API测试
* 快递单号434815453222560韵达快递
*/
public class ExpressMapTest {
public static void main(String[] args) {
String host = "https://alicloudmarket8002.kdniao.com";
String path = "/api/track/8002";
String appcode = "e7b5746ef8854cf2a6da24342ab53af6";
String body = "{\"CustomInfo\":\"0000\",\"LogisticCode\":\"434815453222560\"}";
System.out.println("========================================");
System.out.println("阿里云物流轨迹地图API测试");
System.out.println("========================================");
System.out.println("请求地址: " + host + path);
System.out.println("快递公司: 韵达快递");
System.out.println("快递单号: 434815453222560");
System.out.println("----------------------------------------");
try {
URL url = new URL(host + path);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Authorization", "APPCODE " + appcode);
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
connection.setDoOutput(true);
try (OutputStream os = connection.getOutputStream()) {
byte[] input = body.getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length);
}
int httpCode = connection.getResponseCode();
System.out.println("HTTP状态码: " + httpCode);
System.out.println("----------------------------------------");
BufferedReader br;
if (httpCode == 200) {
br = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
} else {
br = new BufferedReader(new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8));
}
StringBuilder response = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
response.append(line);
}
br.close();
if (httpCode == 200) {
System.out.println("✅ API调用成功");
System.out.println("返回结果:");
System.out.println(response.toString());
java.io.FileWriter fileWriter = new java.io.FileWriter("map_result.json");
fileWriter.write(response.toString());
fileWriter.close();
System.out.println("\n✅ 结果已保存到: map_result.json");
// 获取驾车路线数据
String routeData = getDrivingRoute();
createMapHtml(response.toString(), routeData);
} else {
System.out.println("❌ API调用失败");
System.out.println("错误信息: " + response.toString());
}
System.out.println("========================================");
} catch (Exception e) {
System.out.println("❌ 发生异常");
e.printStackTrace();
}
}
/**
* 调用高德Web服务API获取驾车路线
*/
private static String getDrivingRoute() {
try {
String key = "a0f9b8eecd097889d3aa21f4bd6665ff"; // Web服务API Key
String origin = "113.264385,23.129163"; // 广州
String destination = "114.057868,22.543099"; // 深圳
String urlStr = "https://restapi.amap.com/v3/direction/driving?origin=" + origin +
"&destination=" + destination +
"&key=" + key +
"&output=json";
System.out.println("\n🚗 调用高德驾车路线规划API...");
System.out.println("起点: 广州市");
System.out.println("终点: 深圳市");
URL url = new URL(urlStr);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
int httpCode = connection.getResponseCode();
if (httpCode == 200) {
BufferedReader br = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)
);
StringBuilder response = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
response.append(line);
}
br.close();
System.out.println("✅ 驾车路线获取成功");
return response.toString();
} else {
System.out.println("❌ 驾车路线获取失败,状态码: " + httpCode);
return null;
}
} catch (Exception e) {
System.out.println("❌ 获取驾车路线异常: " + e.getMessage());
return null;
}
}
/**
* 创建HTML地图页面使用Web服务API的路线数据
*/
private static void createMapHtml(String jsonData, String routeData) {
try {
String routeJson = routeData != null ? routeData : "null";
String html = "<!DOCTYPE html>\n" +
"<html lang=\"zh-CN\">\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" +
" <title>物流轨迹地图 - 韵达快递</title>\n" +
" <script>\n" +
" window._AMapSecurityConfig = {\n" +
" securityJsCode: '1d765513173e511dd6e1c963f1b38c5e'\n" +
" };\n" +
" </script>\n" +
" <script src=\"https://webapi.amap.com/maps?v=2.0&key=47c7546d2ac2a12ff7de8caf5b62c753\"></script>\n" +
" <style>\n" +
" * { margin: 0; padding: 0; box-sizing: border-box; }\n" +
" body { font-family: 'Arial', 'Microsoft YaHei', sans-serif; background: #f5f5f5; }\n" +
" .container { max-width: 1600px; margin: 0 auto; padding: 20px; }\n" +
" .header {\n" +
" background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n" +
" color: white;\n" +
" padding: 30px;\n" +
" border-radius: 10px;\n" +
" margin-bottom: 20px;\n" +
" box-shadow: 0 4px 6px rgba(0,0,0,0.1);\n" +
" }\n" +
" .header h1 { font-size: 28px; margin-bottom: 10px; }\n" +
" .header p { opacity: 0.9; }\n" +
" .express-info {\n" +
" background: white;\n" +
" padding: 20px;\n" +
" border-radius: 10px;\n" +
" margin-bottom: 20px;\n" +
" box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n" +
" }\n" +
" .info-item {\n" +
" display: inline-block;\n" +
" margin-right: 30px;\n" +
" padding: 10px 0;\n" +
" }\n" +
" .info-label {\n" +
" color: #666;\n" +
" font-size: 14px;\n" +
" margin-right: 10px;\n" +
" }\n" +
" .info-value {\n" +
" color: #333;\n" +
" font-weight: bold;\n" +
" font-size: 16px;\n" +
" }\n" +
" .main-content {\n" +
" display: grid;\n" +
" grid-template-columns: 1fr 400px;\n" +
" gap: 20px;\n" +
" }\n" +
" .map-container {\n" +
" background: white;\n" +
" border-radius: 10px;\n" +
" overflow: hidden;\n" +
" box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n" +
" }\n" +
" #map {\n" +
" width: 100%;\n" +
" height: 600px;\n" +
" }\n" +
" .timeline-container {\n" +
" background: white;\n" +
" border-radius: 10px;\n" +
" padding: 20px;\n" +
" box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n" +
" max-height: 600px;\n" +
" overflow-y: auto;\n" +
" }\n" +
" .timeline-title {\n" +
" font-size: 18px;\n" +
" font-weight: bold;\n" +
" color: #333;\n" +
" margin-bottom: 20px;\n" +
" padding-bottom: 10px;\n" +
" border-bottom: 2px solid #667eea;\n" +
" }\n" +
" .timeline-item {\n" +
" position: relative;\n" +
" padding-left: 30px;\n" +
" padding-bottom: 20px;\n" +
" border-left: 2px solid #e0e0e0;\n" +
" }\n" +
" .timeline-item:last-child {\n" +
" border-left: none;\n" +
" }\n" +
" .timeline-dot {\n" +
" position: absolute;\n" +
" left: -6px;\n" +
" top: 0;\n" +
" width: 12px;\n" +
" height: 12px;\n" +
" border-radius: 50%;\n" +
" background: #667eea;\n" +
" }\n" +
" .timeline-item:first-child .timeline-dot {\n" +
" background: #28a745;\n" +
" width: 16px;\n" +
" height: 16px;\n" +
" left: -8px;\n" +
" }\n" +
" .timeline-time {\n" +
" color: #999;\n" +
" font-size: 12px;\n" +
" margin-bottom: 5px;\n" +
" }\n" +
" .timeline-location {\n" +
" color: #667eea;\n" +
" font-weight: bold;\n" +
" font-size: 14px;\n" +
" margin-bottom: 5px;\n" +
" }\n" +
" .timeline-desc {\n" +
" color: #333;\n" +
" font-size: 14px;\n" +
" line-height: 1.6;\n" +
" }\n" +
" .status-badge {\n" +
" display: inline-block;\n" +
" padding: 5px 15px;\n" +
" background: #28a745;\n" +
" color: white;\n" +
" border-radius: 20px;\n" +
" font-size: 14px;\n" +
" margin-top: 10px;\n" +
" }\n" +
" .debug-info {\n" +
" background: #e7f3ff;\n" +
" border: 1px solid #b3d9ff;\n" +
" border-radius: 5px;\n" +
" padding: 10px;\n" +
" margin-top: 10px;\n" +
" font-size: 12px;\n" +
" color: #0066cc;\n" +
" }\n" +
" @media (max-width: 1200px) {\n" +
" .main-content {\n" +
" grid-template-columns: 1fr;\n" +
" }\n" +
" }\n" +
" </style>\n" +
"</head>\n" +
"<body>\n" +
" <div class=\"container\">\n" +
" <div class=\"header\">\n" +
" <h1>🗺️ 物流轨迹地图</h1>\n" +
" <p>实时追踪快递位置 - 真实驾车路线展示</p>\n" +
" <span class=\"status-badge\">✓ 在途中</span>\n" +
" </div>\n" +
" <div class=\"express-info\">\n" +
" <div class=\"info-item\">\n" +
" <span class=\"info-label\">快递公司:</span>\n" +
" <span class=\"info-value\">韵达快递</span>\n" +
" </div>\n" +
" <div class=\"info-item\">\n" +
" <span class=\"info-label\">快递单号:</span>\n" +
" <span class=\"info-value\">434815453222560</span>\n" +
" </div>\n" +
" <div class=\"info-item\">\n" +
" <span class=\"info-label\">当前位置:</span>\n" +
" <span class=\"info-value\" id=\"currentLocation\">加载中...</span>\n" +
" </div>\n" +
" <div class=\"debug-info\" id=\"debugInfo\">🚗 使用Web服务API获取真实路线...</div>\n" +
" </div>\n" +
" <div class=\"main-content\">\n" +
" <div class=\"map-container\">\n" +
" <div id=\"map\"></div>\n" +
" </div>\n" +
" <div class=\"timeline-container\">\n" +
" <div class=\"timeline-title\">📦 物流轨迹</div>\n" +
" <div id=\"timeline\"></div>\n" +
" </div>\n" +
" </div>\n" +
" </div>\n" +
" <script>\n" +
" const apiData = " + jsonData + ";\n" +
" const routeData = " + routeJson + ";\n" +
" \n" +
" console.log('🔍 物流数据:', apiData);\n" +
" console.log('🚗 路线数据:', routeData);\n" +
" \n" +
" // 预设主要城市坐标\n" +
" const cityCoords = {\n" +
" '广州市': [113.264385, 23.129163],\n" +
" '深圳市': [114.057868, 22.543099]\n" +
" };\n" +
" \n" +
" // 初始化地图\n" +
" const map = new AMap.Map('map', {\n" +
" zoom: 9,\n" +
" center: [113.664385, 23.129163],\n" +
" mapStyle: 'amap://styles/normal'\n" +
" });\n" +
" \n" +
" console.log('✅ 地图初始化完成');\n" +
" \n" +
" document.getElementById('currentLocation').textContent = apiData.Location || '未知';\n" +
" \n" +
" const traces = apiData.Traces || [];\n" +
" const timelineHTML = traces.map((trace, index) => `\n" +
" <div class=\"timeline-item\">\n" +
" <div class=\"timeline-dot\"></div>\n" +
" <div class=\"timeline-time\">${trace.AcceptTime || ''}</div>\n" +
" <div class=\"timeline-location\">${trace.Location || ''}</div>\n" +
" <div class=\"timeline-desc\">${trace.AcceptStation || ''}</div>\n" +
" </div>\n" +
" `).join('');\n" +
" document.getElementById('timeline').innerHTML = timelineHTML;\n" +
" \n" +
" const cities = [...new Set(traces.map(t => t.Location).filter(l => l))];\n" +
" console.log('🏙️ 城市列表:', cities);\n" +
" \n" +
" let debugMsg = `找到 ${cities.length} 个城市: ${cities.join(' → ')}`;\n" +
" \n" +
" // 添加城市标记\n" +
" cities.forEach((city, index) => {\n" +
" const coords = cityCoords[city];\n" +
" if (coords) {\n" +
" const marker = new AMap.CircleMarker({\n" +
" center: coords,\n" +
" radius: 15,\n" +
" strokeColor: '#667eea',\n" +
" strokeWeight: 3,\n" +
" fillColor: index === 0 ? '#28a745' : '#667eea',\n" +
" fillOpacity: 0.9,\n" +
" zIndex: 100\n" +
" });\n" +
" \n" +
" const text = new AMap.Text({\n" +
" text: `${index + 1}. ${city}`,\n" +
" position: coords,\n" +
" offset: new AMap.Pixel(0, -30),\n" +
" style: {\n" +
" 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',\n" +
" 'border': 'none',\n" +
" 'color': 'white',\n" +
" 'font-size': '14px',\n" +
" 'font-weight': 'bold',\n" +
" 'padding': '8px 12px',\n" +
" 'border-radius': '8px',\n" +
" 'box-shadow': '0 4px 8px rgba(102,126,234,0.4)'\n" +
" }\n" +
" });\n" +
" \n" +
" map.add([marker, text]);\n" +
" }\n" +
" });\n" +
" \n" +
" // 绘制路线\n" +
" if (routeData && routeData.status === '1' && routeData.route && routeData.route.paths) {\n" +
" console.log('✅ 使用Web服务API路线数据');\n" +
" \n" +
" const path = routeData.route.paths[0];\n" +
" const steps = path.steps;\n" +
" const polylinePath = [];\n" +
" \n" +
" // 提取所有路径点\n" +
" steps.forEach(step => {\n" +
" const points = step.polyline.split(';');\n" +
" points.forEach(point => {\n" +
" const coords = point.split(',');\n" +
" if (coords.length === 2) {\n" +
" polylinePath.push([parseFloat(coords[0]), parseFloat(coords[1])]);\n" +
" }\n" +
" });\n" +
" });\n" +
" \n" +
" // 绘制路径\n" +
" const polyline = new AMap.Polyline({\n" +
" path: polylinePath,\n" +
" strokeColor: '#667eea',\n" +
" strokeWeight: 8,\n" +
" strokeOpacity: 0.9,\n" +
" strokeStyle: 'solid',\n" +
" lineJoin: 'round',\n" +
" lineCap: 'round',\n" +
" zIndex: 50,\n" +
" showDir: true\n" +
" });\n" +
" map.add(polyline);\n" +
" \n" +
" const distance = (path.distance / 1000).toFixed(1);\n" +
" const duration = Math.round(path.duration / 60);\n" +
" \n" +
" debugMsg += `\\n✓ 已绘制真实驾车路线`;\n" +
" debugMsg += `\\n预计距离: ${distance} 公里`;\n" +
" debugMsg += `\\n预计时间: ${duration} 分钟`;\n" +
" \n" +
" map.setFitView();\n" +
" } else {\n" +
" console.warn('⚠️ 使用直线连接');\n" +
" \n" +
" const waypoints = cities.map(c => cityCoords[c]).filter(c => c);\n" +
" const polyline = new AMap.Polyline({\n" +
" path: waypoints,\n" +
" strokeColor: '#667eea',\n" +
" strokeWeight: 8,\n" +
" strokeOpacity: 0.8,\n" +
" strokeStyle: 'solid',\n" +
" lineJoin: 'round',\n" +
" showDir: true\n" +
" });\n" +
" map.add(polyline);\n" +
" \n" +
" debugMsg += '\\n⚠ 使用直线连接Web服务API未返回路线数据';\n" +
" map.setFitView();\n" +
" }\n" +
" \n" +
" document.getElementById('debugInfo').textContent = debugMsg;\n" +
" console.log('🎉 地图加载完成');\n" +
" </script>\n" +
"</body>\n" +
"</html>";
java.io.FileWriter fileWriter = new java.io.FileWriter("map_view.html");
fileWriter.write(html);
fileWriter.close();
String absPath = new File("map_view.html").getAbsolutePath();
System.out.println("\n✅ HTML地图页面已创建: map_view.html");
System.out.println("\n📌 请在浏览器中打开以下文件查看物流地图:");
System.out.println(" file://" + absPath);
System.out.println("\n🔑 改进内容:");
System.out.println(" ✓ 使用Web服务API获取真实驾车路线");
System.out.println(" ✓ 在Java端调用路径规划API");
System.out.println(" ✓ 避免JS API的安全密钥问题");
System.out.println(" ✓ 显示预计距离和时间");
} catch (Exception e) {
System.out.println("❌ 创建HTML失败: " + e.getMessage());
e.printStackTrace();
}
}
}

106
tmp/ExpressQueryTest.java Normal file
View File

@ -0,0 +1,106 @@
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.List;
import java.util.Map;
/**
* 阿里云物流API测试
* 测试快递单号434815453222560韵达快递
*/
public class ExpressQueryTest {
public static void main(String[] args) {
String host = "https://wuliu.market.alicloudapi.com";
String path = "/kdi";
String appcode = "e7b5746ef8854cf2a6da24342ab53af6";
String no = "434815453222560"; // 韵达快递单号
String type = "yunda"; // 韵达快递代码
String urlSend = host + path + "?no=" + no + "&type=" + type;
System.out.println("========================================");
System.out.println("阿里云物流API测试");
System.out.println("========================================");
System.out.println("请求地址: " + urlSend);
System.out.println("快递公司: 韵达快递");
System.out.println("快递单号: " + no);
System.out.println("----------------------------------------");
try {
URL url = new URL(urlSend);
HttpURLConnection httpURLCon = (HttpURLConnection) url.openConnection();
httpURLCon.setRequestProperty("Authorization", "APPCODE " + appcode);
int httpCode = httpURLCon.getResponseCode();
System.out.println("HTTP状态码: " + httpCode);
System.out.println("----------------------------------------");
if (httpCode == 200) {
String json = read(httpURLCon.getInputStream());
System.out.println("✅ API调用成功");
System.out.println("返回结果:");
System.out.println(json);
System.out.println("========================================");
} else {
Map<String, List<String>> map = httpURLCon.getHeaderFields();
List<String> errorList = map.get("X-Ca-Error-Message");
String error = errorList != null && !errorList.isEmpty() ? errorList.get(0) : "未知错误";
System.out.println("❌ API调用失败");
System.out.println("错误信息: " + error);
if (httpCode == 400 && error.equals("Invalid AppCode `not exists`")) {
System.out.println("原因: AppCode错误");
} else if (httpCode == 400 && error.equals("Invalid Url")) {
System.out.println("原因: 请求的 Method、Path 或者环境错误");
} else if (httpCode == 400 && error.equals("Invalid Param Location")) {
System.out.println("原因: 参数错误");
} else if (httpCode == 403 && error.equals("Unauthorized")) {
System.out.println("原因: 服务未被授权或URL和Path不正确");
} else if (httpCode == 403 && error.equals("Quota Exhausted")) {
System.out.println("原因: 套餐包次数用完");
} else if (httpCode == 403 && error.equals("Api Market Subscription quota exhausted")) {
System.out.println("原因: 套餐包次数用完,请续购套餐");
} else {
System.out.println("原因: 参数名错误或其他错误");
}
// 打印所有响应头帮助调试
System.out.println("\n所有响应头:");
for (Map.Entry<String, List<String>> entry : map.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
System.out.println("========================================");
}
} catch (MalformedURLException e) {
System.out.println("❌ URL格式错误");
e.printStackTrace();
} catch (UnknownHostException e) {
System.out.println("❌ URL地址错误");
e.printStackTrace();
} catch (Exception e) {
System.out.println("❌ 发生异常");
e.printStackTrace();
}
}
/**
* 读取返回结果
*/
private static String read(InputStream is) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader br = new BufferedReader(new InputStreamReader(is, "utf-8"));
String line = null;
while ((line = br.readLine()) != null) {
sb.append(line);
}
br.close();
return sb.toString();
}
}

1
tmp/map_result.json Normal file
View File

@ -0,0 +1 @@
{"Location":"深圳市","LogisticCode":"434815453222560","ShipperCode":"YD","State":"2","StateEx":"2","Success":true,"Traces":[{"Action":"1","AcceptStation":"【广州市】广东广州增城市增江街公司-罗志焰13544414635 已揽收","AcceptTime":"2025-10-07 16:13:31","Location":"广州市"},{"Action":"2","AcceptStation":"【广州市】已离开 广东广州增城市增江街公司;发往 广东广州分拨交付中心","AcceptTime":"2025-10-08 00:55:37","Location":"广州市"},{"Action":"2","AcceptStation":"【广州市】已到达 广东广州分拨交付中心如遇物流问题请联系专属客服电话020-22145829为您解决","AcceptTime":"2025-10-08 01:20:43","Location":"广州市"},{"Action":"2","AcceptStation":"【广州市】已离开 广东广州分拨交付中心;发往 广东深圳分拨交付中心。如遇物流问题请联系专属客服电话0755-61886188为您解决","AcceptTime":"2025-10-08 02:05:05","Location":"广州市"},{"Action":"2","AcceptStation":"【深圳市】已到达 广东深圳分拨交付中心如遇物流问题请联系专属客服电话0755-61886188为您解决","AcceptTime":"2025-10-08 05:01:37","Location":"深圳市"},{"Action":"2","AcceptStation":"【深圳市】已离开 广东深圳分拨交付中心;发往 广东深圳公司南山区西丽分部。如遇物流问题请联系专属客服电话0755-36368534为您解决","AcceptTime":"2025-10-08 06:09:59","Location":"深圳市"}]}

315
tmp/map_view.html Normal file

File diff suppressed because one or more lines are too long