From f3fac48ad6a778721b38b77db56f23a6de1894b8 Mon Sep 17 00:00:00 2001 From: sparksfly Date: Sat, 1 Aug 2026 22:01:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E3=80=81=E8=AE=A2=E5=8D=95=E3=80=81=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E7=AD=89=E6=A8=A1=E5=9D=97=E5=8A=9F=E8=83=BD=E5=BC=80?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交完成了多项核心功能开发: 1. 新增通用类型定义:分页结果、公告/收藏/优惠券/订单/地址等业务实体类型 2. 补充用户信息扩展字段,新增日期格式化工具类 3. 实现公告中心分页查询功能,包含公告列表页和详情页 4. 完成收货地址的增删改查API封装 5. 实现优惠券相关API与后端数据对接 6. 重构订单提交逻辑,新增优惠券抵扣、订单状态管理功能 7. 完成收藏模块的API接口开发 8. 调整后端存储配置为S3模式,更新演示账号信息 9. 补充Element Plus组件类型声明,完善页面骨架与样式 --- .claude/launch.json | 5 +- .../service/impl/CouponServiceImpl.java | 3 + .../server/module/coupon/vo/UserCouponVO.java | 10 + .../controller/NoticePublicController.java | 8 + .../module/notice/service/NoticeService.java | 3 + .../service/impl/NoticeServiceImpl.java | 20 + .../order/controller/OrderController.java | 7 +- .../module/order/service/OrderService.java | 5 +- .../order/service/impl/OrderServiceImpl.java | 121 +- .../src/main/resources/application.yml | 6 +- web-snack/src/api/address.ts | 81 + web-snack/src/api/coupon.ts | 37 + web-snack/src/api/favorite.ts | 71 + web-snack/src/api/notice.ts | 26 +- web-snack/src/api/order.ts | 72 + web-snack/src/api/user.ts | 16 + web-snack/src/types/address.ts | 31 + web-snack/src/types/auth.ts | 8 + web-snack/src/types/common.ts | 8 + web-snack/src/types/components.d.ts | 14 + web-snack/src/types/coupon.ts | 21 + web-snack/src/types/favorite.ts | 13 + web-snack/src/types/notice.ts | 7 + web-snack/src/types/order.ts | 63 + web-snack/src/utils/date.ts | 11 + web-snack/src/utils/notice.ts | 33 + web-snack/src/views/auth/login.vue | 2 +- web-snack/src/views/checkout/index.vue | 1373 +++++++++++++++- web-snack/src/views/notice/detail.vue | 584 ++++++- web-snack/src/views/notice/index.vue | 439 ++++- web-snack/src/views/user/index.vue | 1440 ++++++++++++++++- 31 files changed, 4480 insertions(+), 58 deletions(-) create mode 100644 web-snack/src/api/address.ts create mode 100644 web-snack/src/api/coupon.ts create mode 100644 web-snack/src/api/favorite.ts create mode 100644 web-snack/src/api/order.ts create mode 100644 web-snack/src/api/user.ts create mode 100644 web-snack/src/types/address.ts create mode 100644 web-snack/src/types/common.ts create mode 100644 web-snack/src/types/coupon.ts create mode 100644 web-snack/src/types/favorite.ts create mode 100644 web-snack/src/types/order.ts create mode 100644 web-snack/src/utils/date.ts create mode 100644 web-snack/src/utils/notice.ts diff --git a/.claude/launch.json b/.claude/launch.json index 77cd5f5..b1f2020 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -10,8 +10,9 @@ { "name": "web-snack", "runtimeExecutable": "sh", - "runtimeArgs": ["-c", "cd web-snack && npx vite --host 127.0.0.1 --port 5174"], - "port": 5174 + "runtimeArgs": ["-c", "cd web-snack && npx vite --host 127.0.0.1"], + "port": 5174, + "autoPort": true } ] } diff --git a/server-snack/src/main/java/com/snack/server/module/coupon/service/impl/CouponServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/coupon/service/impl/CouponServiceImpl.java index 5653938..b5632d1 100644 --- a/server-snack/src/main/java/com/snack/server/module/coupon/service/impl/CouponServiceImpl.java +++ b/server-snack/src/main/java/com/snack/server/module/coupon/service/impl/CouponServiceImpl.java @@ -322,6 +322,9 @@ public class CouponServiceImpl implements CouponService { if (coupon != null) { vo.setCouponName(coupon.getName()); vo.setCouponType(coupon.getType()); + vo.setAmount(coupon.getAmount()); + vo.setMinAmount(coupon.getMinAmount()); + vo.setMaxDiscount(coupon.getMaxDiscount()); } User user = userMap.get(uc.getUserId()); if (user != null) { diff --git a/server-snack/src/main/java/com/snack/server/module/coupon/vo/UserCouponVO.java b/server-snack/src/main/java/com/snack/server/module/coupon/vo/UserCouponVO.java index 297a39f..33ab541 100644 --- a/server-snack/src/main/java/com/snack/server/module/coupon/vo/UserCouponVO.java +++ b/server-snack/src/main/java/com/snack/server/module/coupon/vo/UserCouponVO.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serializable; +import java.math.BigDecimal; import java.time.LocalDateTime; /** @@ -35,6 +36,15 @@ public class UserCouponVO implements Serializable { @Schema(description = "优惠券类型") 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-已作废") private Integer status; diff --git a/server-snack/src/main/java/com/snack/server/module/notice/controller/NoticePublicController.java b/server-snack/src/main/java/com/snack/server/module/notice/controller/NoticePublicController.java index a843744..a27243e 100644 --- a/server-snack/src/main/java/com/snack/server/module/notice/controller/NoticePublicController.java +++ b/server-snack/src/main/java/com/snack/server/module/notice/controller/NoticePublicController.java @@ -1,8 +1,10 @@ package com.snack.server.module.notice.controller; 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.vo.NoticeVO; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -30,6 +32,12 @@ public class NoticePublicController { return Result.ok(noticeService.listActiveNotices()); } + @Operation(summary = "分页查询当前生效的公告(公告中心)") + @GetMapping("/page") + public Result> page(NoticePageReq req) { + return Result.ok(noticeService.pageActiveNotices(req)); + } + @Operation(summary = "公告详情 + 增加浏览量") @GetMapping("/{id}") public Result detail(@PathVariable Long id) { diff --git a/server-snack/src/main/java/com/snack/server/module/notice/service/NoticeService.java b/server-snack/src/main/java/com/snack/server/module/notice/service/NoticeService.java index 444f7ed..3274cac 100644 --- a/server-snack/src/main/java/com/snack/server/module/notice/service/NoticeService.java +++ b/server-snack/src/main/java/com/snack/server/module/notice/service/NoticeService.java @@ -36,6 +36,9 @@ public interface NoticeService { /** 用户端:获取当前生效的公告(status=1 且在生效期内),置顶优先 */ List listActiveNotices(); + /** 用户端:分页查询当前生效的公告(status=1 且在生效期内),置顶优先 */ + Page pageActiveNotices(NoticePageReq req); + /** 用户端:增加浏览量(详情页打开时调用) */ void incrViewCount(Long id); } diff --git a/server-snack/src/main/java/com/snack/server/module/notice/service/impl/NoticeServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/notice/service/impl/NoticeServiceImpl.java index b022dd5..e69f27d 100644 --- a/server-snack/src/main/java/com/snack/server/module/notice/service/impl/NoticeServiceImpl.java +++ b/server-snack/src/main/java/com/snack/server/module/notice/service/impl/NoticeServiceImpl.java @@ -154,6 +154,26 @@ public class NoticeServiceImpl implements NoticeService { return list.stream().map(this::toVO).toList(); } + @Override + public Page pageActiveNotices(NoticePageReq req) { + LocalDateTime now = LocalDateTime.now(); + Page page = new Page<>(req.getCurrent(), req.getSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 result = noticeMapper.selectPage(page, wrapper); + Page voPage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal()); + voPage.setRecords(result.getRecords().stream().map(this::toVO).toList()); + return voPage; + } + @Override @Transactional(rollbackFor = Exception.class) public void incrViewCount(Long id) { diff --git a/server-snack/src/main/java/com/snack/server/module/order/controller/OrderController.java b/server-snack/src/main/java/com/snack/server/module/order/controller/OrderController.java index 5b5b530..e48364c 100644 --- a/server-snack/src/main/java/com/snack/server/module/order/controller/OrderController.java +++ b/server-snack/src/main/java/com/snack/server/module/order/controller/OrderController.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.snack.server.common.Result; import com.snack.server.module.order.dto.req.OrderPageReq; 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.vo.OrderDetailVO; import io.swagger.v3.oas.annotations.Operation; @@ -30,9 +31,9 @@ public class OrderController { @Operation(summary = "提交订单") @SaCheckLogin @PostMapping - public Result> submit(@Valid @RequestBody OrderSubmitReq req) { - String orderNo = orderService.submitOrder(req); - return Result.ok(Map.of("orderNo", orderNo)); + public Result> submit(@Valid @RequestBody OrderSubmitReq req) { + Order order = orderService.submitOrder(req); + return Result.ok(Map.of("orderId", order.getId(), "orderNo", order.getOrderNo())); } @Operation(summary = "订单详情") diff --git a/server-snack/src/main/java/com/snack/server/module/order/service/OrderService.java b/server-snack/src/main/java/com/snack/server/module/order/service/OrderService.java index ff97d74..2812334 100644 --- a/server-snack/src/main/java/com/snack/server/module/order/service/OrderService.java +++ b/server-snack/src/main/java/com/snack/server/module/order/service/OrderService.java @@ -3,6 +3,7 @@ package com.snack.server.module.order.service; 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.OrderSubmitReq; +import com.snack.server.module.order.entity.Order; import com.snack.server.module.order.vo.OrderDetailVO; import java.util.List; @@ -14,9 +15,9 @@ public interface OrderService { /** * 提交订单(事务:校验库存 → 扣库存 → 创建订单 → 删购物车) - * @return 创建成功的订单号 + * @return 创建成功的订单 */ - String submitOrder(OrderSubmitReq req); + Order submitOrder(OrderSubmitReq req); /** * 订单详情(用户端) diff --git a/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java index d31c29c..e9453ee 100644 --- a/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java +++ b/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java @@ -5,6 +5,7 @@ import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; 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.snack.server.common.ResultCode; 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.cart.entity.Cart; 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.dto.req.OrderPageReq; import com.snack.server.module.order.dto.req.OrderSubmitReq; @@ -63,12 +68,14 @@ public class OrderServiceImpl implements OrderService { private final ProductService productService; private final CartService cartService; private final AddressService addressService; + private final CouponMapper couponMapper; + private final UserCouponMapper userCouponMapper; // ==================== 提交订单 ==================== @Override @Transactional(rollbackFor = Exception.class) - public String submitOrder(OrderSubmitReq req) { + public Order submitOrder(OrderSubmitReq req) { Long userId = StpUtil.getLoginIdAsLong(); // 1. 校验收货地址 @@ -132,20 +139,32 @@ public class OrderServiceImpl implements OrderService { // 4. 运费(满 N 元包邮,可作为配置项) BigDecimal freight = totalAmount.compareTo(new BigDecimal("99")) >= 0 ? 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(); - // 6. 创建订单主表 + // 7. 创建订单主表 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setFreightAmount(freight); - order.setDiscountAmount(BigDecimal.ZERO); - order.setCouponId(null); - order.setCouponAmount(BigDecimal.ZERO); + order.setDiscountAmount(couponAmount); + order.setCouponId(userCouponId); + order.setCouponAmount(couponAmount); order.setPayAmount(payAmount); order.setStatus(OrderStatusEnum.PENDING_PAY.getCode()); order.setReceiverName(address.getReceiver()); @@ -156,14 +175,27 @@ public class OrderServiceImpl implements OrderService { log.info("创建订单 orderId={} orderNo={} userId={} payAmount={}", order.getId(), orderNo, userId, payAmount); - // 7. 写入订单项 + // 核销优惠券(事务内,后续失败自动回滚) + if (userCouponId != null) { + int affected = userCouponMapper.update(null, new LambdaUpdateWrapper() + .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) { it.setOrderId(order.getId()); it.setOrderNo(orderNo); } orderItemMapper.insertBatch(orderItems); - // 8. 扣减库存(原子操作,失败会抛异常回滚整个事务) + // 9. 扣减库存(原子操作,失败会抛异常回滚整个事务) for (OrderItem it : orderItems) { boolean ok = productService.decrStock(it.getSkuId(), it.getQuantity()); if (!ok) { @@ -172,10 +204,10 @@ public class OrderServiceImpl implements OrderService { } } - // 9. 删除已下单的购物车项(按 SKU ID) + // 10. 删除已下单的购物车项(按 SKU ID) cartService.deleteItems(skuIds); - return orderNo; + return order; } // ==================== 订单详情 ==================== @@ -352,6 +384,8 @@ public class OrderServiceImpl implements OrderService { } log.info("订单 {} 取消,库存已释放", order.getOrderNo()); } + // 取消订单后退回优惠券 + restoreCouponIfUsed(order); } @Override @@ -421,6 +455,8 @@ public class OrderServiceImpl implements OrderService { for (OrderItem item : items) { productService.incrStock(item.getSkuId(), item.getQuantity()); } + // 退款后退回优惠券 + restoreCouponIfUsed(order); log.info("订单 {} 退款成功 refundNo={} reason={}", order.getOrderNo(), mockRefundNo, reason); } @@ -439,6 +475,9 @@ public class OrderServiceImpl implements OrderService { log.warn("订单 {} 退款回调失败:状态不符合", orderId); return false; } + Order order = orderMapper.selectById(orderId); + // 退款后退回优惠券 + restoreCouponIfUsed(order); // 释放库存 List items = orderItemMapper.selectList( new LambdaQueryWrapper().eq(OrderItem::getOrderId, orderId) @@ -501,6 +540,8 @@ public class OrderServiceImpl implements OrderService { for (OrderItem item : items) { productService.incrStock(item.getSkuId(), item.getQuantity()); } + // 管理员取消后退回优惠券 + restoreCouponIfUsed(order); log.info("管理员取消订单 {} 原因:{}", order.getOrderNo(), reason); } @@ -522,6 +563,64 @@ public class OrderServiceImpl implements OrderService { 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() + .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)); + } + /** * 拼接完整收货地址 */ diff --git a/server-snack/src/main/resources/application.yml b/server-snack/src/main/resources/application.yml index 1d00736..c6edf0a 100644 --- a/server-snack/src/main/resources/application.yml +++ b/server-snack/src/main/resources/application.yml @@ -76,7 +76,7 @@ snack: # 存储类型:local | s3 # local:存储到 ./uploads/ 目录(适合本地开发 / 单机部署) # s3 :存储到 S3 协议的对象存储(RustFS / MinIO / AWS S3 / 阿里云 OSS) - type: local + type: s3 upload: # 文件上传根路径(仅 local 模式生效) @@ -93,9 +93,9 @@ snack: # 是否使用路径风格访问(RustFS / MinIO = true,AWS S3 = false) path-style-access: true # AccessKey - access-key: minioadmin + access-key: WbpIY0VtEZyTgZ9efQHv # SecretKey - secret-key: minioadmin + secret-key: jhgQ1SXnk599tPqe784GjnH4VJ8K20jK1E5Zht5I # 存储桶名称(bucket 不存在时会自动尝试创建) bucket: snack-mall # 公网访问域名(用于拼接文件 URL;可配合 CDN 域名) diff --git a/web-snack/src/api/address.ts b/web-snack/src/api/address.ts new file mode 100644 index 0000000..612acd9 --- /dev/null +++ b/web-snack/src/api/address.ts @@ -0,0 +1,81 @@ +import { request } from '@/utils/request' +import type { Address, AddressSaveReq } from '@/types/address' + +/** + * 获取当前用户的所有地址 + * GET /api/address(需登录) + */ +export function getAddressListApi() { + return request({ + url: '/api/address', + method: 'GET' + }) +} + +/** + * 获取默认地址 + * GET /api/address/default(需登录) + */ +export function getDefaultAddressApi() { + return request
({ + url: '/api/address/default', + method: 'GET' + }) +} + +/** + * 获取地址详情 + * GET /api/address/{id}(需登录) + */ +export function getAddressDetailApi(id: number) { + return request
({ + url: `/api/address/${id}`, + method: 'GET' + }) +} + +/** + * 新增地址 + * POST /api/address(需登录) + */ +export function createAddressApi(data: AddressSaveReq) { + return request({ + url: '/api/address', + method: 'POST', + data + }) +} + +/** + * 更新地址 + * PUT /api/address(需登录) + */ +export function updateAddressApi(data: AddressSaveReq) { + return request({ + url: '/api/address', + method: 'PUT', + data + }) +} + +/** + * 删除地址 + * DELETE /api/address/{id}(需登录) + */ +export function deleteAddressApi(id: number) { + return request({ + url: `/api/address/${id}`, + method: 'DELETE' + }) +} + +/** + * 设为默认地址 + * PUT /api/address/{id}/default(需登录) + */ +export function setDefaultAddressApi(id: number) { + return request({ + url: `/api/address/${id}/default`, + method: 'PUT' + }) +} diff --git a/web-snack/src/api/coupon.ts b/web-snack/src/api/coupon.ts new file mode 100644 index 0000000..18ddd81 --- /dev/null +++ b/web-snack/src/api/coupon.ts @@ -0,0 +1,37 @@ +import { request } from '@/utils/request' +import type { UserCoupon } from '@/types/coupon' + +/** + * 可领取优惠券列表 + * GET /api/coupons + */ +export function getReceivableCouponsApi() { + return request({ + url: '/api/coupons', + method: 'GET' + }) +} + +/** + * 领取优惠券 + * POST /api/coupons/{id}/receive(需登录) + */ +export function receiveCouponApi(id: number) { + return request({ + url: `/api/coupons/${id}/receive`, + method: 'POST' + }) +} + +/** + * 我的优惠券 + * GET /api/user/coupons?status=(需登录) + * status:不传=全部,0-未使用 1-已使用 2-已过期 3-已作废 + */ +export function getMyCouponsApi(status?: number) { + return request({ + url: '/api/user/coupons', + method: 'GET', + params: { status } + }) +} diff --git a/web-snack/src/api/favorite.ts b/web-snack/src/api/favorite.ts new file mode 100644 index 0000000..79c8218 --- /dev/null +++ b/web-snack/src/api/favorite.ts @@ -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>({ + url: '/api/favorite', + method: 'GET', + params: { current, size } + }) +} + +/** + * 添加收藏 + * POST /api/favorite/{productId}(需登录) + */ +export function addFavoriteApi(productId: number) { + return request({ + url: `/api/favorite/${productId}`, + method: 'POST' + }) +} + +/** + * 取消收藏(按收藏记录 ID) + * DELETE /api/favorite/{favoriteId}(需登录) + */ +export function cancelFavoriteApi(favoriteId: number) { + return request({ + url: `/api/favorite/${favoriteId}`, + method: 'DELETE' + }) +} + +/** + * 取消收藏(按商品 ID) + * DELETE /api/favorite/product/{productId}(需登录) + */ +export function cancelFavoriteByProductIdApi(productId: number) { + return request({ + url: `/api/favorite/product/${productId}`, + method: 'DELETE' + }) +} + +/** + * 检查是否已收藏 + * GET /api/favorite/check?productId=(需登录) + */ +export function checkFavoriteApi(productId: number) { + return request({ + url: '/api/favorite/check', + method: 'GET', + params: { productId } + }) +} + +/** + * 获取收藏数量 + * GET /api/favorite/count(需登录) + */ +export function getFavoriteCountApi() { + return request({ + url: '/api/favorite/count', + method: 'GET' + }) +} diff --git a/web-snack/src/api/notice.ts b/web-snack/src/api/notice.ts index aec34d4..6ad46fc 100644 --- a/web-snack/src/api/notice.ts +++ b/web-snack/src/api/notice.ts @@ -1,5 +1,6 @@ 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' }) } + +/** + * 分页获取当前生效的公告(公告中心) + * GET /api/notice/page + */ +export function getNoticePageApi(params: NoticePageQuery) { + return request>({ + url: '/api/notice/page', + method: 'GET', + params + }) +} + +/** + * 获取公告详情(公开,会自增浏览量) + * GET /api/notice/{id} + */ +export function getNoticeDetailApi(id: number) { + return request({ + url: `/api/notice/${id}`, + method: 'GET' + }) +} diff --git a/web-snack/src/api/order.ts b/web-snack/src/api/order.ts new file mode 100644 index 0000000..7e0836d --- /dev/null +++ b/web-snack/src/api/order.ts @@ -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({ + 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>({ + url: '/api/orders', + method: 'GET', + params: { current, size, status } + }) +} + +/** + * 订单详情 + * GET /api/orders/{id}(需登录) + */ +export function getOrderDetailApi(id: number) { + return request({ + url: `/api/orders/${id}`, + method: 'GET' + }) +} + +/** + * 支付订单(测试用模拟支付) + * POST /api/orders/{id}/pay(需登录) + */ +export function payOrderApi(id: number) { + return request({ + url: `/api/orders/${id}/pay`, + method: 'POST' + }) +} + +/** + * 取消订单 + * POST /api/orders/{id}/cancel(需登录) + */ +export function cancelOrderApi(id: number) { + return request({ + url: `/api/orders/${id}/cancel`, + method: 'POST' + }) +} + +/** + * 确认收货 + * POST /api/orders/{id}/receive(需登录) + */ +export function receiveOrderApi(id: number) { + return request({ + url: `/api/orders/${id}/receive`, + method: 'POST' + }) +} diff --git a/web-snack/src/api/user.ts b/web-snack/src/api/user.ts new file mode 100644 index 0000000..36f8d06 --- /dev/null +++ b/web-snack/src/api/user.ts @@ -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({ + url: '/api/user/profile', + method: 'GET' + }) +} diff --git a/web-snack/src/types/address.ts b/web-snack/src/types/address.ts new file mode 100644 index 0000000..00b524b --- /dev/null +++ b/web-snack/src/types/address.ts @@ -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 +} diff --git a/web-snack/src/types/auth.ts b/web-snack/src/types/auth.ts index 0c22d35..3a64e8f 100644 --- a/web-snack/src/types/auth.ts +++ b/web-snack/src/types/auth.ts @@ -15,4 +15,12 @@ export interface LoginResult { export interface UserInfo { userId: number nickname: string + /** 头像 URL */ + avatar?: string + /** 手机号 */ + phone?: string + /** 性别:0-未知 1-男 2-女 */ + gender?: number + /** 生日 */ + birthday?: string } diff --git a/web-snack/src/types/common.ts b/web-snack/src/types/common.ts new file mode 100644 index 0000000..dd52ab2 --- /dev/null +++ b/web-snack/src/types/common.ts @@ -0,0 +1,8 @@ +/** 后端 MyBatis-Plus 分页结果(Page 序列化字段) */ +export interface PageResult { + records: T[] + total: number + current: number + size: number + pages: number +} diff --git a/web-snack/src/types/components.d.ts b/web-snack/src/types/components.d.ts index b363e5b..87c64b0 100644 --- a/web-snack/src/types/components.d.ts +++ b/web-snack/src/types/components.d.ts @@ -19,15 +19,29 @@ declare module 'vue' { ElCarousel: typeof import('element-plus/es')['ElCarousel'] ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem'] 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'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElImage: typeof import('element-plus/es')['ElImage'] 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'] + 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'] ProductCard: typeof import('./../components/product/ProductCard.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } } diff --git a/web-snack/src/types/coupon.ts b/web-snack/src/types/coupon.ts new file mode 100644 index 0000000..df82afb --- /dev/null +++ b/web-snack/src/types/coupon.ts @@ -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 +} diff --git a/web-snack/src/types/favorite.ts b/web-snack/src/types/favorite.ts new file mode 100644 index 0000000..b4c9440 --- /dev/null +++ b/web-snack/src/types/favorite.ts @@ -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 +} diff --git a/web-snack/src/types/notice.ts b/web-snack/src/types/notice.ts index f8e4553..f29b324 100644 --- a/web-snack/src/types/notice.ts +++ b/web-snack/src/types/notice.ts @@ -13,3 +13,10 @@ export interface NoticeItem { createTime: string updateTime: string } + +/** 公告中心分页查询参数 */ +export interface NoticePageQuery { + current: number + size: number + type?: 0 | 1 | 2 +} diff --git a/web-snack/src/types/order.ts b/web-snack/src/types/order.ts new file mode 100644 index 0000000..fd7c1b3 --- /dev/null +++ b/web-snack/src/types/order.ts @@ -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 +} diff --git a/web-snack/src/utils/date.ts b/web-snack/src/utils/date.ts new file mode 100644 index 0000000..297d4d7 --- /dev/null +++ b/web-snack/src/utils/date.ts @@ -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) : '' +} diff --git a/web-snack/src/utils/notice.ts b/web-snack/src/utils/notice.ts new file mode 100644 index 0000000..c453c82 --- /dev/null +++ b/web-snack/src/utils/notice.ts @@ -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 || '不限'}` +} diff --git a/web-snack/src/views/auth/login.vue b/web-snack/src/views/auth/login.vue index b1881cd..03c8c56 100644 --- a/web-snack/src/views/auth/login.vue +++ b/web-snack/src/views/auth/login.vue @@ -147,7 +147,7 @@ async function handleLogin() {
- 演示账号:user001 / 123456 + 演示账号:zhangsan / 123456
diff --git a/web-snack/src/views/checkout/index.vue b/web-snack/src/views/checkout/index.vue index 5827494..d185cab 100644 --- a/web-snack/src/views/checkout/index.vue +++ b/web-snack/src/views/checkout/index.vue @@ -1,25 +1,1384 @@ diff --git a/web-snack/src/views/notice/detail.vue b/web-snack/src/views/notice/detail.vue index e84c96e..2c37e47 100644 --- a/web-snack/src/views/notice/detail.vue +++ b/web-snack/src/views/notice/detail.vue @@ -1,25 +1,589 @@ diff --git a/web-snack/src/views/notice/index.vue b/web-snack/src/views/notice/index.vue index 3755687..104f5c4 100644 --- a/web-snack/src/views/notice/index.vue +++ b/web-snack/src/views/notice/index.vue @@ -1,25 +1,448 @@ diff --git a/web-snack/src/views/user/index.vue b/web-snack/src/views/user/index.vue index 079de50..10bfb67 100644 --- a/web-snack/src/views/user/index.vue +++ b/web-snack/src/views/user/index.vue @@ -1,25 +1,1445 @@