feat: 完成公告中心、订单、收藏等模块功能开发

本次提交完成了多项核心功能开发:
1. 新增通用类型定义:分页结果、公告/收藏/优惠券/订单/地址等业务实体类型
2. 补充用户信息扩展字段,新增日期格式化工具类
3. 实现公告中心分页查询功能,包含公告列表页和详情页
4. 完成收货地址的增删改查API封装
5. 实现优惠券相关API与后端数据对接
6. 重构订单提交逻辑,新增优惠券抵扣、订单状态管理功能
7. 完成收藏模块的API接口开发
8. 调整后端存储配置为S3模式,更新演示账号信息
9. 补充Element Plus组件类型声明,完善页面骨架与样式
This commit is contained in:
sparksfly 2026-08-01 22:01:09 +08:00
parent 4454107e7c
commit f3fac48ad6
31 changed files with 4480 additions and 58 deletions

View File

@ -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
} }
] ]
} }

View File

@ -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) {

View File

@ -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;

View File

@ -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) {

View File

@ -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);
} }

View File

@ -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) {

View File

@ -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 = "订单详情")

View File

@ -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);
/** /**
* 订单详情用户端 * 订单详情用户端

View File

@ -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));
}
/** /**
* 拼接完整收货地址 * 拼接完整收货地址
*/ */

View File

@ -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 = trueAWS S3 = false # 是否使用路径风格访问RustFS / MinIO = trueAWS 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 域名)

View File

@ -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'
})
}

View File

@ -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 }
})
}

View File

@ -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'
})
}

View File

@ -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'
})
}

View File

@ -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'
})
}

16
web-snack/src/api/user.ts Normal file
View File

@ -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'
})
}

View File

@ -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
}

View File

@ -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
} }

View File

@ -0,0 +1,8 @@
/** 后端 MyBatis-Plus 分页结果Page 序列化字段) */
export interface PageResult<T> {
records: T[]
total: number
current: number
size: number
pages: number
}

View File

@ -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']
}
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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) : ''
}

View File

@ -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 || '不限'}`
}

View File

@ -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

View File

@ -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>

View File

@ -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