feat: 完成公告中心、订单、收藏等模块功能开发
本次提交完成了多项核心功能开发: 1. 新增通用类型定义:分页结果、公告/收藏/优惠券/订单/地址等业务实体类型 2. 补充用户信息扩展字段,新增日期格式化工具类 3. 实现公告中心分页查询功能,包含公告列表页和详情页 4. 完成收货地址的增删改查API封装 5. 实现优惠券相关API与后端数据对接 6. 重构订单提交逻辑,新增优惠券抵扣、订单状态管理功能 7. 完成收藏模块的API接口开发 8. 调整后端存储配置为S3模式,更新演示账号信息 9. 补充Element Plus组件类型声明,完善页面骨架与样式
This commit is contained in:
parent
4454107e7c
commit
f3fac48ad6
|
|
@ -10,8 +10,9 @@
|
||||||
{
|
{
|
||||||
"name": "web-snack",
|
"name": "web-snack",
|
||||||
"runtimeExecutable": "sh",
|
"runtimeExecutable": "sh",
|
||||||
"runtimeArgs": ["-c", "cd web-snack && npx vite --host 127.0.0.1 --port 5174"],
|
"runtimeArgs": ["-c", "cd web-snack && npx vite --host 127.0.0.1"],
|
||||||
"port": 5174
|
"port": 5174,
|
||||||
|
"autoPort": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,9 @@ public class CouponServiceImpl implements CouponService {
|
||||||
if (coupon != null) {
|
if (coupon != null) {
|
||||||
vo.setCouponName(coupon.getName());
|
vo.setCouponName(coupon.getName());
|
||||||
vo.setCouponType(coupon.getType());
|
vo.setCouponType(coupon.getType());
|
||||||
|
vo.setAmount(coupon.getAmount());
|
||||||
|
vo.setMinAmount(coupon.getMinAmount());
|
||||||
|
vo.setMaxDiscount(coupon.getMaxDiscount());
|
||||||
}
|
}
|
||||||
User user = userMap.get(uc.getUserId());
|
User user = userMap.get(uc.getUserId());
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,6 +36,15 @@ public class UserCouponVO implements Serializable {
|
||||||
@Schema(description = "优惠券类型")
|
@Schema(description = "优惠券类型")
|
||||||
private Integer couponType;
|
private Integer couponType;
|
||||||
|
|
||||||
|
@Schema(description = "满减面值 / 折扣率(0.90 表示 9 折)")
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Schema(description = "最低使用金额(0 表示无门槛)")
|
||||||
|
private BigDecimal minAmount;
|
||||||
|
|
||||||
|
@Schema(description = "折扣券最高抵扣金额")
|
||||||
|
private BigDecimal maxDiscount;
|
||||||
|
|
||||||
@Schema(description = "领取记录状态:0-未使用 1-已使用 2-已过期 3-已作废")
|
@Schema(description = "领取记录状态:0-未使用 1-已使用 2-已过期 3-已作废")
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package com.snack.server.module.notice.controller;
|
package com.snack.server.module.notice.controller;
|
||||||
|
|
||||||
import com.snack.server.common.Result;
|
import com.snack.server.common.Result;
|
||||||
|
import com.snack.server.module.notice.dto.req.NoticePageReq;
|
||||||
import com.snack.server.module.notice.service.NoticeService;
|
import com.snack.server.module.notice.service.NoticeService;
|
||||||
import com.snack.server.module.notice.vo.NoticeVO;
|
import com.snack.server.module.notice.vo.NoticeVO;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -30,6 +32,12 @@ public class NoticePublicController {
|
||||||
return Result.ok(noticeService.listActiveNotices());
|
return Result.ok(noticeService.listActiveNotices());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询当前生效的公告(公告中心)")
|
||||||
|
@GetMapping("/page")
|
||||||
|
public Result<Page<NoticeVO>> page(NoticePageReq req) {
|
||||||
|
return Result.ok(noticeService.pageActiveNotices(req));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "公告详情 + 增加浏览量")
|
@Operation(summary = "公告详情 + 增加浏览量")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Result<NoticeVO> detail(@PathVariable Long id) {
|
public Result<NoticeVO> detail(@PathVariable Long id) {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ public interface NoticeService {
|
||||||
/** 用户端:获取当前生效的公告(status=1 且在生效期内),置顶优先 */
|
/** 用户端:获取当前生效的公告(status=1 且在生效期内),置顶优先 */
|
||||||
List<NoticeVO> listActiveNotices();
|
List<NoticeVO> listActiveNotices();
|
||||||
|
|
||||||
|
/** 用户端:分页查询当前生效的公告(status=1 且在生效期内),置顶优先 */
|
||||||
|
Page<NoticeVO> pageActiveNotices(NoticePageReq req);
|
||||||
|
|
||||||
/** 用户端:增加浏览量(详情页打开时调用) */
|
/** 用户端:增加浏览量(详情页打开时调用) */
|
||||||
void incrViewCount(Long id);
|
void incrViewCount(Long id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,26 @@ public class NoticeServiceImpl implements NoticeService {
|
||||||
return list.stream().map(this::toVO).toList();
|
return list.stream().map(this::toVO).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<NoticeVO> pageActiveNotices(NoticePageReq req) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
Page<Notice> page = new Page<>(req.getCurrent(), req.getSize());
|
||||||
|
LambdaQueryWrapper<Notice> wrapper = new LambdaQueryWrapper<Notice>()
|
||||||
|
.eq(Notice::getStatus, 1)
|
||||||
|
.eq(req.getType() != null, Notice::getType, req.getType())
|
||||||
|
.and(w -> w
|
||||||
|
.and(w2 -> w2.isNull(Notice::getStartTime).or().le(Notice::getStartTime, now))
|
||||||
|
.and(w3 -> w3.isNull(Notice::getEndTime).or().ge(Notice::getEndTime, now))
|
||||||
|
)
|
||||||
|
.orderByDesc(Notice::getIsTop)
|
||||||
|
.orderByDesc(Notice::getId);
|
||||||
|
|
||||||
|
Page<Notice> result = noticeMapper.selectPage(page, wrapper);
|
||||||
|
Page<NoticeVO> voPage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal());
|
||||||
|
voPage.setRecords(result.getRecords().stream().map(this::toVO).toList());
|
||||||
|
return voPage;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void incrViewCount(Long id) {
|
public void incrViewCount(Long id) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.snack.server.common.Result;
|
import com.snack.server.common.Result;
|
||||||
import com.snack.server.module.order.dto.req.OrderPageReq;
|
import com.snack.server.module.order.dto.req.OrderPageReq;
|
||||||
import com.snack.server.module.order.dto.req.OrderSubmitReq;
|
import com.snack.server.module.order.dto.req.OrderSubmitReq;
|
||||||
|
import com.snack.server.module.order.entity.Order;
|
||||||
import com.snack.server.module.order.service.OrderService;
|
import com.snack.server.module.order.service.OrderService;
|
||||||
import com.snack.server.module.order.vo.OrderDetailVO;
|
import com.snack.server.module.order.vo.OrderDetailVO;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
|
@ -30,9 +31,9 @@ public class OrderController {
|
||||||
@Operation(summary = "提交订单")
|
@Operation(summary = "提交订单")
|
||||||
@SaCheckLogin
|
@SaCheckLogin
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public Result<Map<String, String>> submit(@Valid @RequestBody OrderSubmitReq req) {
|
public Result<Map<String, Object>> submit(@Valid @RequestBody OrderSubmitReq req) {
|
||||||
String orderNo = orderService.submitOrder(req);
|
Order order = orderService.submitOrder(req);
|
||||||
return Result.ok(Map.of("orderNo", orderNo));
|
return Result.ok(Map.of("orderId", order.getId(), "orderNo", order.getOrderNo()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "订单详情")
|
@Operation(summary = "订单详情")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.snack.server.module.order.service;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.snack.server.module.order.dto.req.OrderPageReq;
|
import com.snack.server.module.order.dto.req.OrderPageReq;
|
||||||
import com.snack.server.module.order.dto.req.OrderSubmitReq;
|
import com.snack.server.module.order.dto.req.OrderSubmitReq;
|
||||||
|
import com.snack.server.module.order.entity.Order;
|
||||||
import com.snack.server.module.order.vo.OrderDetailVO;
|
import com.snack.server.module.order.vo.OrderDetailVO;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -14,9 +15,9 @@ public interface OrderService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交订单(事务:校验库存 → 扣库存 → 创建订单 → 删购物车)
|
* 提交订单(事务:校验库存 → 扣库存 → 创建订单 → 删购物车)
|
||||||
* @return 创建成功的订单号
|
* @return 创建成功的订单
|
||||||
*/
|
*/
|
||||||
String submitOrder(OrderSubmitReq req);
|
Order submitOrder(OrderSubmitReq req);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单详情(用户端)
|
* 订单详情(用户端)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import cn.hutool.core.bean.BeanUtil;
|
||||||
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.snack.server.common.ResultCode;
|
import com.snack.server.common.ResultCode;
|
||||||
import com.snack.server.exception.BusinessException;
|
import com.snack.server.exception.BusinessException;
|
||||||
|
|
@ -12,6 +13,10 @@ import com.snack.server.module.address.entity.Address;
|
||||||
import com.snack.server.module.address.service.AddressService;
|
import com.snack.server.module.address.service.AddressService;
|
||||||
import com.snack.server.module.cart.entity.Cart;
|
import com.snack.server.module.cart.entity.Cart;
|
||||||
import com.snack.server.module.cart.service.CartService;
|
import com.snack.server.module.cart.service.CartService;
|
||||||
|
import com.snack.server.module.coupon.entity.Coupon;
|
||||||
|
import com.snack.server.module.coupon.entity.UserCoupon;
|
||||||
|
import com.snack.server.module.coupon.mapper.CouponMapper;
|
||||||
|
import com.snack.server.module.coupon.mapper.UserCouponMapper;
|
||||||
import com.snack.server.module.order.constant.OrderConstant;
|
import com.snack.server.module.order.constant.OrderConstant;
|
||||||
import com.snack.server.module.order.dto.req.OrderPageReq;
|
import com.snack.server.module.order.dto.req.OrderPageReq;
|
||||||
import com.snack.server.module.order.dto.req.OrderSubmitReq;
|
import com.snack.server.module.order.dto.req.OrderSubmitReq;
|
||||||
|
|
@ -63,12 +68,14 @@ public class OrderServiceImpl implements OrderService {
|
||||||
private final ProductService productService;
|
private final ProductService productService;
|
||||||
private final CartService cartService;
|
private final CartService cartService;
|
||||||
private final AddressService addressService;
|
private final AddressService addressService;
|
||||||
|
private final CouponMapper couponMapper;
|
||||||
|
private final UserCouponMapper userCouponMapper;
|
||||||
|
|
||||||
// ==================== 提交订单 ====================
|
// ==================== 提交订单 ====================
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public String submitOrder(OrderSubmitReq req) {
|
public Order submitOrder(OrderSubmitReq req) {
|
||||||
Long userId = StpUtil.getLoginIdAsLong();
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
|
||||||
// 1. 校验收货地址
|
// 1. 校验收货地址
|
||||||
|
|
@ -132,20 +139,32 @@ public class OrderServiceImpl implements OrderService {
|
||||||
// 4. 运费(满 N 元包邮,可作为配置项)
|
// 4. 运费(满 N 元包邮,可作为配置项)
|
||||||
BigDecimal freight = totalAmount.compareTo(new BigDecimal("99")) >= 0
|
BigDecimal freight = totalAmount.compareTo(new BigDecimal("99")) >= 0
|
||||||
? BigDecimal.ZERO : OrderConstant.DEFAULT_FREIGHT;
|
? BigDecimal.ZERO : OrderConstant.DEFAULT_FREIGHT;
|
||||||
BigDecimal payAmount = totalAmount.add(freight);
|
|
||||||
|
|
||||||
// 5. 生成订单号
|
// 5. 优惠券校验与抵扣(同一订单仅可使用一张)
|
||||||
|
BigDecimal couponAmount = BigDecimal.ZERO;
|
||||||
|
Long userCouponId = req.getCouponId();
|
||||||
|
if (userCouponId != null) {
|
||||||
|
couponAmount = validateAndCalcCoupon(userCouponId, userId, totalAmount);
|
||||||
|
}
|
||||||
|
// 实付金额最低 0.01 元
|
||||||
|
BigDecimal maxDeductible = totalAmount.add(freight).subtract(new BigDecimal("0.01"));
|
||||||
|
if (couponAmount.compareTo(maxDeductible) > 0) {
|
||||||
|
couponAmount = maxDeductible;
|
||||||
|
}
|
||||||
|
BigDecimal payAmount = totalAmount.add(freight).subtract(couponAmount);
|
||||||
|
|
||||||
|
// 6. 生成订单号
|
||||||
String orderNo = generateOrderNo();
|
String orderNo = generateOrderNo();
|
||||||
|
|
||||||
// 6. 创建订单主表
|
// 7. 创建订单主表
|
||||||
Order order = new Order();
|
Order order = new Order();
|
||||||
order.setOrderNo(orderNo);
|
order.setOrderNo(orderNo);
|
||||||
order.setUserId(userId);
|
order.setUserId(userId);
|
||||||
order.setTotalAmount(totalAmount);
|
order.setTotalAmount(totalAmount);
|
||||||
order.setFreightAmount(freight);
|
order.setFreightAmount(freight);
|
||||||
order.setDiscountAmount(BigDecimal.ZERO);
|
order.setDiscountAmount(couponAmount);
|
||||||
order.setCouponId(null);
|
order.setCouponId(userCouponId);
|
||||||
order.setCouponAmount(BigDecimal.ZERO);
|
order.setCouponAmount(couponAmount);
|
||||||
order.setPayAmount(payAmount);
|
order.setPayAmount(payAmount);
|
||||||
order.setStatus(OrderStatusEnum.PENDING_PAY.getCode());
|
order.setStatus(OrderStatusEnum.PENDING_PAY.getCode());
|
||||||
order.setReceiverName(address.getReceiver());
|
order.setReceiverName(address.getReceiver());
|
||||||
|
|
@ -156,14 +175,27 @@ public class OrderServiceImpl implements OrderService {
|
||||||
log.info("创建订单 orderId={} orderNo={} userId={} payAmount={}",
|
log.info("创建订单 orderId={} orderNo={} userId={} payAmount={}",
|
||||||
order.getId(), orderNo, userId, payAmount);
|
order.getId(), orderNo, userId, payAmount);
|
||||||
|
|
||||||
// 7. 写入订单项
|
// 核销优惠券(事务内,后续失败自动回滚)
|
||||||
|
if (userCouponId != null) {
|
||||||
|
int affected = userCouponMapper.update(null, new LambdaUpdateWrapper<UserCoupon>()
|
||||||
|
.eq(UserCoupon::getId, userCouponId)
|
||||||
|
.eq(UserCoupon::getStatus, 0)
|
||||||
|
.set(UserCoupon::getStatus, 1)
|
||||||
|
.set(UserCoupon::getOrderId, order.getId())
|
||||||
|
.set(UserCoupon::getUseTime, LocalDateTime.now()));
|
||||||
|
if (affected == 0) {
|
||||||
|
throw new BusinessException(ResultCode.COUPON_USED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 写入订单项
|
||||||
for (OrderItem it : orderItems) {
|
for (OrderItem it : orderItems) {
|
||||||
it.setOrderId(order.getId());
|
it.setOrderId(order.getId());
|
||||||
it.setOrderNo(orderNo);
|
it.setOrderNo(orderNo);
|
||||||
}
|
}
|
||||||
orderItemMapper.insertBatch(orderItems);
|
orderItemMapper.insertBatch(orderItems);
|
||||||
|
|
||||||
// 8. 扣减库存(原子操作,失败会抛异常回滚整个事务)
|
// 9. 扣减库存(原子操作,失败会抛异常回滚整个事务)
|
||||||
for (OrderItem it : orderItems) {
|
for (OrderItem it : orderItems) {
|
||||||
boolean ok = productService.decrStock(it.getSkuId(), it.getQuantity());
|
boolean ok = productService.decrStock(it.getSkuId(), it.getQuantity());
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|
@ -172,10 +204,10 @@ public class OrderServiceImpl implements OrderService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. 删除已下单的购物车项(按 SKU ID)
|
// 10. 删除已下单的购物车项(按 SKU ID)
|
||||||
cartService.deleteItems(skuIds);
|
cartService.deleteItems(skuIds);
|
||||||
|
|
||||||
return orderNo;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 订单详情 ====================
|
// ==================== 订单详情 ====================
|
||||||
|
|
@ -352,6 +384,8 @@ public class OrderServiceImpl implements OrderService {
|
||||||
}
|
}
|
||||||
log.info("订单 {} 取消,库存已释放", order.getOrderNo());
|
log.info("订单 {} 取消,库存已释放", order.getOrderNo());
|
||||||
}
|
}
|
||||||
|
// 取消订单后退回优惠券
|
||||||
|
restoreCouponIfUsed(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -421,6 +455,8 @@ public class OrderServiceImpl implements OrderService {
|
||||||
for (OrderItem item : items) {
|
for (OrderItem item : items) {
|
||||||
productService.incrStock(item.getSkuId(), item.getQuantity());
|
productService.incrStock(item.getSkuId(), item.getQuantity());
|
||||||
}
|
}
|
||||||
|
// 退款后退回优惠券
|
||||||
|
restoreCouponIfUsed(order);
|
||||||
log.info("订单 {} 退款成功 refundNo={} reason={}", order.getOrderNo(), mockRefundNo, reason);
|
log.info("订单 {} 退款成功 refundNo={} reason={}", order.getOrderNo(), mockRefundNo, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,6 +475,9 @@ public class OrderServiceImpl implements OrderService {
|
||||||
log.warn("订单 {} 退款回调失败:状态不符合", orderId);
|
log.warn("订单 {} 退款回调失败:状态不符合", orderId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
Order order = orderMapper.selectById(orderId);
|
||||||
|
// 退款后退回优惠券
|
||||||
|
restoreCouponIfUsed(order);
|
||||||
// 释放库存
|
// 释放库存
|
||||||
List<OrderItem> items = orderItemMapper.selectList(
|
List<OrderItem> items = orderItemMapper.selectList(
|
||||||
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
|
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
|
||||||
|
|
@ -501,6 +540,8 @@ public class OrderServiceImpl implements OrderService {
|
||||||
for (OrderItem item : items) {
|
for (OrderItem item : items) {
|
||||||
productService.incrStock(item.getSkuId(), item.getQuantity());
|
productService.incrStock(item.getSkuId(), item.getQuantity());
|
||||||
}
|
}
|
||||||
|
// 管理员取消后退回优惠券
|
||||||
|
restoreCouponIfUsed(order);
|
||||||
log.info("管理员取消订单 {} 原因:{}", order.getOrderNo(), reason);
|
log.info("管理员取消订单 {} 原因:{}", order.getOrderNo(), reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -522,6 +563,64 @@ public class OrderServiceImpl implements OrderService {
|
||||||
return OrderConstant.ORDER_NO_PREFIX + date + rand;
|
return OrderConstant.ORDER_NO_PREFIX + date + rand;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验优惠券并计算抵扣金额
|
||||||
|
*/
|
||||||
|
private BigDecimal validateAndCalcCoupon(Long userCouponId, Long userId, BigDecimal orderAmount) {
|
||||||
|
UserCoupon userCoupon = userCouponMapper.selectById(userCouponId);
|
||||||
|
if (userCoupon == null || !userCoupon.getUserId().equals(userId)) {
|
||||||
|
throw new BusinessException(ResultCode.COUPON_NOT_EXIST);
|
||||||
|
}
|
||||||
|
if (!Objects.equals(userCoupon.getStatus(), 0)) {
|
||||||
|
throw new BusinessException(ResultCode.COUPON_USED);
|
||||||
|
}
|
||||||
|
if (userCoupon.getExpireTime() != null && userCoupon.getExpireTime().isBefore(LocalDateTime.now())) {
|
||||||
|
throw new BusinessException(ResultCode.COUPON_EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
Coupon coupon = couponMapper.selectById(userCoupon.getCouponId());
|
||||||
|
if (coupon == null) {
|
||||||
|
throw new BusinessException(ResultCode.COUPON_NOT_EXIST);
|
||||||
|
}
|
||||||
|
BigDecimal minAmount = coupon.getMinAmount() == null ? BigDecimal.ZERO : coupon.getMinAmount();
|
||||||
|
if (orderAmount.compareTo(minAmount) < 0) {
|
||||||
|
throw new BusinessException(ResultCode.COUPON_CONDITION_NOT_MET);
|
||||||
|
}
|
||||||
|
return calcCouponDiscount(coupon, orderAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优惠金额计算:满减/无门槛直减,折扣券按订单金额 × (1 - 折扣率),封顶最高抵扣
|
||||||
|
*/
|
||||||
|
private BigDecimal calcCouponDiscount(Coupon coupon, BigDecimal orderAmount) {
|
||||||
|
BigDecimal amount = coupon.getAmount() == null ? BigDecimal.ZERO : coupon.getAmount();
|
||||||
|
if (Objects.equals(coupon.getType(), 1)) {
|
||||||
|
BigDecimal discount = orderAmount.multiply(BigDecimal.ONE.subtract(amount));
|
||||||
|
BigDecimal max = coupon.getMaxDiscount() == null ? BigDecimal.ZERO : coupon.getMaxDiscount();
|
||||||
|
if (max.compareTo(BigDecimal.ZERO) > 0 && discount.compareTo(max) > 0) {
|
||||||
|
discount = max;
|
||||||
|
}
|
||||||
|
return discount.max(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
return amount.max(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单取消 / 退款后退回已使用的优惠券(模拟实现)
|
||||||
|
*/
|
||||||
|
private void restoreCouponIfUsed(Order order) {
|
||||||
|
if (order == null || order.getCouponId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userCouponMapper.update(null, new LambdaUpdateWrapper<UserCoupon>()
|
||||||
|
.eq(UserCoupon::getId, order.getCouponId())
|
||||||
|
.eq(UserCoupon::getStatus, 1)
|
||||||
|
.eq(UserCoupon::getOrderId, order.getId())
|
||||||
|
.set(UserCoupon::getStatus, 0)
|
||||||
|
.set(UserCoupon::getOrderId, null)
|
||||||
|
.set(UserCoupon::getUseTime, null));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拼接完整收货地址
|
* 拼接完整收货地址
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ snack:
|
||||||
# 存储类型:local | s3
|
# 存储类型:local | s3
|
||||||
# local:存储到 ./uploads/ 目录(适合本地开发 / 单机部署)
|
# local:存储到 ./uploads/ 目录(适合本地开发 / 单机部署)
|
||||||
# s3 :存储到 S3 协议的对象存储(RustFS / MinIO / AWS S3 / 阿里云 OSS)
|
# s3 :存储到 S3 协议的对象存储(RustFS / MinIO / AWS S3 / 阿里云 OSS)
|
||||||
type: local
|
type: s3
|
||||||
|
|
||||||
upload:
|
upload:
|
||||||
# 文件上传根路径(仅 local 模式生效)
|
# 文件上传根路径(仅 local 模式生效)
|
||||||
|
|
@ -93,9 +93,9 @@ snack:
|
||||||
# 是否使用路径风格访问(RustFS / MinIO = true,AWS S3 = false)
|
# 是否使用路径风格访问(RustFS / MinIO = true,AWS S3 = false)
|
||||||
path-style-access: true
|
path-style-access: true
|
||||||
# AccessKey
|
# AccessKey
|
||||||
access-key: minioadmin
|
access-key: WbpIY0VtEZyTgZ9efQHv
|
||||||
# SecretKey
|
# SecretKey
|
||||||
secret-key: minioadmin
|
secret-key: jhgQ1SXnk599tPqe784GjnH4VJ8K20jK1E5Zht5I
|
||||||
# 存储桶名称(bucket 不存在时会自动尝试创建)
|
# 存储桶名称(bucket 不存在时会自动尝试创建)
|
||||||
bucket: snack-mall
|
bucket: snack-mall
|
||||||
# 公网访问域名(用于拼接文件 URL;可配合 CDN 域名)
|
# 公网访问域名(用于拼接文件 URL;可配合 CDN 域名)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { Address, AddressSaveReq } from '@/types/address'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的所有地址
|
||||||
|
* GET /api/address(需登录)
|
||||||
|
*/
|
||||||
|
export function getAddressListApi() {
|
||||||
|
return request<Address[]>({
|
||||||
|
url: '/api/address',
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认地址
|
||||||
|
* GET /api/address/default(需登录)
|
||||||
|
*/
|
||||||
|
export function getDefaultAddressApi() {
|
||||||
|
return request<Address>({
|
||||||
|
url: '/api/address/default',
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取地址详情
|
||||||
|
* GET /api/address/{id}(需登录)
|
||||||
|
*/
|
||||||
|
export function getAddressDetailApi(id: number) {
|
||||||
|
return request<Address>({
|
||||||
|
url: `/api/address/${id}`,
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增地址
|
||||||
|
* POST /api/address(需登录)
|
||||||
|
*/
|
||||||
|
export function createAddressApi(data: AddressSaveReq) {
|
||||||
|
return request<number>({
|
||||||
|
url: '/api/address',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新地址
|
||||||
|
* PUT /api/address(需登录)
|
||||||
|
*/
|
||||||
|
export function updateAddressApi(data: AddressSaveReq) {
|
||||||
|
return request<void>({
|
||||||
|
url: '/api/address',
|
||||||
|
method: 'PUT',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除地址
|
||||||
|
* DELETE /api/address/{id}(需登录)
|
||||||
|
*/
|
||||||
|
export function deleteAddressApi(id: number) {
|
||||||
|
return request<void>({
|
||||||
|
url: `/api/address/${id}`,
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设为默认地址
|
||||||
|
* PUT /api/address/{id}/default(需登录)
|
||||||
|
*/
|
||||||
|
export function setDefaultAddressApi(id: number) {
|
||||||
|
return request<void>({
|
||||||
|
url: `/api/address/${id}/default`,
|
||||||
|
method: 'PUT'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { UserCoupon } from '@/types/coupon'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可领取优惠券列表
|
||||||
|
* GET /api/coupons
|
||||||
|
*/
|
||||||
|
export function getReceivableCouponsApi() {
|
||||||
|
return request<UserCoupon[]>({
|
||||||
|
url: '/api/coupons',
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取优惠券
|
||||||
|
* POST /api/coupons/{id}/receive(需登录)
|
||||||
|
*/
|
||||||
|
export function receiveCouponApi(id: number) {
|
||||||
|
return request<number>({
|
||||||
|
url: `/api/coupons/${id}/receive`,
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的优惠券
|
||||||
|
* GET /api/user/coupons?status=(需登录)
|
||||||
|
* status:不传=全部,0-未使用 1-已使用 2-已过期 3-已作废
|
||||||
|
*/
|
||||||
|
export function getMyCouponsApi(status?: number) {
|
||||||
|
return request<UserCoupon[]>({
|
||||||
|
url: '/api/user/coupons',
|
||||||
|
method: 'GET',
|
||||||
|
params: { status }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type { FavoriteItem } from '@/types/favorite'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页获取收藏列表
|
||||||
|
* GET /api/favorite?current=&size=(需登录)
|
||||||
|
*/
|
||||||
|
export function getFavoriteListApi(current = 1, size = 10) {
|
||||||
|
return request<PageResult<FavoriteItem>>({
|
||||||
|
url: '/api/favorite',
|
||||||
|
method: 'GET',
|
||||||
|
params: { current, size }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加收藏
|
||||||
|
* POST /api/favorite/{productId}(需登录)
|
||||||
|
*/
|
||||||
|
export function addFavoriteApi(productId: number) {
|
||||||
|
return request<void>({
|
||||||
|
url: `/api/favorite/${productId}`,
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消收藏(按收藏记录 ID)
|
||||||
|
* DELETE /api/favorite/{favoriteId}(需登录)
|
||||||
|
*/
|
||||||
|
export function cancelFavoriteApi(favoriteId: number) {
|
||||||
|
return request<void>({
|
||||||
|
url: `/api/favorite/${favoriteId}`,
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消收藏(按商品 ID)
|
||||||
|
* DELETE /api/favorite/product/{productId}(需登录)
|
||||||
|
*/
|
||||||
|
export function cancelFavoriteByProductIdApi(productId: number) {
|
||||||
|
return request<void>({
|
||||||
|
url: `/api/favorite/product/${productId}`,
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已收藏
|
||||||
|
* GET /api/favorite/check?productId=(需登录)
|
||||||
|
*/
|
||||||
|
export function checkFavoriteApi(productId: number) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: '/api/favorite/check',
|
||||||
|
method: 'GET',
|
||||||
|
params: { productId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取收藏数量
|
||||||
|
* GET /api/favorite/count(需登录)
|
||||||
|
*/
|
||||||
|
export function getFavoriteCountApi() {
|
||||||
|
return request<number>({
|
||||||
|
url: '/api/favorite/count',
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
import type { NoticeItem } from '@/types/notice'
|
import type { NoticeItem, NoticePageQuery } from '@/types/notice'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前生效的公告列表
|
* 获取当前生效的公告列表
|
||||||
|
|
@ -11,3 +12,26 @@ export function getNoticesApi() {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页获取当前生效的公告(公告中心)
|
||||||
|
* GET /api/notice/page
|
||||||
|
*/
|
||||||
|
export function getNoticePageApi(params: NoticePageQuery) {
|
||||||
|
return request<PageResult<NoticeItem>>({
|
||||||
|
url: '/api/notice/page',
|
||||||
|
method: 'GET',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取公告详情(公开,会自增浏览量)
|
||||||
|
* GET /api/notice/{id}
|
||||||
|
*/
|
||||||
|
export function getNoticeDetailApi(id: number) {
|
||||||
|
return request<NoticeItem>({
|
||||||
|
url: `/api/notice/${id}`,
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import type { OrderDetail, OrderSubmitReq, OrderSubmitResult } from '@/types/order'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交订单(需登录)
|
||||||
|
* POST /api/orders
|
||||||
|
*/
|
||||||
|
export function submitOrderApi(data: OrderSubmitReq) {
|
||||||
|
return request<OrderSubmitResult>({
|
||||||
|
url: '/api/orders',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的订单(按状态筛选)
|
||||||
|
* GET /api/orders?current=&size=&status=(需登录)
|
||||||
|
* status:不传=全部,0-待付款 1-待发货 2-待收货 3-已完成 4-已取消 5-已退款
|
||||||
|
*/
|
||||||
|
export function getOrderPageApi(current = 1, size = 10, status?: number) {
|
||||||
|
return request<PageResult<OrderDetail>>({
|
||||||
|
url: '/api/orders',
|
||||||
|
method: 'GET',
|
||||||
|
params: { current, size, status }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单详情
|
||||||
|
* GET /api/orders/{id}(需登录)
|
||||||
|
*/
|
||||||
|
export function getOrderDetailApi(id: number) {
|
||||||
|
return request<OrderDetail>({
|
||||||
|
url: `/api/orders/${id}`,
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付订单(测试用模拟支付)
|
||||||
|
* POST /api/orders/{id}/pay(需登录)
|
||||||
|
*/
|
||||||
|
export function payOrderApi(id: number) {
|
||||||
|
return request<void>({
|
||||||
|
url: `/api/orders/${id}/pay`,
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订单
|
||||||
|
* POST /api/orders/{id}/cancel(需登录)
|
||||||
|
*/
|
||||||
|
export function cancelOrderApi(id: number) {
|
||||||
|
return request<void>({
|
||||||
|
url: `/api/orders/${id}/cancel`,
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认收货
|
||||||
|
* POST /api/orders/{id}/receive(需登录)
|
||||||
|
*/
|
||||||
|
export function receiveOrderApi(id: number) {
|
||||||
|
return request<void>({
|
||||||
|
url: `/api/orders/${id}/receive`,
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type { UserInfo } from '@/types/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户信息
|
||||||
|
* GET /api/user/profile(需登录)
|
||||||
|
*
|
||||||
|
* 注意:后端当前尚未提供该接口(个人资料查询/修改仍在开发中),
|
||||||
|
* 前端个人中心页暂以登录态 store 数据 + 占位表单呈现。
|
||||||
|
*/
|
||||||
|
export function getUserProfileApi() {
|
||||||
|
return request<UserInfo>({
|
||||||
|
url: '/api/user/profile',
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/** 收货地址(对应后端 AddressVO) */
|
||||||
|
export interface Address {
|
||||||
|
id: number
|
||||||
|
receiver: string
|
||||||
|
phone: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
district: string
|
||||||
|
detail: string
|
||||||
|
tag: string
|
||||||
|
/** 是否默认:0-否 1-是 */
|
||||||
|
isDefault: number
|
||||||
|
/** 省市区+详细拼接的完整地址 */
|
||||||
|
fullAddress: string
|
||||||
|
createTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/更新地址请求(对应后端 AddressSaveReq) */
|
||||||
|
export interface AddressSaveReq {
|
||||||
|
/** 地址 ID(更新时必填) */
|
||||||
|
id?: number
|
||||||
|
receiver: string
|
||||||
|
phone: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
district: string
|
||||||
|
detail: string
|
||||||
|
tag: string
|
||||||
|
/** 是否设为默认:0-否 1-是 */
|
||||||
|
isDefault: number
|
||||||
|
}
|
||||||
|
|
@ -15,4 +15,12 @@ export interface LoginResult {
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
userId: number
|
userId: number
|
||||||
nickname: string
|
nickname: string
|
||||||
|
/** 头像 URL */
|
||||||
|
avatar?: string
|
||||||
|
/** 手机号 */
|
||||||
|
phone?: string
|
||||||
|
/** 性别:0-未知 1-男 2-女 */
|
||||||
|
gender?: number
|
||||||
|
/** 生日 */
|
||||||
|
birthday?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** 后端 MyBatis-Plus 分页结果(Page 序列化字段) */
|
||||||
|
export interface PageResult<T> {
|
||||||
|
records: T[]
|
||||||
|
total: number
|
||||||
|
current: number
|
||||||
|
size: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
@ -19,15 +19,29 @@ declare module 'vue' {
|
||||||
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
||||||
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
EmptyState: typeof import('./../components/common/EmptyState.vue')['default']
|
EmptyState: typeof import('./../components/common/EmptyState.vue')['default']
|
||||||
ProductCard: typeof import('./../components/product/ProductCard.vue')['default']
|
ProductCard: typeof import('./../components/product/ProductCard.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
}
|
}
|
||||||
|
export interface GlobalDirectives {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/** 用户优惠券领取记录(对应后端 UserCouponVO) */
|
||||||
|
export interface UserCoupon {
|
||||||
|
/** 领取记录 ID */
|
||||||
|
id: number
|
||||||
|
couponId: number
|
||||||
|
couponName: string
|
||||||
|
/** 优惠券类型 */
|
||||||
|
couponType: number
|
||||||
|
/** 满减面值 / 折扣率(0.90 表示 9 折) */
|
||||||
|
amount: number
|
||||||
|
/** 最低使用金额(0 表示无门槛) */
|
||||||
|
minAmount: number
|
||||||
|
/** 折扣券最高抵扣金额 */
|
||||||
|
maxDiscount: number
|
||||||
|
/** 状态:0-未使用 1-已使用 2-已过期 3-已作废 */
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
receiveTime: string
|
||||||
|
useTime: string
|
||||||
|
expireTime: string
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/** 收藏项(对应后端 FavoriteVO) */
|
||||||
|
export interface FavoriteItem {
|
||||||
|
/** 收藏记录 ID */
|
||||||
|
id: number
|
||||||
|
/** 商品 SPU ID */
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
productMainImage: string
|
||||||
|
minPrice: number
|
||||||
|
/** 商品状态:0-下架 1-上架 */
|
||||||
|
productStatus: number
|
||||||
|
createTime: string
|
||||||
|
}
|
||||||
|
|
@ -13,3 +13,10 @@ export interface NoticeItem {
|
||||||
createTime: string
|
createTime: string
|
||||||
updateTime: string
|
updateTime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 公告中心分页查询参数 */
|
||||||
|
export interface NoticePageQuery {
|
||||||
|
current: number
|
||||||
|
size: number
|
||||||
|
type?: 0 | 1 | 2
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
/** 订单商品项(对应后端 OrderItemVO) */
|
||||||
|
export interface OrderItem {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
productImage: string
|
||||||
|
skuId: number
|
||||||
|
skuName: string
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
totalAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交订单商品项 */
|
||||||
|
export interface OrderSubmitItem {
|
||||||
|
skuId: number
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交订单请求 */
|
||||||
|
export interface OrderSubmitReq {
|
||||||
|
items: OrderSubmitItem[]
|
||||||
|
addressId: number
|
||||||
|
/** 优惠券领取记录 ID(可不传) */
|
||||||
|
couponId?: number
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交订单返回 */
|
||||||
|
export interface OrderSubmitResult {
|
||||||
|
orderId: number
|
||||||
|
orderNo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单(对应后端 OrderDetailVO) */
|
||||||
|
export interface OrderDetail {
|
||||||
|
id: number
|
||||||
|
orderNo: string
|
||||||
|
userId: number
|
||||||
|
totalAmount: number
|
||||||
|
freightAmount: number
|
||||||
|
discountAmount: number
|
||||||
|
couponAmount: number
|
||||||
|
payAmount: number
|
||||||
|
/** 状态码:0-待付款 1-待发货 2-待收货 3-已完成 4-已取消 5-已退款 */
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
/** 状态标签类型(Element Plus) */
|
||||||
|
statusTagType: string
|
||||||
|
receiverName: string
|
||||||
|
receiverPhone: string
|
||||||
|
receiverAddress: string
|
||||||
|
remark: string
|
||||||
|
trackingCompany: string
|
||||||
|
trackingNo: string
|
||||||
|
payTime: string
|
||||||
|
deliverTime: string
|
||||||
|
receiveTime: string
|
||||||
|
cancelTime: string
|
||||||
|
createTime: string
|
||||||
|
items: OrderItem[]
|
||||||
|
totalQuantity: number
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
/** 时间格式化,缺省格式为 YYYY-MM-DD HH:mm */
|
||||||
|
export function formatDateTime(
|
||||||
|
value?: string | null,
|
||||||
|
template = 'YYYY-MM-DD HH:mm'
|
||||||
|
): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = dayjs(value)
|
||||||
|
return date.isValid() ? date.format(template) : ''
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { formatDateTime } from '@/utils/date'
|
||||||
|
|
||||||
|
export interface NoticeTypeMeta {
|
||||||
|
label: string
|
||||||
|
className: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_META: Record<0 | 1 | 2, NoticeTypeMeta> = {
|
||||||
|
0: { label: '公告', className: 'type-normal' },
|
||||||
|
1: { label: '重要', className: 'type-important' },
|
||||||
|
2: { label: '活动', className: 'type-event' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公告类型展示信息 */
|
||||||
|
export function getNoticeTypeMeta(type: number): NoticeTypeMeta {
|
||||||
|
const key = type === 1 || type === 2 ? type : 0
|
||||||
|
return TYPE_META[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉富文本标签,用于列表摘要 */
|
||||||
|
export function stripNoticeHtml(html?: string): string {
|
||||||
|
if (!html) return ''
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
return (doc.body.textContent || '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公告有效期展示文案 */
|
||||||
|
export function formatNoticeRange(startTime?: string, endTime?: string): string {
|
||||||
|
const start = startTime ? formatDateTime(startTime, 'YYYY-MM-DD') : ''
|
||||||
|
const end = endTime ? formatDateTime(endTime, 'YYYY-MM-DD') : ''
|
||||||
|
if (!start && !end) return '长期有效'
|
||||||
|
return `${start || '不限'} ~ ${end || '不限'}`
|
||||||
|
}
|
||||||
|
|
@ -147,7 +147,7 @@ async function handleLogin() {
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="tips">
|
<div class="tips">
|
||||||
<span>演示账号:user001 / 123456</span>
|
<span>演示账号:zhangsan / 123456</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,25 +1,589 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 公告详情页 — 占位
|
* 公告详情
|
||||||
|
*
|
||||||
|
* 展示公告富文本正文,右侧提供最新公告快捷入口
|
||||||
*/
|
*/
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
InfoFilled,
|
||||||
|
View
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
|
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||||
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
|
import { getNoticeDetailApi, getNoticePageApi } from '@/api/notice'
|
||||||
|
import type { NoticeItem } from '@/types/notice'
|
||||||
|
import { formatDateTime } from '@/utils/date'
|
||||||
|
import { getNoticeTypeMeta, formatNoticeRange } from '@/utils/notice'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const noticeId = computed(() => Number(route.params.id))
|
||||||
|
const loading = ref(true)
|
||||||
|
const notice = ref<NoticeItem | null>(null)
|
||||||
|
const latestLoading = ref(true)
|
||||||
|
const latestNotices = ref<NoticeItem[]>([])
|
||||||
|
|
||||||
|
async function loadNotice() {
|
||||||
|
const id = noticeId.value
|
||||||
|
if (!Number.isFinite(id) || id <= 0) {
|
||||||
|
router.replace('/404')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
notice.value = null
|
||||||
|
try {
|
||||||
|
notice.value = await getNoticeDetailApi(id)
|
||||||
|
document.title = `${notice.value.title} - 零食商城`
|
||||||
|
} catch {
|
||||||
|
notice.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLatest() {
|
||||||
|
latestLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getNoticePageApi({ current: 1, size: 6 })
|
||||||
|
latestNotices.value = (res.records || [])
|
||||||
|
.filter(item => item.id !== noticeId.value)
|
||||||
|
.slice(0, 5)
|
||||||
|
} catch {
|
||||||
|
latestNotices.value = []
|
||||||
|
} finally {
|
||||||
|
latestLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(item: NoticeItem) {
|
||||||
|
if (item.id === noticeId.value) return
|
||||||
|
router.push(`/notice/${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.params.id, () => {
|
||||||
|
loadNotice()
|
||||||
|
loadLatest()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadNotice()
|
||||||
|
loadLatest()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="placeholder-page">
|
<div class="notice-detail-page">
|
||||||
<h2>公告详情</h2>
|
<AppHeader />
|
||||||
<p>即将上线,敬请期待</p>
|
|
||||||
|
<main class="detail-main">
|
||||||
|
<div class="container">
|
||||||
|
<div class="back-bar">
|
||||||
|
<button type="button" class="back-btn" @click="router.push('/notice')">
|
||||||
|
<el-icon :size="15"><ArrowLeft /></el-icon>
|
||||||
|
返回公告中心
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="article-skeleton">
|
||||||
|
<div class="skeleton-line skeleton-title" />
|
||||||
|
<div class="skeleton-line skeleton-short" />
|
||||||
|
<div class="skeleton-line" />
|
||||||
|
<div class="skeleton-block" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!notice" class="error-box">
|
||||||
|
<el-icon :size="52" class="error-icon"><InfoFilled /></el-icon>
|
||||||
|
<h2>公告不存在或已下线</h2>
|
||||||
|
<p>这条公告可能已经被删除或已过期</p>
|
||||||
|
<el-button type="primary" round @click="router.push('/notice')">
|
||||||
|
返回公告中心
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="detail-layout">
|
||||||
|
<article class="article-card">
|
||||||
|
<div class="article-head">
|
||||||
|
<div class="badge-row">
|
||||||
|
<span
|
||||||
|
class="type-badge"
|
||||||
|
:class="getNoticeTypeMeta(notice.type).className"
|
||||||
|
>
|
||||||
|
{{ getNoticeTypeMeta(notice.type).label }}
|
||||||
|
</span>
|
||||||
|
<span v-if="notice.isTop === 1" class="top-badge">置顶</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="article-title">{{ notice.title }}</h1>
|
||||||
|
<div class="article-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon :size="14"><Clock /></el-icon>
|
||||||
|
发布时间:{{ formatDateTime(notice.createTime) }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-divider">|</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon :size="14"><View /></el-icon>
|
||||||
|
浏览:{{ notice.viewCount ?? 0 }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-divider">|</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon :size="14"><Calendar /></el-icon>
|
||||||
|
有效期:{{ formatNoticeRange(notice.startTime, notice.endTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="article-content" v-html="notice.content" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside class="latest-panel">
|
||||||
|
<h2 class="panel-title">最新公告</h2>
|
||||||
|
<el-skeleton v-if="latestLoading" :rows="4" animated />
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="latestNotices.length" class="latest-list">
|
||||||
|
<div
|
||||||
|
v-for="item in latestNotices"
|
||||||
|
:key="item.id"
|
||||||
|
class="latest-item"
|
||||||
|
@click="goDetail(item)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="latest-badge"
|
||||||
|
:class="getNoticeTypeMeta(item.type).className"
|
||||||
|
>
|
||||||
|
{{ getNoticeTypeMeta(item.type).label }}
|
||||||
|
</span>
|
||||||
|
<div class="latest-info">
|
||||||
|
<span class="latest-title">{{ item.title }}</span>
|
||||||
|
<span class="latest-date">
|
||||||
|
{{ formatDateTime(item.createTime, 'MM-DD HH:mm') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else description="暂无更多公告" />
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.placeholder-page {
|
.notice-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
background-color: $bg-page;
|
||||||
justify-content: center;
|
}
|
||||||
min-height: 60vh;
|
|
||||||
color: $color-text-secondary;
|
|
||||||
|
|
||||||
h2 { font-size: $font-size-xxl; color: $color-text-primary; margin-bottom: 8px; }
|
.detail-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: $container-max;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 返回栏 ====================
|
||||||
|
.back-bar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 2px solid $color-border;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
background: $bg-surface;
|
||||||
|
color: $color-text-regular;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 0 0 $color-border-dark;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, border-color 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-primary;
|
||||||
|
border-color: $color-primary;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 骨架屏 ====================
|
||||||
|
.article-skeleton {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
background: $bg-surface;
|
||||||
|
border: 2px solid $color-border;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $clay-shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 20px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: $bg-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-title {
|
||||||
|
height: 34px;
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-short {
|
||||||
|
width: 35%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-block {
|
||||||
|
height: 260px;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: $bg-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 错误态 ====================
|
||||||
|
.error-box {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
background: $bg-surface;
|
||||||
|
border: 2px solid $color-border;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $clay-shadow-sm;
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
color: $color-primary;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 双栏布局 ====================
|
||||||
|
.detail-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card {
|
||||||
|
background: $bg-surface;
|
||||||
|
border: 2px solid $color-border;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $clay-shadow-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-head {
|
||||||
|
padding: 32px 36px 24px;
|
||||||
|
border-bottom: 2px solid $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge,
|
||||||
|
.top-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
&.type-normal {
|
||||||
|
background: $bg-hover;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.type-important {
|
||||||
|
background: #FEF3C7;
|
||||||
|
color: $color-warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.type-event {
|
||||||
|
background: $color-primary-light;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-badge {
|
||||||
|
background: linear-gradient(135deg, $color-primary, $color-cta);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: $font-size-display;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-divider {
|
||||||
|
color: $color-border-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 富文本正文 ====================
|
||||||
|
.article-content {
|
||||||
|
padding: 32px 36px;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: $color-text-primary;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
|
:deep(p) {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h1),
|
||||||
|
:deep(h2),
|
||||||
|
:deep(h3),
|
||||||
|
:deep(h4),
|
||||||
|
:deep(h5),
|
||||||
|
:deep(h6) {
|
||||||
|
margin: 24px 0 12px;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul),
|
||||||
|
:deep(ol) {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(li) {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(blockquote) {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-left: 4px solid $color-primary;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $color-primary-light;
|
||||||
|
color: $color-text-regular;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre) {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #2B2422;
|
||||||
|
color: #F5E9E3;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(code) {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $bg-hover;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre code) {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(th),
|
||||||
|
:deep(td) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(hr) {
|
||||||
|
margin: 24px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid $color-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 最新公告 ====================
|
||||||
|
.latest-panel {
|
||||||
|
padding: 22px;
|
||||||
|
background: $bg-surface;
|
||||||
|
border: 2px solid $color-border;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $clay-shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-family: 'Fredoka', 'PingFang SC', sans-serif;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-border;
|
||||||
|
background: $color-primary-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.type-normal {
|
||||||
|
background: $bg-hover;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.type-important {
|
||||||
|
background: #FEF3C7;
|
||||||
|
color: $color-warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.type-event {
|
||||||
|
background: $color-primary-light;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-title {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-date {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 响应式 ====================
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.detail-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-head {
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
font-size: $font-size-xxl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,448 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 公告中心 — 占位
|
* 公告中心
|
||||||
|
*
|
||||||
|
* 展示当前生效的公告,按类型筛选 + 分页,置顶公告优先
|
||||||
*/
|
*/
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowRight, Bell, Clock, View } from '@element-plus/icons-vue'
|
||||||
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
|
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||||
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
|
import { getNoticePageApi } from '@/api/notice'
|
||||||
|
import type { NoticeItem } from '@/types/notice'
|
||||||
|
import type { PageResult } from '@/types/common'
|
||||||
|
import { formatDateTime } from '@/utils/date'
|
||||||
|
import { getNoticeTypeMeta, stripNoticeHtml, formatNoticeRange } from '@/utils/notice'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const TYPE_TABS = [
|
||||||
|
{ label: '全部公告', value: undefined },
|
||||||
|
{ label: '重要', value: 1 },
|
||||||
|
{ label: '活动', value: 2 },
|
||||||
|
{ label: '普通', value: 0 }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const notices = ref<NoticeItem[]>([])
|
||||||
|
const page = ref<PageResult<NoticeItem>>({
|
||||||
|
records: [],
|
||||||
|
total: 0,
|
||||||
|
current: 1,
|
||||||
|
size: 10,
|
||||||
|
pages: 0
|
||||||
|
})
|
||||||
|
const activeType = ref<0 | 1 | 2 | undefined>(undefined)
|
||||||
|
|
||||||
|
async function loadNotices() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
page.value = await getNoticePageApi({
|
||||||
|
current: page.value.current,
|
||||||
|
size: page.value.size,
|
||||||
|
type: activeType.value
|
||||||
|
})
|
||||||
|
notices.value = page.value.records || []
|
||||||
|
} catch {
|
||||||
|
notices.value = []
|
||||||
|
page.value.total = 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchType(type?: 0 | 1 | 2) {
|
||||||
|
if (activeType.value === type) return
|
||||||
|
activeType.value = type
|
||||||
|
page.value.current = 1
|
||||||
|
loadNotices()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(current: number) {
|
||||||
|
page.value.current = current
|
||||||
|
loadNotices()
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(notice: NoticeItem) {
|
||||||
|
router.push(`/notice/${notice.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadNotices()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="placeholder-page">
|
<div class="notice-page">
|
||||||
<h2>公告中心</h2>
|
<AppHeader />
|
||||||
<p>即将上线,敬请期待</p>
|
|
||||||
|
<main class="notice-main">
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1 class="page-title">
|
||||||
|
<span class="title-badge">
|
||||||
|
<el-icon :size="20"><Bell /></el-icon>
|
||||||
|
</span>
|
||||||
|
公告中心
|
||||||
|
</h1>
|
||||||
|
<p class="page-desc">零食商城的最新动态、系统通知与优惠活动</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="type-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in TYPE_TABS"
|
||||||
|
:key="String(tab.value)"
|
||||||
|
type="button"
|
||||||
|
class="type-tab"
|
||||||
|
:class="{ active: activeType === tab.value }"
|
||||||
|
@click="switchType(tab.value)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="total-text">共 {{ page.total }} 条公告</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="notice-list">
|
||||||
|
<div v-for="i in 6" :key="i" class="notice-skeleton" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="notices.length === 0"
|
||||||
|
description="暂无公告,稍后再来看看吧"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="notice-list">
|
||||||
|
<article
|
||||||
|
v-for="notice in notices"
|
||||||
|
:key="notice.id"
|
||||||
|
class="notice-card"
|
||||||
|
@click="goDetail(notice)"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-badges">
|
||||||
|
<span
|
||||||
|
class="type-badge"
|
||||||
|
:class="getNoticeTypeMeta(notice.type).className"
|
||||||
|
>
|
||||||
|
{{ getNoticeTypeMeta(notice.type).label }}
|
||||||
|
</span>
|
||||||
|
<span v-if="notice.isTop === 1" class="top-badge">置顶</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="notice-title">{{ notice.title }}</h2>
|
||||||
|
<p class="notice-preview">{{ stripNoticeHtml(notice.content) }}</p>
|
||||||
|
<div class="notice-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon :size="14"><Clock /></el-icon>
|
||||||
|
{{ formatDateTime(notice.createTime) }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon :size="14"><View /></el-icon>
|
||||||
|
{{ notice.viewCount ?? 0 }} 次浏览
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
有效期:{{ formatNoticeRange(notice.startTime, notice.endTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="card-arrow">
|
||||||
|
<el-icon :size="18"><ArrowRight /></el-icon>
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="page.total > page.size" class="pagination-wrap">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:current-page="page.current"
|
||||||
|
:page-size="page.size"
|
||||||
|
:total="page.total"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.placeholder-page {
|
.notice-page {
|
||||||
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background-color: $bg-page;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28px 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: $container-max;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 页头 ====================
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Fredoka', 'PingFang SC', sans-serif;
|
||||||
|
font-size: $font-size-xxl;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $color-text-primary;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.title-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-primary-light;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 筛选栏 ====================
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: $bg-surface;
|
||||||
|
border: 2px solid $color-border;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $clay-shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
background: transparent;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
font-size: $font-size-md;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-primary;
|
||||||
|
background: $color-primary-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, $color-primary, $color-cta);
|
||||||
|
border-color: $color-border-dark;
|
||||||
|
box-shadow: 0 3px 0 0 $color-primary-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-text {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 公告列表 ====================
|
||||||
|
.notice-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-skeleton {
|
||||||
|
height: 156px;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
background: $bg-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 22px 24px;
|
||||||
|
background: $bg-surface;
|
||||||
|
border: 2px solid $color-border;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $clay-shadow-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: $clay-shadow-md;
|
||||||
|
border-color: $color-border-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge,
|
||||||
|
.top-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
&.type-normal {
|
||||||
|
background: $bg-hover;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.type-important {
|
||||||
|
background: #FEF3C7;
|
||||||
|
color: $color-warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.type-event {
|
||||||
|
background: $color-primary-light;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-badge {
|
||||||
|
background: linear-gradient(135deg, $color-primary, $color-cta);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: $font-size-xl;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $color-text-primary;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-preview {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-regular;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-arrow {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 60vh;
|
flex-shrink: 0;
|
||||||
color: $color-text-secondary;
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 2px solid $color-border;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
background: $color-primary-light;
|
||||||
|
color: $color-primary;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
h2 { font-size: $font-size-xxl; color: $color-text-primary; margin-bottom: 8px; }
|
.notice-card:hover & {
|
||||||
|
transform: translateX(3px);
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 分页 ====================
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 响应式 ====================
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: $font-size-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-card {
|
||||||
|
padding: 18px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-meta {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue