END
This commit is contained in:
parent
af926257b9
commit
364b7acbb7
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 "未知状态";
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取最新动态
|
||||
*/
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -108,4 +108,9 @@ public interface ProductService extends IService<Product> {
|
||||
* 获取商家热销商品排行
|
||||
*/
|
||||
List<MerchantDashboardDTO.ProductSalesDTO> getTopProductsByMerchant(Long merchantId, Integer limit);
|
||||
|
||||
/**
|
||||
* 增加商品销量
|
||||
*/
|
||||
void increaseSalesCount(Long productId, Integer quantity);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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
129
export_copyright_code.sh
Executable 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 "✅ 代码导出成功!"
|
||||
@ -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')
|
||||
|
||||
20
frontend/src/api/express.js
Normal file
20
frontend/src/api/express.js
Normal 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}`)
|
||||
}
|
||||
589
frontend/src/components/ExpressTracking.vue
Normal file
589
frontend/src/components/ExpressTracking.vue
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化表单数据
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
@ -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
477
tmp/ExpressMapTest.java
Normal 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
106
tmp/ExpressQueryTest.java
Normal 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
1
tmp/map_result.json
Normal 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
315
tmp/map_view.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user