|
|
@@ -0,0 +1,4038 @@
|
|
|
+# Phase 1: 商品系统 — 实现计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 在 Phase 0 角色模型基础上,搭建完整的商品发布、浏览、购买、订单管理、系统配置能力,为 Phase 2 会员体系提供商品基础。
|
|
|
+
|
|
|
+**Architecture:** 3 个新实体(Product/ProductOrder/SysConfig)+ 3 个新 Controller 包(product/ + config/)+ 5 个新小程序页面 + 3 个新 Web 管理页面。服务商可发布商品(需审核),用户可在发现/商城浏览并下单,管理员可在 Web 端审核/管理商品和订单。商品价格支持原价+会员价,等值商品选择留给 Phase 2。
|
|
|
+
|
|
|
+**Tech Stack:** Java 1.8 + Spring Boot 2.7.18 + MyBatis-Plus + uni-app Vue 2 + Element UI
|
|
|
+
|
|
|
+**Spec reference:** `docs/superpowers/specs/2026-06-02-care-family-club-design.md` (溪福俱乐部 §3.2-3.6, §9.2, §10.1, §11.1)
|
|
|
+
|
|
|
+**依赖关系:**
|
|
|
+- 依赖 Phase 0 的 User.vendorType / vendorStatus(服务商必须为 approved 才能发布商品)
|
|
|
+- 依赖 Phase 0 的 TabBar 改版(商城/发现 Tab 已就位)
|
|
|
+- 为 Phase 2 提供 Product 基础(等值商品选择依赖此 Phase)
|
|
|
+
|
|
|
+**Key constraints:**
|
|
|
+- 所有实体使用 `java.util.Date` 和 `implements Serializable`
|
|
|
+- 所有 Controller 使用 `@RequestMapping("/api/...")` + `@PostMapping`
|
|
|
+- DI 通过 `@Resource`
|
|
|
+- 不建索引、不建约束、不写自定义 SQL(DDL 全部在 DatabaseInitializer)
|
|
|
+- Vue 2 Options API,uni-app 标签,rpx 单位,**禁止可选链 `?.`**
|
|
|
+- 支付走测试模式:直接"已支付",无真实微信支付(Phase 3+ 再接入)
|
|
|
+- 商品发布需审核流程:draft → pending → approved → on_shelf/off_shelf
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务结构总览
|
|
|
+
|
|
|
+| 任务组 | 范围 | 任务数 |
|
|
|
+|--------|------|--------|
|
|
|
+| **T1: 数据库初始化** | Product/Order/SysConfig 3 张表 DDL + 商品种子数据 | 1 |
|
|
|
+| **T2: Product 实体+Mapper+Service** | 商品 CRUD + 列表查询 | 4 |
|
|
|
+| **T3: ProductOrder 实体+Mapper+Service** | 订单创建/支付/状态流转 | 3 |
|
|
|
+| **T4: SysConfig 实体+Mapper+Service** | 系统配置 CRUD | 2 |
|
|
|
+| **T5: 后端 API Controller** | ProductController / ProductOrderController / SysConfigController / ProductVendorController | 4 |
|
|
|
+| **T6: 前端 API 封装** | zxyj-frontend/utils/api.js 新增商品/订单/配置 API | 1 |
|
|
|
+| **T7: 商城页迭代** | shop/index.vue 从占位改为商品列表(含筛选) | 1 |
|
|
|
+| **T8: 发现页迭代** | discover/index.vue 从占位改为活动+课程列表 | 1 |
|
|
|
+| **T9: 商品详情页** | discover/product-detail.vue(可复用商城/发现) | 1 |
|
|
|
+| **T10: 订单列表/详情页** | shop/order-list.vue + shop/order-detail.vue | 2 |
|
|
|
+| **T11: 服务商中心扩展** | vendor/center.vue 添加商品管理/订单管理入口 + 新增 vendor/products.vue + vendor/orders.vue | 3 |
|
|
|
+| **T12: Web 商品管理** | admin/ProductManage.vue + api/product.js + router + Layout | 4 |
|
|
|
+| **T13: Web 订单管理** | admin/OrderManage.vue | 1 |
|
|
|
+| **T14: Web 系统配置** | admin/SysConfig.vue | 1 |
|
|
|
+| **T15: 验证** | 后端编译 + 前端 lint + 端到端测试 | 1 |
|
|
|
+| **总计** | | **30 任务** |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T1: 数据库初始化 — Product/Order/SysConfig 三表 DDL + 商品种子数据
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `zxyj-backend/src/main/java/com/zxyj/config/DatabaseInitializer.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 DatabaseInitializer 末尾("数据库迁移完成" 日志前)追加 3 张表 DDL**
|
|
|
+
|
|
|
+找到 `runMigrations()` 方法结束位置("数据库迁移完成" log 之前),添加:
|
|
|
+
|
|
|
+```java
|
|
|
+// ==================== Phase 1: 溪福俱乐部商品系统 ====================
|
|
|
+
|
|
|
+// 商品表
|
|
|
+jdbcTemplate.execute(
|
|
|
+ "CREATE TABLE IF NOT EXISTS products (" +
|
|
|
+ " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ " name VARCHAR(200) NOT NULL," +
|
|
|
+ " description VARCHAR(1000)," +
|
|
|
+ " intro TEXT," +
|
|
|
+ " cover_image VARCHAR(500)," +
|
|
|
+ " images TEXT," +
|
|
|
+ " price DECIMAL(10,2) NOT NULL DEFAULT 0," +
|
|
|
+ " stock INT NOT NULL DEFAULT 0," +
|
|
|
+ " sales_count INT NOT NULL DEFAULT 0," +
|
|
|
+ " product_type VARCHAR(32) NOT NULL COMMENT 'activity/course/physical/digital/service'," +
|
|
|
+ " vendor_id BIGINT," +
|
|
|
+ " vendor_name VARCHAR(100)," +
|
|
|
+ " status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/pending/approved/rejected/on_shelf/off_shelf'," +
|
|
|
+ " reject_reason VARCHAR(500)," +
|
|
|
+ " member_price DECIMAL(10,2)," +
|
|
|
+ " member_eligible VARCHAR(32) DEFAULT 'none' COMMENT 'all/budget/none'," +
|
|
|
+ " domain VARCHAR(32) COMMENT 'action/mind/body/wisdom/wealth'," +
|
|
|
+ " external_source VARCHAR(64)," +
|
|
|
+ " external_id VARCHAR(128)," +
|
|
|
+ " external_data TEXT," +
|
|
|
+ " created_at DATETIME," +
|
|
|
+ " updated_at DATETIME" +
|
|
|
+ ")"
|
|
|
+);
|
|
|
+
|
|
|
+// 订单表
|
|
|
+jdbcTemplate.execute(
|
|
|
+ "CREATE TABLE IF NOT EXISTS product_orders (" +
|
|
|
+ " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ " order_no VARCHAR(64) NOT NULL," +
|
|
|
+ " product_id BIGINT NOT NULL," +
|
|
|
+ " product_name VARCHAR(200)," +
|
|
|
+ " product_type VARCHAR(32)," +
|
|
|
+ " buyer_id BIGINT NOT NULL," +
|
|
|
+ " family_id BIGINT," +
|
|
|
+ " quantity INT NOT NULL DEFAULT 1," +
|
|
|
+ " unit_price DECIMAL(10,2) NOT NULL," +
|
|
|
+ " total_amount DECIMAL(10,2) NOT NULL," +
|
|
|
+ " status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/completed/cancelled/refunded'," +
|
|
|
+ " payment_method VARCHAR(32) COMMENT 'wechat/points/membership'," +
|
|
|
+ " remark VARCHAR(500)," +
|
|
|
+ " paid_at DATETIME," +
|
|
|
+ " created_at DATETIME," +
|
|
|
+ " updated_at DATETIME" +
|
|
|
+ ")"
|
|
|
+);
|
|
|
+
|
|
|
+// 系统配置表
|
|
|
+jdbcTemplate.execute(
|
|
|
+ "CREATE TABLE IF NOT EXISTS sys_config (" +
|
|
|
+ " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ " config_key VARCHAR(64) NOT NULL," +
|
|
|
+ " config_value TEXT," +
|
|
|
+ " description VARCHAR(500)," +
|
|
|
+ " updated_at DATETIME" +
|
|
|
+ ")"
|
|
|
+);
|
|
|
+
|
|
|
+// SysConfig 种子数据
|
|
|
+insertSysConfigSeed("points_exchange_rate", "0.01", "积分兑换汇率(1积分=0.01元)");
|
|
|
+insertSysConfigSeed("points_deduct_cap", "0.5", "积分抵扣上限(50%)");
|
|
|
+insertSysConfigSeed("member_fee_standard", "1314", "标准会员年费(元)");
|
|
|
+insertSysConfigSeed("member_fee_premium", "3650", "高级会员年费(元)");
|
|
|
+insertSysConfigSeed("member_eligible_budget", "1314", "等值商品选择预算上限(元)");
|
|
|
+insertSysConfigSeed("renewal_discount_rate", "0.85", "续费折扣率");
|
|
|
+insertSysConfigSeed("member_discount_standard", "0.9", "标准会员折扣率");
|
|
|
+insertSysConfigSeed("member_discount_premium", "0.8", "高级会员折扣率");
|
|
|
+
|
|
|
+// Product 种子数据(5 个示例商品,覆盖全部 5 种 productType)
|
|
|
+// 注意:vendor_id 设为 NULL(平台官方商品,无服务商归属)
|
|
|
+insertProductSeed("亲子关系工作坊", "线下半日工作坊,重建家庭对话模式", "activity", 388.00, 1L, "action", "all");
|
|
|
+insertProductSeed("情绪管理21天训练营", "21天视频课程,识别并管理情绪", "course", 199.00, 1L, "mind", "all");
|
|
|
+insertProductSeed("肠道菌群检测套装", "家用检测 + 报告解读", "physical", 599.00, 1L, "body", "budget");
|
|
|
+insertProductSeed("儿童多模态认知测评", "在线测评 + 个性化建议", "digital", 99.00, 1L, "wisdom", "none");
|
|
|
+insertProductSeed("成长规划师1对1咨询", "60分钟专业规划", "service", 500.00, 1L, "wealth", "budget");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 DatabaseInitializer 类中添加私有方法 insertSysConfigSeed 和 insertProductSeed**
|
|
|
+
|
|
|
+在类的末尾(最后一个方法之后)添加:
|
|
|
+
|
|
|
+```java
|
|
|
+private void insertSysConfigSeed(String key, String value, String desc) {
|
|
|
+ Integer count = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM sys_config WHERE config_key = ?", Integer.class, key);
|
|
|
+ if (count == null || count == 0) {
|
|
|
+ jdbcTemplate.update(
|
|
|
+ "INSERT INTO sys_config (config_key, config_value, description, updated_at) VALUES (?, ?, ?, NOW())",
|
|
|
+ key, value, desc);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private void insertProductSeed(String name, String desc, String type,
|
|
|
+ java.math.BigDecimal price, Long vendorId,
|
|
|
+ String domain, String memberEligible) {
|
|
|
+ Integer count = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM products WHERE name = ? AND product_type = ?", Integer.class, name, type);
|
|
|
+ if (count == null || count == 0) {
|
|
|
+ jdbcTemplate.update(
|
|
|
+ "INSERT INTO products (name, description, price, product_type, vendor_id, vendor_name, status, domain, member_eligible, created_at, updated_at) " +
|
|
|
+ "VALUES (?, ?, ?, ?, ?, '平台官方', 'on_shelf', ?, ?, NOW(), NOW())",
|
|
|
+ name, desc, price, type, vendorId, domain, memberEligible);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`(无错误)
|
|
|
+
|
|
|
+- [ ] **Step 4: 启动后端验证表创建成功**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 查找现有后端进程
|
|
|
+ps aux | grep "zxyj-backend\|spring-boot" | grep -v grep
|
|
|
+
|
|
|
+# 如果进程存在,kill 后重启
|
|
|
+pkill -f "zxyj-backend" 2>/dev/null || true
|
|
|
+sleep 2
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn spring-boot:run &
|
|
|
+sleep 30 # 等待启动
|
|
|
+
|
|
|
+# 检查表是否创建(用 mysql 客户端或 REST)
|
|
|
+curl -s -X POST http://localhost:8080/api/product/list -H "Content-Type: application/json" -d '{"page":1,"size":10}'
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 返回 JSON 含 `code: 200`,5 个商品数据(虽然 ProductController 还没实现,会报 404,这是正常的)
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-backend/src/main/java/com/zxyj/config/DatabaseInitializer.java
|
|
|
+git commit -m "feat(phase1): 商品/订单/系统配置 3 张表 DDL + 种子数据"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T2: Product 实体 + Mapper + Service
|
|
|
+
|
|
|
+### Task 2.1: Product 实体类
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/entity/Product.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 Product.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.io.Serializable;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("products")
|
|
|
+public class Product implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private String name;
|
|
|
+ private String description;
|
|
|
+ private String intro;
|
|
|
+ private String coverImage;
|
|
|
+ private String images;
|
|
|
+ private BigDecimal price;
|
|
|
+ private Integer stock;
|
|
|
+ private Integer salesCount;
|
|
|
+ private String productType; // activity/course/physical/digital/service
|
|
|
+ private Long vendorId;
|
|
|
+ private String vendorName;
|
|
|
+ private String status; // draft/pending/approved/rejected/on_shelf/off_shelf
|
|
|
+ private String rejectReason;
|
|
|
+ private BigDecimal memberPrice;
|
|
|
+ private String memberEligible; // all/budget/none
|
|
|
+ private String domain; // action/mind/body/wisdom/wealth
|
|
|
+ private String externalSource;
|
|
|
+ private String externalId;
|
|
|
+ private String externalData;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2.2: Product Mapper
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/mapper/ProductMapper.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ProductMapper.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.zxyj.entity.Product;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface ProductMapper extends BaseMapper<Product> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2.3: Product DTO 类
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/dto/ProductDTO.java`
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/dto/ProductListQueryDTO.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ProductDTO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.dto;
|
|
|
+
|
|
|
+import com.zxyj.entity.Product;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class ProductDTO {
|
|
|
+ private Long id;
|
|
|
+ private String name;
|
|
|
+ private String description;
|
|
|
+ private String intro;
|
|
|
+ private String coverImage;
|
|
|
+ private String images;
|
|
|
+ private BigDecimal price;
|
|
|
+ private BigDecimal memberPrice;
|
|
|
+ private Integer stock;
|
|
|
+ private Integer salesCount;
|
|
|
+ private String productType;
|
|
|
+ private Long vendorId;
|
|
|
+ private String vendorName;
|
|
|
+ private String status;
|
|
|
+ private String domain;
|
|
|
+ private String memberEligible;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+
|
|
|
+ public static ProductDTO from(Product p) {
|
|
|
+ if (p == null) return null;
|
|
|
+ ProductDTO d = new ProductDTO();
|
|
|
+ d.id = p.getId();
|
|
|
+ d.name = p.getName();
|
|
|
+ d.description = p.getDescription();
|
|
|
+ d.intro = p.getIntro();
|
|
|
+ d.coverImage = p.getCoverImage();
|
|
|
+ d.images = p.getImages();
|
|
|
+ d.price = p.getPrice();
|
|
|
+ d.memberPrice = p.getMemberPrice();
|
|
|
+ d.stock = p.getStock();
|
|
|
+ d.salesCount = p.getSalesCount();
|
|
|
+ d.productType = p.getProductType();
|
|
|
+ d.vendorId = p.getVendorId();
|
|
|
+ d.vendorName = p.getVendorName();
|
|
|
+ d.status = p.getStatus();
|
|
|
+ d.domain = p.getDomain();
|
|
|
+ d.memberEligible = p.getMemberEligible();
|
|
|
+ d.createdAt = p.getCreatedAt();
|
|
|
+ d.updatedAt = p.getUpdatedAt();
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 ProductListQueryDTO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class ProductListQueryDTO {
|
|
|
+ private Integer page = 1;
|
|
|
+ private Integer size = 10;
|
|
|
+ private String productType; // 可选筛选
|
|
|
+ private String domain; // 可选筛选
|
|
|
+ private Long vendorId; // 可选筛选(查询某服务商的商品)
|
|
|
+ private String keyword; // 可选搜索关键字
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2.4: ProductService
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/service/ProductService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ProductService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.IPage;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.zxyj.common.Result;
|
|
|
+import com.zxyj.dto.ProductDTO;
|
|
|
+import com.zxyj.dto.ProductListQueryDTO;
|
|
|
+import com.zxyj.entity.Product;
|
|
|
+import com.zxyj.entity.User;
|
|
|
+import com.zxyj.mapper.ProductMapper;
|
|
|
+import com.zxyj.mapper.UserMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class ProductService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductMapper productMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 商品列表(公开 - 仅显示已上架商品)
|
|
|
+ */
|
|
|
+ public Result<Map<String, Object>> list(ProductListQueryDTO query) {
|
|
|
+ Page<Product> page = new Page<>(query.getPage(), query.getSize());
|
|
|
+ LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
|
|
|
+ .eq(Product::getStatus, "on_shelf")
|
|
|
+ .orderByDesc(Product::getCreatedAt);
|
|
|
+ if (query.getProductType() != null && !query.getProductType().isEmpty()) {
|
|
|
+ wrapper.eq(Product::getProductType, query.getProductType());
|
|
|
+ }
|
|
|
+ if (query.getDomain() != null && !query.getDomain().isEmpty()) {
|
|
|
+ wrapper.eq(Product::getDomain, query.getDomain());
|
|
|
+ }
|
|
|
+ if (query.getKeyword() != null && !query.getKeyword().isEmpty()) {
|
|
|
+ wrapper.like(Product::getName, query.getKeyword());
|
|
|
+ }
|
|
|
+ IPage<Product> result = productMapper.selectPage(page, wrapper);
|
|
|
+ List<ProductDTO> records = result.getRecords().stream()
|
|
|
+ .map(ProductDTO::from)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ Map<String, Object> data = new HashMap<>();
|
|
|
+ data.put("records", records);
|
|
|
+ data.put("total", result.getTotal());
|
|
|
+ data.put("page", result.getCurrent());
|
|
|
+ data.put("size", result.getSize());
|
|
|
+ return Result.success(data);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 商品详情(公开)
|
|
|
+ */
|
|
|
+ public Result<ProductDTO> detail(Long id) {
|
|
|
+ Product product = productMapper.selectById(id);
|
|
|
+ if (product == null) {
|
|
|
+ return Result.error("商品不存在");
|
|
|
+ }
|
|
|
+ if (!"on_shelf".equals(product.getStatus()) && !"approved".equals(product.getStatus())) {
|
|
|
+ // 非上架商品,仅创建者和管理员可见
|
|
|
+ return Result.error("商品未上架");
|
|
|
+ }
|
|
|
+ return Result.success(ProductDTO.from(product));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商发布商品
|
|
|
+ */
|
|
|
+ public Result<ProductDTO> create(Product product, Long vendorId) {
|
|
|
+ if (vendorId == null) {
|
|
|
+ return Result.error("请先登录");
|
|
|
+ }
|
|
|
+ User vendor = userMapper.selectById(vendorId);
|
|
|
+ if (vendor == null || !"approved".equals(vendor.getVendorStatus())) {
|
|
|
+ return Result.error("仅审核通过的服务商可发布商品");
|
|
|
+ }
|
|
|
+ product.setVendorId(vendorId);
|
|
|
+ product.setVendorName(vendor.getNickname() != null ? vendor.getNickname() : vendor.getRealName());
|
|
|
+ product.setStatus("pending"); // 待管理员审核
|
|
|
+ product.setCreatedAt(new Date());
|
|
|
+ product.setUpdatedAt(new Date());
|
|
|
+ productMapper.insert(product);
|
|
|
+ return Result.success(ProductDTO.from(product));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商更新商品
|
|
|
+ */
|
|
|
+ public Result<ProductDTO> update(Product product, Long vendorId) {
|
|
|
+ Product existing = productMapper.selectById(product.getId());
|
|
|
+ if (existing == null) {
|
|
|
+ return Result.error("商品不存在");
|
|
|
+ }
|
|
|
+ if (!existing.getVendorId().equals(vendorId)) {
|
|
|
+ return Result.error("无权修改此商品");
|
|
|
+ }
|
|
|
+ // 服务商更新后回到 pending 状态待审核
|
|
|
+ product.setStatus("pending");
|
|
|
+ product.setUpdatedAt(new Date());
|
|
|
+ productMapper.updateById(product);
|
|
|
+ return Result.success(ProductDTO.from(productMapper.selectById(product.getId())));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商查询自己的商品(含各状态)
|
|
|
+ */
|
|
|
+ public Result<List<ProductDTO>> myProducts(Long vendorId) {
|
|
|
+ List<Product> list = productMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Product>()
|
|
|
+ .eq(Product::getVendorId, vendorId)
|
|
|
+ .orderByDesc(Product::getCreatedAt)
|
|
|
+ );
|
|
|
+ List<ProductDTO> dtos = list.stream().map(ProductDTO::from).collect(Collectors.toList());
|
|
|
+ return Result.success(dtos);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商上架/下架商品
|
|
|
+ */
|
|
|
+ public Result<String> shelve(Long productId, Long vendorId, String action) {
|
|
|
+ Product product = productMapper.selectById(productId);
|
|
|
+ if (product == null) {
|
|
|
+ return Result.error("商品不存在");
|
|
|
+ }
|
|
|
+ if (!product.getVendorId().equals(vendorId)) {
|
|
|
+ return Result.error("无权操作");
|
|
|
+ }
|
|
|
+ if (!"approved".equals(product.getStatus())) {
|
|
|
+ return Result.error("商品未通过审核,无法上下架");
|
|
|
+ }
|
|
|
+ if ("shelve".equals(action)) {
|
|
|
+ product.setStatus("on_shelf");
|
|
|
+ } else if ("unshelve".equals(action)) {
|
|
|
+ product.setStatus("off_shelf");
|
|
|
+ } else {
|
|
|
+ return Result.error("操作类型错误");
|
|
|
+ }
|
|
|
+ product.setUpdatedAt(new Date());
|
|
|
+ productMapper.updateById(product);
|
|
|
+ return Result.success("操作成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 管理员审核商品
|
|
|
+ */
|
|
|
+ public Result<String> review(Long productId, String action, String rejectReason) {
|
|
|
+ Product product = productMapper.selectById(productId);
|
|
|
+ if (product == null) {
|
|
|
+ return Result.error("商品不存在");
|
|
|
+ }
|
|
|
+ if (!"pending".equals(product.getStatus())) {
|
|
|
+ return Result.error("仅待审核商品可审核");
|
|
|
+ }
|
|
|
+ if ("approve".equals(action)) {
|
|
|
+ product.setStatus("approved");
|
|
|
+ product.setRejectReason(null);
|
|
|
+ } else if ("reject".equals(action)) {
|
|
|
+ product.setStatus("rejected");
|
|
|
+ product.setRejectReason(rejectReason != null ? rejectReason : "不符合规范");
|
|
|
+ } else {
|
|
|
+ return Result.error("操作类型错误");
|
|
|
+ }
|
|
|
+ product.setUpdatedAt(new Date());
|
|
|
+ productMapper.updateById(product);
|
|
|
+ return Result.success("操作成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 管理员查询待审核商品
|
|
|
+ */
|
|
|
+ public Result<List<ProductDTO>> pendingReview() {
|
|
|
+ List<Product> list = productMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Product>()
|
|
|
+ .eq(Product::getStatus, "pending")
|
|
|
+ .orderByAsc(Product::getCreatedAt)
|
|
|
+ );
|
|
|
+ List<ProductDTO> dtos = list.stream().map(ProductDTO::from).collect(Collectors.toList());
|
|
|
+ return Result.success(dtos);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 管理员查询所有商品
|
|
|
+ */
|
|
|
+ public Result<List<ProductDTO>> adminList(String status) {
|
|
|
+ LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
|
|
|
+ .orderByDesc(Product::getCreatedAt);
|
|
|
+ if (status != null && !status.isEmpty()) {
|
|
|
+ wrapper.eq(Product::getStatus, status);
|
|
|
+ }
|
|
|
+ List<Product> list = productMapper.selectList(wrapper);
|
|
|
+ List<ProductDTO> dtos = list.stream().map(ProductDTO::from).collect(Collectors.toList());
|
|
|
+ return Result.success(dtos);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 减少库存(订单创建时调用)
|
|
|
+ */
|
|
|
+ public boolean decreaseStock(Long productId, Integer quantity) {
|
|
|
+ Product product = productMapper.selectById(productId);
|
|
|
+ if (product == null || product.getStock() < quantity) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ product.setStock(product.getStock() - quantity);
|
|
|
+ product.setSalesCount((product.getSalesCount() != null ? product.getSalesCount() : 0) + quantity);
|
|
|
+ product.setUpdatedAt(new Date());
|
|
|
+ productMapper.updateById(product);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-backend/src/main/java/com/zxyj/entity/Product.java zxyj-backend/src/main/java/com/zxyj/mapper/ProductMapper.java zxyj-backend/src/main/java/com/zxyj/dto/ProductDTO.java zxyj-backend/src/main/java/com/zxyj/dto/ProductListQueryDTO.java zxyj-backend/src/main/java/com/zxyj/service/ProductService.java
|
|
|
+git commit -m "feat(phase1): Product 实体/Mapper/Service 实现"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T3: ProductOrder 实体 + Mapper + Service
|
|
|
+
|
|
|
+### Task 3.1: ProductOrder 实体 + Mapper + DTO
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/entity/ProductOrder.java`
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/mapper/ProductOrderMapper.java`
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/dto/ProductOrderDTO.java`
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/dto/CreateProductOrderDTO.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ProductOrder.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.io.Serializable;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("product_orders")
|
|
|
+public class ProductOrder implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private String orderNo;
|
|
|
+ private Long productId;
|
|
|
+ private String productName;
|
|
|
+ private String productType;
|
|
|
+ private Long buyerId;
|
|
|
+ private Long familyId;
|
|
|
+ private Integer quantity;
|
|
|
+ private BigDecimal unitPrice;
|
|
|
+ private BigDecimal totalAmount;
|
|
|
+ private String status; // pending/paid/completed/cancelled/refunded
|
|
|
+ private String paymentMethod; // wechat/points/membership
|
|
|
+ private String remark;
|
|
|
+ private Date paidAt;
|
|
|
+ private Date createdAt;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 ProductOrderMapper.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.zxyj.entity.ProductOrder;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface ProductOrderMapper extends BaseMapper<ProductOrder> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 ProductOrderDTO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.dto;
|
|
|
+
|
|
|
+import com.zxyj.entity.ProductOrder;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class ProductOrderDTO {
|
|
|
+ private Long id;
|
|
|
+ private String orderNo;
|
|
|
+ private Long productId;
|
|
|
+ private String productName;
|
|
|
+ private String productType;
|
|
|
+ private Long buyerId;
|
|
|
+ private String buyerName;
|
|
|
+ private Long familyId;
|
|
|
+ private Integer quantity;
|
|
|
+ private BigDecimal unitPrice;
|
|
|
+ private BigDecimal totalAmount;
|
|
|
+ private String status;
|
|
|
+ private String paymentMethod;
|
|
|
+ private String remark;
|
|
|
+ private Date paidAt;
|
|
|
+ private Date createdAt;
|
|
|
+
|
|
|
+ public static ProductOrderDTO from(ProductOrder o) {
|
|
|
+ if (o == null) return null;
|
|
|
+ ProductOrderDTO d = new ProductOrderDTO();
|
|
|
+ d.id = o.getId();
|
|
|
+ d.orderNo = o.getOrderNo();
|
|
|
+ d.productId = o.getProductId();
|
|
|
+ d.productName = o.getProductName();
|
|
|
+ d.productType = o.getProductType();
|
|
|
+ d.buyerId = o.getBuyerId();
|
|
|
+ d.familyId = o.getFamilyId();
|
|
|
+ d.quantity = o.getQuantity();
|
|
|
+ d.unitPrice = o.getUnitPrice();
|
|
|
+ d.totalAmount = o.getTotalAmount();
|
|
|
+ d.status = o.getStatus();
|
|
|
+ d.paymentMethod = o.getPaymentMethod();
|
|
|
+ d.remark = o.getRemark();
|
|
|
+ d.paidAt = o.getPaidAt();
|
|
|
+ d.createdAt = o.getCreatedAt();
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 创建 CreateProductOrderDTO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class CreateProductOrderDTO {
|
|
|
+ private Long productId;
|
|
|
+ private Integer quantity = 1;
|
|
|
+ private String paymentMethod; // wechat/points/membership
|
|
|
+ private String remark;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3.2: ProductOrderService
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/service/ProductOrderService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ProductOrderService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.zxyj.common.Result;
|
|
|
+import com.zxyj.dto.CreateProductOrderDTO;
|
|
|
+import com.zxyj.dto.ProductOrderDTO;
|
|
|
+import com.zxyj.entity.Product;
|
|
|
+import com.zxyj.entity.ProductOrder;
|
|
|
+import com.zxyj.entity.User;
|
|
|
+import com.zxyj.mapper.ProductMapper;
|
|
|
+import com.zxyj.mapper.ProductOrderMapper;
|
|
|
+import com.zxyj.mapper.UserMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class ProductOrderService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductOrderMapper orderMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductMapper productMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductService productService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建订单
|
|
|
+ */
|
|
|
+ public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
|
|
|
+ if (buyerId == null) {
|
|
|
+ return Result.error("请先登录");
|
|
|
+ }
|
|
|
+ Product product = productMapper.selectById(dto.getProductId());
|
|
|
+ if (product == null) {
|
|
|
+ return Result.error("商品不存在");
|
|
|
+ }
|
|
|
+ if (!"on_shelf".equals(product.getStatus())) {
|
|
|
+ return Result.error("商品已下架");
|
|
|
+ }
|
|
|
+ if (product.getStock() != null && product.getStock() < dto.getQuantity()) {
|
|
|
+ return Result.error("库存不足");
|
|
|
+ }
|
|
|
+
|
|
|
+ User buyer = userMapper.selectById(buyerId);
|
|
|
+ ProductOrder order = new ProductOrder();
|
|
|
+ order.setOrderNo(generateOrderNo());
|
|
|
+ order.setProductId(product.getId());
|
|
|
+ order.setProductName(product.getName());
|
|
|
+ order.setProductType(product.getProductType());
|
|
|
+ order.setBuyerId(buyerId);
|
|
|
+ order.setFamilyId(buyer != null ? buyer.getFamilyId() : null);
|
|
|
+ order.setQuantity(dto.getQuantity());
|
|
|
+ order.setUnitPrice(product.getPrice());
|
|
|
+ order.setTotalAmount(product.getPrice().multiply(BigDecimal.valueOf(dto.getQuantity())));
|
|
|
+ order.setStatus("pending");
|
|
|
+ order.setPaymentMethod(dto.getPaymentMethod() != null ? dto.getPaymentMethod() : "wechat");
|
|
|
+ order.setRemark(dto.getRemark());
|
|
|
+ order.setCreatedAt(new Date());
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ orderMapper.insert(order);
|
|
|
+ return Result.success(ProductOrderDTO.from(order));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 支付订单(测试模式:直接标记为已支付)
|
|
|
+ */
|
|
|
+ public Result<ProductOrderDTO> pay(String orderNo, Long userId) {
|
|
|
+ ProductOrder order = orderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null) {
|
|
|
+ return Result.error("订单不存在");
|
|
|
+ }
|
|
|
+ if (!order.getBuyerId().equals(userId)) {
|
|
|
+ return Result.error("无权操作此订单");
|
|
|
+ }
|
|
|
+ if (!"pending".equals(order.getStatus())) {
|
|
|
+ return Result.error("订单状态异常");
|
|
|
+ }
|
|
|
+ // 减少库存
|
|
|
+ boolean stockOk = productService.decreaseStock(order.getProductId(), order.getQuantity());
|
|
|
+ if (!stockOk) {
|
|
|
+ return Result.error("库存不足");
|
|
|
+ }
|
|
|
+ order.setStatus("paid");
|
|
|
+ order.setPaidAt(new Date());
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ orderMapper.updateById(order);
|
|
|
+ return Result.success(ProductOrderDTO.from(order));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取消订单
|
|
|
+ */
|
|
|
+ public Result<String> cancel(String orderNo, Long userId) {
|
|
|
+ ProductOrder order = orderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null) {
|
|
|
+ return Result.error("订单不存在");
|
|
|
+ }
|
|
|
+ if (!order.getBuyerId().equals(userId)) {
|
|
|
+ return Result.error("无权操作此订单");
|
|
|
+ }
|
|
|
+ if (!"pending".equals(order.getStatus())) {
|
|
|
+ return Result.error("仅待支付订单可取消");
|
|
|
+ }
|
|
|
+ order.setStatus("cancelled");
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ orderMapper.updateById(order);
|
|
|
+ return Result.success("订单已取消");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 买家查询自己的订单
|
|
|
+ */
|
|
|
+ public Result<List<ProductOrderDTO>> myOrders(Long buyerId, String status) {
|
|
|
+ LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
|
|
|
+ .eq(ProductOrder::getBuyerId, buyerId)
|
|
|
+ .orderByDesc(ProductOrder::getCreatedAt);
|
|
|
+ if (status != null && !status.isEmpty()) {
|
|
|
+ wrapper.eq(ProductOrder::getStatus, status);
|
|
|
+ }
|
|
|
+ List<ProductOrder> list = orderMapper.selectList(wrapper);
|
|
|
+ return Result.success(list.stream().map(ProductOrderDTO::from).collect(Collectors.toList()));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 订单详情
|
|
|
+ */
|
|
|
+ public Result<ProductOrderDTO> detail(String orderNo, Long userId) {
|
|
|
+ ProductOrder order = orderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null) {
|
|
|
+ return Result.error("订单不存在");
|
|
|
+ }
|
|
|
+ // 仅买家/卖家/管理员可见
|
|
|
+ Product product = productMapper.selectById(order.getProductId());
|
|
|
+ boolean isBuyer = order.getBuyerId().equals(userId);
|
|
|
+ boolean isVendor = product != null && product.getVendorId() != null && product.getVendorId().equals(userId);
|
|
|
+ if (!isBuyer && !isVendor) {
|
|
|
+ return Result.error("无权查看");
|
|
|
+ }
|
|
|
+ return Result.success(ProductOrderDTO.from(order));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商查询收到的订单
|
|
|
+ */
|
|
|
+ public Result<List<ProductOrderDTO>> vendorOrders(Long vendorId, String status) {
|
|
|
+ // 找出该服务商的所有商品 ID
|
|
|
+ List<Product> products = productMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<Product>().eq(Product::getVendorId, vendorId));
|
|
|
+ if (products.isEmpty()) {
|
|
|
+ return Result.success(java.util.Collections.emptyList());
|
|
|
+ }
|
|
|
+ List<Long> productIds = products.stream().map(Product::getId).collect(Collectors.toList());
|
|
|
+ LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
|
|
|
+ .in(ProductOrder::getProductId, productIds)
|
|
|
+ .orderByDesc(ProductOrder::getCreatedAt);
|
|
|
+ if (status != null && !status.isEmpty()) {
|
|
|
+ wrapper.eq(ProductOrder::getStatus, status);
|
|
|
+ }
|
|
|
+ List<ProductOrder> list = orderMapper.selectList(wrapper);
|
|
|
+ return Result.success(list.stream().map(ProductOrderDTO::from).collect(Collectors.toList()));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 管理员查询所有订单
|
|
|
+ */
|
|
|
+ public Result<List<ProductOrderDTO>> adminList(String status) {
|
|
|
+ LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
|
|
|
+ .orderByDesc(ProductOrder::getCreatedAt);
|
|
|
+ if (status != null && !status.isEmpty()) {
|
|
|
+ wrapper.eq(ProductOrder::getStatus, status);
|
|
|
+ }
|
|
|
+ List<ProductOrder> list = orderMapper.selectList(wrapper);
|
|
|
+ return Result.success(list.stream().map(ProductOrderDTO::from).collect(Collectors.toList()));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商确认订单完成
|
|
|
+ */
|
|
|
+ public Result<String> confirm(String orderNo, Long vendorId) {
|
|
|
+ ProductOrder order = orderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null) {
|
|
|
+ return Result.error("订单不存在");
|
|
|
+ }
|
|
|
+ Product product = productMapper.selectById(order.getProductId());
|
|
|
+ if (product == null || !product.getVendorId().equals(vendorId)) {
|
|
|
+ return Result.error("无权操作");
|
|
|
+ }
|
|
|
+ if (!"paid".equals(order.getStatus())) {
|
|
|
+ return Result.error("仅已支付订单可确认完成");
|
|
|
+ }
|
|
|
+ order.setStatus("completed");
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ orderMapper.updateById(order);
|
|
|
+ return Result.success("订单已完成");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String generateOrderNo() {
|
|
|
+ return "PO" + System.currentTimeMillis();
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-backend/src/main/java/com/zxyj/entity/ProductOrder.java zxyj-backend/src/main/java/com/zxyj/mapper/ProductOrderMapper.java zxyj-backend/src/main/java/com/zxyj/dto/ProductOrderDTO.java zxyj-backend/src/main/java/com/zxyj/dto/CreateProductOrderDTO.java zxyj-backend/src/main/java/com/zxyj/service/ProductOrderService.java
|
|
|
+git commit -m "feat(phase1): ProductOrder 实体/Mapper/Service 实现"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T4: SysConfig 实体 + Mapper + Service
|
|
|
+
|
|
|
+### Task 4.1: SysConfig 实体 + Mapper + DTO
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/entity/SysConfig.java`
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/mapper/SysConfigMapper.java`
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/dto/SysConfigDTO.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 SysConfig.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.io.Serializable;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("sys_config")
|
|
|
+public class SysConfig implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private String configKey;
|
|
|
+ private String configValue;
|
|
|
+ private String description;
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 SysConfigMapper.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.zxyj.entity.SysConfig;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface SysConfigMapper extends BaseMapper<SysConfig> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 SysConfigDTO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.dto;
|
|
|
+
|
|
|
+import com.zxyj.entity.SysConfig;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class SysConfigDTO {
|
|
|
+ private Long id;
|
|
|
+ private String configKey;
|
|
|
+ private String configValue;
|
|
|
+ private String description;
|
|
|
+ private Date updatedAt;
|
|
|
+
|
|
|
+ public static SysConfigDTO from(SysConfig c) {
|
|
|
+ if (c == null) return null;
|
|
|
+ SysConfigDTO d = new SysConfigDTO();
|
|
|
+ d.id = c.getId();
|
|
|
+ d.configKey = c.getConfigKey();
|
|
|
+ d.configValue = c.getConfigValue();
|
|
|
+ d.description = c.getDescription();
|
|
|
+ d.updatedAt = c.getUpdatedAt();
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4.2: SysConfigService
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/service/SysConfigService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 SysConfigService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.zxyj.common.Result;
|
|
|
+import com.zxyj.dto.SysConfigDTO;
|
|
|
+import com.zxyj.entity.SysConfig;
|
|
|
+import com.zxyj.mapper.SysConfigMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class SysConfigService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private SysConfigMapper configMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取所有配置(key-value 形式)
|
|
|
+ */
|
|
|
+ public Result<Map<String, String>> getAllAsMap() {
|
|
|
+ List<SysConfig> list = configMapper.selectList(null);
|
|
|
+ Map<String, String> map = new HashMap<>();
|
|
|
+ for (SysConfig c : list) {
|
|
|
+ map.put(c.getConfigKey(), c.getConfigValue());
|
|
|
+ }
|
|
|
+ return Result.success(map);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取所有配置(详细列表)
|
|
|
+ */
|
|
|
+ public Result<List<SysConfigDTO>> listAll() {
|
|
|
+ List<SysConfig> list = configMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<SysConfig>().orderByAsc(SysConfig::getConfigKey));
|
|
|
+ return Result.success(list.stream().map(SysConfigDTO::from).collect(Collectors.toList()));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取单个配置
|
|
|
+ */
|
|
|
+ public Result<SysConfigDTO> get(String key) {
|
|
|
+ SysConfig config = configMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
|
|
|
+ if (config == null) {
|
|
|
+ return Result.error("配置不存在");
|
|
|
+ }
|
|
|
+ return Result.success(SysConfigDTO.from(config));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新配置
|
|
|
+ */
|
|
|
+ public Result<String> update(String key, String value) {
|
|
|
+ SysConfig config = configMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
|
|
|
+ if (config == null) {
|
|
|
+ return Result.error("配置不存在");
|
|
|
+ }
|
|
|
+ config.setConfigValue(value);
|
|
|
+ config.setUpdatedAt(new Date());
|
|
|
+ configMapper.updateById(config);
|
|
|
+ return Result.success("更新成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 内部方法:获取配置值(供其他 Service 调用)
|
|
|
+ */
|
|
|
+ public String getValue(String key) {
|
|
|
+ SysConfig config = configMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
|
|
|
+ return config != null ? config.getConfigValue() : null;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-backend/src/main/java/com/zxyj/entity/SysConfig.java zxyj-backend/src/main/java/com/zxyj/mapper/SysConfigMapper.java zxyj-backend/src/main/java/com/zxyj/dto/SysConfigDTO.java zxyj-backend/src/main/java/com/zxyj/service/SysConfigService.java
|
|
|
+git commit -m "feat(phase1): SysConfig 实体/Mapper/Service 实现"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T5: 后端 API Controller
|
|
|
+
|
|
|
+### Task 5.1: ProductController(公开 API + 服务商 API)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/controller/product/ProductController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ProductController.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.controller.product;
|
|
|
+
|
|
|
+import com.zxyj.common.Result;
|
|
|
+import com.zxyj.dto.ProductDTO;
|
|
|
+import com.zxyj.dto.ProductListQueryDTO;
|
|
|
+import com.zxyj.entity.Product;
|
|
|
+import com.zxyj.service.ProductService;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestHeader;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/product")
|
|
|
+public class ProductController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductService productService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 商品列表(公开)
|
|
|
+ */
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<Map<String, Object>> list(@RequestBody ProductListQueryDTO query) {
|
|
|
+ return productService.list(query);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 商品详情(公开)
|
|
|
+ */
|
|
|
+ @PostMapping("/detail")
|
|
|
+ public Result<ProductDTO> detail(@RequestBody Map<String, Object> params) {
|
|
|
+ Long id = Long.parseLong(params.get("id").toString());
|
|
|
+ return productService.detail(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商发布商品
|
|
|
+ */
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<ProductDTO> create(@RequestBody Product product,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ return productService.create(product, userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商更新商品
|
|
|
+ */
|
|
|
+ @PostMapping("/update")
|
|
|
+ public Result<ProductDTO> update(@RequestBody Product product,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ return productService.update(product, userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商查询自己的商品
|
|
|
+ */
|
|
|
+ @PostMapping("/my")
|
|
|
+ public Result<List<ProductDTO>> myProducts(@RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ return productService.myProducts(userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商上架/下架商品
|
|
|
+ */
|
|
|
+ @PostMapping("/shelve")
|
|
|
+ public Result<String> shelve(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ Long productId = Long.parseLong(params.get("productId").toString());
|
|
|
+ String action = (String) params.get("action"); // shelve / unshelve
|
|
|
+ return productService.shelve(productId, userId, action);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5.2: ProductOrderController
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/controller/product/ProductOrderController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ProductOrderController.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.controller.product;
|
|
|
+
|
|
|
+import com.zxyj.common.Result;
|
|
|
+import com.zxyj.dto.CreateProductOrderDTO;
|
|
|
+import com.zxyj.dto.ProductOrderDTO;
|
|
|
+import com.zxyj.service.ProductOrderService;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestHeader;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/product/order")
|
|
|
+public class ProductOrderController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ProductOrderService orderService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建订单
|
|
|
+ */
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<ProductOrderDTO> create(@RequestBody CreateProductOrderDTO dto,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ return orderService.create(dto, userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 支付订单(测试模式)
|
|
|
+ */
|
|
|
+ @PostMapping("/pay")
|
|
|
+ public Result<ProductOrderDTO> pay(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ String orderNo = (String) params.get("orderNo");
|
|
|
+ return orderService.pay(orderNo, userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取消订单
|
|
|
+ */
|
|
|
+ @PostMapping("/cancel")
|
|
|
+ public Result<String> cancel(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ String orderNo = (String) params.get("orderNo");
|
|
|
+ return orderService.cancel(orderNo, userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 我的订单
|
|
|
+ */
|
|
|
+ @PostMapping("/my")
|
|
|
+ public Result<List<ProductOrderDTO>> myOrders(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ String status = (String) params.get("status");
|
|
|
+ return orderService.myOrders(userId, status);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 订单详情
|
|
|
+ */
|
|
|
+ @PostMapping("/detail")
|
|
|
+ public Result<ProductOrderDTO> detail(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ String orderNo = (String) params.get("orderNo");
|
|
|
+ return orderService.detail(orderNo, userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商查询收到的订单
|
|
|
+ */
|
|
|
+ @PostMapping("/vendor")
|
|
|
+ public Result<List<ProductOrderDTO>> vendorOrders(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ String status = (String) params.get("status");
|
|
|
+ return orderService.vendorOrders(userId, status);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务商确认订单完成
|
|
|
+ */
|
|
|
+ @PostMapping("/confirm")
|
|
|
+ public Result<String> confirm(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
|
|
+ String orderNo = (String) params.get("orderNo");
|
|
|
+ return orderService.confirm(orderNo, userId);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5.3: SysConfigController(管理员 API)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-backend/src/main/java/com/zxyj/controller/config/SysConfigController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 SysConfigController.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.zxyj.controller.config;
|
|
|
+
|
|
|
+import com.zxyj.common.Result;
|
|
|
+import com.zxyj.dto.SysConfigDTO;
|
|
|
+import com.zxyj.service.SysConfigService;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/config")
|
|
|
+public class SysConfigController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private SysConfigService configService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 管理员获取所有配置
|
|
|
+ */
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<List<SysConfigDTO>> list() {
|
|
|
+ return configService.listAll();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 管理员更新配置
|
|
|
+ */
|
|
|
+ @PostMapping("/update")
|
|
|
+ public Result<String> update(@RequestBody Map<String, Object> params) {
|
|
|
+ String key = (String) params.get("configKey");
|
|
|
+ String value = (String) params.get("configValue");
|
|
|
+ return configService.update(key, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 内部使用:获取所有配置(key-value 形式)
|
|
|
+ */
|
|
|
+ @PostMapping("/all")
|
|
|
+ public Result<Map<String, String>> getAllAsMap() {
|
|
|
+ return configService.getAllAsMap();
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5.4: AdminController 扩展 — 商品审核端点
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `zxyj-backend/src/main/java/com/zxyj/controller/admin/AdminController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 找到 AdminController 类,在最后添加商品/订单审核端点**
|
|
|
+
|
|
|
+在类的最后一个方法之后(`}` 闭合大括号之前)添加:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Resource
|
|
|
+ private com.zxyj.service.ProductService productService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private com.zxyj.service.ProductOrderService productOrderService;
|
|
|
+
|
|
|
+ @PostMapping("/product/pending")
|
|
|
+ public Result<List<com.zxyj.dto.ProductDTO>> productPending() {
|
|
|
+ return productService.pendingReview();
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/product/list")
|
|
|
+ public Result<List<com.zxyj.dto.ProductDTO>> productList(@RequestBody Map<String, Object> params) {
|
|
|
+ String status = (String) params.get("status");
|
|
|
+ return productService.adminList(status);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/product/review")
|
|
|
+ public Result<String> productReview(@RequestBody Map<String, Object> params) {
|
|
|
+ Long productId = Long.parseLong(params.get("productId").toString());
|
|
|
+ String action = (String) params.get("action"); // approve / reject
|
|
|
+ String rejectReason = (String) params.get("rejectReason");
|
|
|
+ return productService.review(productId, action, rejectReason);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/order/list")
|
|
|
+ public Result<List<com.zxyj.dto.ProductOrderDTO>> orderList(@RequestBody Map<String, Object> params) {
|
|
|
+ String status = (String) params.get("status");
|
|
|
+ return productOrderService.adminList(status);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-backend/src/main/java/com/zxyj/controller/product/ProductController.java zxyj-backend/src/main/java/com/zxyj/controller/product/ProductOrderController.java zxyj-backend/src/main/java/com/zxyj/controller/config/SysConfigController.java zxyj-backend/src/main/java/com/zxyj/controller/admin/AdminController.java
|
|
|
+git commit -m "feat(phase1): Product/ProductOrder/SysConfig/Admin 后端 Controller"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T6: 前端 API 封装
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `zxyj-frontend/utils/api.js`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 api.js 末尾追加商品/订单/系统配置 API**
|
|
|
+
|
|
|
+打开 `zxyj-frontend/utils/api.js`,在 `module.exports` 之前找到合适位置(推荐放在 `vendorApply` 等 vendor API 附近),添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// ==================== Phase 1: 商品系统 ====================
|
|
|
+
|
|
|
+// 商品列表(公开)
|
|
|
+const productList = (params) => request('/api/product/list', 'POST', params);
|
|
|
+
|
|
|
+// 商品详情(公开)
|
|
|
+const productDetail = (params) => request('/api/product/detail', 'POST', params);
|
|
|
+
|
|
|
+// 服务商发布商品
|
|
|
+const productCreate = (params) => request('/api/product/create', 'POST', params);
|
|
|
+
|
|
|
+// 服务商更新商品
|
|
|
+const productUpdate = (params) => request('/api/product/update', 'POST', params);
|
|
|
+
|
|
|
+// 服务商查询自己的商品
|
|
|
+const productMy = (params) => request('/api/product/my', 'POST', params);
|
|
|
+
|
|
|
+// 服务商上架/下架商品
|
|
|
+const productShelve = (params) => request('/api/product/shelve', 'POST', params);
|
|
|
+
|
|
|
+// 创建订单
|
|
|
+const productOrderCreate = (params) => request('/api/product/order/create', 'POST', params);
|
|
|
+
|
|
|
+// 支付订单
|
|
|
+const productOrderPay = (params) => request('/api/product/order/pay', 'POST', params);
|
|
|
+
|
|
|
+// 取消订单
|
|
|
+const productOrderCancel = (params) => request('/api/product/order/cancel', 'POST', params);
|
|
|
+
|
|
|
+// 我的订单
|
|
|
+const productOrderMy = (params) => request('/api/product/order/my', 'POST', params);
|
|
|
+
|
|
|
+// 订单详情
|
|
|
+const productOrderDetail = (params) => request('/api/product/order/detail', 'POST', params);
|
|
|
+
|
|
|
+// 服务商查询收到的订单
|
|
|
+const productOrderVendor = (params) => request('/api/product/order/vendor', 'POST', params);
|
|
|
+
|
|
|
+// 服务商确认订单完成
|
|
|
+const productOrderConfirm = (params) => request('/api/product/order/confirm', 'POST', params);
|
|
|
+
|
|
|
+// 管理员:商品待审核列表
|
|
|
+const adminProductPending = (params) => request('/api/admin/product/pending', 'POST', params);
|
|
|
+
|
|
|
+// 管理员:商品列表
|
|
|
+const adminProductList = (params) => request('/api/admin/product/list', 'POST', params);
|
|
|
+
|
|
|
+// 管理员:商品审核
|
|
|
+const adminProductReview = (params) => request('/api/admin/product/review', 'POST', params);
|
|
|
+
|
|
|
+// 管理员:订单列表
|
|
|
+const adminOrderList = (params) => request('/api/admin/order/list', 'POST', params);
|
|
|
+
|
|
|
+// 管理员:系统配置列表
|
|
|
+const adminConfigList = (params) => request('/api/admin/config/list', 'POST', params);
|
|
|
+
|
|
|
+// 管理员:更新系统配置
|
|
|
+const adminConfigUpdate = (params) => request('/api/admin/config/update', 'POST', params);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 找到 `module.exports`,添加这些函数的导出**
|
|
|
+
|
|
|
+在 `module.exports` 对象中追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+ productList, productDetail, productCreate, productUpdate, productMy, productShelve,
|
|
|
+ productOrderCreate, productOrderPay, productOrderCancel, productOrderMy, productOrderDetail, productOrderVendor, productOrderConfirm,
|
|
|
+ adminProductPending, adminProductList, adminProductReview, adminOrderList,
|
|
|
+ adminConfigList, adminConfigUpdate,
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 验证前端能 import 这些 API**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-frontend && rtk grep "productList\|adminProductPending" -n 2>&1
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 输出显示这些函数已经在 api.js 中定义和导出
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/utils/api.js
|
|
|
+git commit -m "feat(phase1): 前端 api.js 新增商品/订单/系统配置 API"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T7: 商城页迭代 — 从占位改为商品列表
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `zxyj-frontend/pages/shop/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 完整重写 pages/shop/index.vue**
|
|
|
+
|
|
|
+用以下代码完整覆盖 `zxyj-frontend/pages/shop/index.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="shop-page">
|
|
|
+ <view class="header">
|
|
|
+ <view class="title">溪福商城</view>
|
|
|
+ <view class="subtitle">五行五维 · 选择属于你的能量</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 五行维度筛选 -->
|
|
|
+ <scroll-view class="domain-tabs" scroll-x>
|
|
|
+ <view v-for="d in domainOptions" :key="d.value"
|
|
|
+ :class="['tab', currentDomain === d.value ? 'active' : '']"
|
|
|
+ @click="onDomainChange(d.value)">
|
|
|
+ <text>{{ d.label }}</text>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+
|
|
|
+ <!-- 商品类型筛选 -->
|
|
|
+ <view class="type-filter">
|
|
|
+ <text v-for="t in typeOptions" :key="t.value"
|
|
|
+ :class="['filter-chip', currentType === t.value ? 'active' : '']"
|
|
|
+ @click="onTypeChange(t.value)">{{ t.label }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 商品网格 -->
|
|
|
+ <view class="product-grid">
|
|
|
+ <view v-for="p in products" :key="p.id" class="product-card" @click="goDetail(p.id)">
|
|
|
+ <image v-if="p.coverImage" :src="p.coverImage" mode="aspectFill" class="cover" />
|
|
|
+ <view v-else class="cover-placeholder">{{ domainEmoji(p.domain) }}</view>
|
|
|
+ <view class="info">
|
|
|
+ <view class="name">{{ p.name }}</view>
|
|
|
+ <view class="meta">
|
|
|
+ <text class="price">¥{{ p.price }}</text>
|
|
|
+ <text v-if="p.memberPrice" class="member-price">会员 ¥{{ p.memberPrice }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="footer">
|
|
|
+ <text class="vendor">{{ p.vendorName || '平台官方' }}</text>
|
|
|
+ <text class="sales">已售 {{ p.salesCount || 0 }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="!products.length && !loading" class="empty">暂无商品</view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { productList } from '@/utils/api.js';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ products: [],
|
|
|
+ loading: false,
|
|
|
+ currentDomain: '',
|
|
|
+ currentType: '',
|
|
|
+ domainOptions: [
|
|
|
+ { label: '全部', value: '' },
|
|
|
+ { label: '🌿 行·木', value: 'action' },
|
|
|
+ { label: '🔥 心·火', value: 'mind' },
|
|
|
+ { label: '🌏 身·土', value: 'body' },
|
|
|
+ { label: '⚔️ 智·金', value: 'wisdom' },
|
|
|
+ { label: '💧 富·水', value: 'wealth' }
|
|
|
+ ],
|
|
|
+ typeOptions: [
|
|
|
+ { label: '全部', value: '' },
|
|
|
+ { label: '活动', value: 'activity' },
|
|
|
+ { label: '课程', value: 'course' },
|
|
|
+ { label: '实物', value: 'physical' },
|
|
|
+ { label: '数字', value: 'digital' },
|
|
|
+ { label: '服务', value: 'service' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ onShow() {
|
|
|
+ this.loadProducts();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadProducts() {
|
|
|
+ this.loading = true;
|
|
|
+ try {
|
|
|
+ const res = await productList({
|
|
|
+ page: 1,
|
|
|
+ size: 50,
|
|
|
+ productType: this.currentType,
|
|
|
+ domain: this.currentDomain
|
|
|
+ });
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.products = res.data.records || [];
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('loadProducts failed', e);
|
|
|
+ }
|
|
|
+ this.loading = false;
|
|
|
+ },
|
|
|
+ onDomainChange(value) {
|
|
|
+ this.currentDomain = value;
|
|
|
+ this.loadProducts();
|
|
|
+ },
|
|
|
+ onTypeChange(value) {
|
|
|
+ this.currentType = value;
|
|
|
+ this.loadProducts();
|
|
|
+ },
|
|
|
+ goDetail(id) {
|
|
|
+ uni.navigateTo({ url: '/pages/discover/product-detail?id=' + id });
|
|
|
+ },
|
|
|
+ domainEmoji(domain) {
|
|
|
+ const map = { action: '🌿', mind: '🔥', body: '🌏', wisdom: '⚔️', wealth: '💧' };
|
|
|
+ return map[domain] || '✨';
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.shop-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #F8F4ED;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+}
|
|
|
+.header {
|
|
|
+ text-align: center;
|
|
|
+ margin: 20rpx 0 30rpx;
|
|
|
+}
|
|
|
+.title {
|
|
|
+ font-size: 40rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #6B4423;
|
|
|
+}
|
|
|
+.subtitle {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #8B7355;
|
|
|
+ margin-top: 8rpx;
|
|
|
+}
|
|
|
+.domain-tabs {
|
|
|
+ white-space: nowrap;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+.tab {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 16rpx 28rpx;
|
|
|
+ margin-right: 16rpx;
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 30rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #6B4423;
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
|
|
|
+}
|
|
|
+.tab.active {
|
|
|
+ background: linear-gradient(135deg, #FFB88C, #DE6262);
|
|
|
+ color: #FFFFFF;
|
|
|
+}
|
|
|
+.type-filter {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ margin-bottom: 24rpx;
|
|
|
+}
|
|
|
+.filter-chip {
|
|
|
+ padding: 12rpx 24rpx;
|
|
|
+ margin: 8rpx 12rpx 8rpx 0;
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #6B4423;
|
|
|
+}
|
|
|
+.filter-chip.active {
|
|
|
+ background: #FFD700;
|
|
|
+ color: #6B4423;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.product-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 20rpx;
|
|
|
+}
|
|
|
+.product-card {
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ overflow: hidden;
|
|
|
+ box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
|
|
+}
|
|
|
+.cover {
|
|
|
+ width: 100%;
|
|
|
+ height: 280rpx;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+.cover-placeholder {
|
|
|
+ width: 100%;
|
|
|
+ height: 280rpx;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 80rpx;
|
|
|
+ background: linear-gradient(135deg, #FFE5B4, #FFB88C);
|
|
|
+}
|
|
|
+.info {
|
|
|
+ padding: 16rpx;
|
|
|
+}
|
|
|
+.name {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #3C2A1E;
|
|
|
+ font-weight: 500;
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ overflow: hidden;
|
|
|
+ min-height: 76rpx;
|
|
|
+}
|
|
|
+.meta {
|
|
|
+ display: flex;
|
|
|
+ align-items: baseline;
|
|
|
+ margin-top: 10rpx;
|
|
|
+}
|
|
|
+.price {
|
|
|
+ font-size: 32rpx;
|
|
|
+ color: #DE6262;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.member-price {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #FF8C00;
|
|
|
+ margin-left: 12rpx;
|
|
|
+ background: #FFF3D6;
|
|
|
+ padding: 2rpx 10rpx;
|
|
|
+ border-radius: 12rpx;
|
|
|
+}
|
|
|
+.footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-top: 8rpx;
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.empty {
|
|
|
+ text-align: center;
|
|
|
+ padding: 100rpx 0;
|
|
|
+ color: #8B7355;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证文件保存成功**
|
|
|
+
|
|
|
+```bash
|
|
|
+rtk wc -l /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/shop/index.vue
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 输出行数(应该 > 100)
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/pages/shop/index.vue
|
|
|
+git commit -m "feat(phase1): 商城页 - 从占位改为商品列表(五行筛选+类型筛选)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T8: 发现页迭代
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `zxyj-frontend/pages/discover/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 完整重写 pages/discover/index.vue**
|
|
|
+
|
|
|
+用以下代码完整覆盖 `zxyj-frontend/pages/discover/index.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="discover-page">
|
|
|
+ <view class="header">
|
|
|
+ <view class="title">溪福发现</view>
|
|
|
+ <view class="subtitle">活动 · 课程 · 探索</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 类型切换 -->
|
|
|
+ <view class="type-tabs">
|
|
|
+ <text v-for="t in typeOptions" :key="t.value"
|
|
|
+ :class="['tab', currentType === t.value ? 'active' : '']"
|
|
|
+ @click="onTypeChange(t.value)">{{ t.label }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 列表 -->
|
|
|
+ <view class="discover-list">
|
|
|
+ <view v-for="p in products" :key="p.id" class="card" @click="goDetail(p.id)">
|
|
|
+ <view class="card-left">
|
|
|
+ <view class="card-cover">{{ domainEmoji(p.domain) }}</view>
|
|
|
+ </view>
|
|
|
+ <view class="card-right">
|
|
|
+ <view class="card-title">{{ p.name }}</view>
|
|
|
+ <view class="card-desc">{{ p.description }}</view>
|
|
|
+ <view class="card-meta">
|
|
|
+ <text class="card-price">¥{{ p.price }}</text>
|
|
|
+ <text class="card-vendor">{{ p.vendorName || '平台官方' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="!products.length && !loading" class="empty">暂无内容</view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { productList } from '@/utils/api.js';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ products: [],
|
|
|
+ loading: false,
|
|
|
+ currentType: '',
|
|
|
+ typeOptions: [
|
|
|
+ { label: '推荐', value: '' },
|
|
|
+ { label: '活动', value: 'activity' },
|
|
|
+ { label: '课程', value: 'course' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ onShow() {
|
|
|
+ this.loadProducts();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadProducts() {
|
|
|
+ this.loading = true;
|
|
|
+ try {
|
|
|
+ const res = await productList({
|
|
|
+ page: 1,
|
|
|
+ size: 30,
|
|
|
+ productType: this.currentType
|
|
|
+ });
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.products = res.data.records || [];
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('loadProducts failed', e);
|
|
|
+ }
|
|
|
+ this.loading = false;
|
|
|
+ },
|
|
|
+ onTypeChange(value) {
|
|
|
+ this.currentType = value;
|
|
|
+ this.loadProducts();
|
|
|
+ },
|
|
|
+ goDetail(id) {
|
|
|
+ uni.navigateTo({ url: '/pages/discover/product-detail?id=' + id });
|
|
|
+ },
|
|
|
+ domainEmoji(domain) {
|
|
|
+ const map = { action: '🌿', mind: '🔥', body: '🌏', wisdom: '⚔️', wealth: '💧' };
|
|
|
+ return map[domain] || '✨';
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.discover-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #F8F4ED;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+}
|
|
|
+.header {
|
|
|
+ text-align: center;
|
|
|
+ margin: 20rpx 0 30rpx;
|
|
|
+}
|
|
|
+.title {
|
|
|
+ font-size: 40rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #6B4423;
|
|
|
+}
|
|
|
+.subtitle {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #8B7355;
|
|
|
+ margin-top: 8rpx;
|
|
|
+}
|
|
|
+.type-tabs {
|
|
|
+ display: flex;
|
|
|
+ margin-bottom: 24rpx;
|
|
|
+}
|
|
|
+.tab {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ padding: 16rpx 0;
|
|
|
+ background: #FFFFFF;
|
|
|
+ margin: 0 8rpx;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #6B4423;
|
|
|
+}
|
|
|
+.tab.active {
|
|
|
+ background: linear-gradient(135deg, #FFB88C, #DE6262);
|
|
|
+ color: #FFFFFF;
|
|
|
+}
|
|
|
+.discover-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 20rpx;
|
|
|
+}
|
|
|
+.card {
|
|
|
+ display: flex;
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ overflow: hidden;
|
|
|
+ box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
|
|
+ padding: 20rpx;
|
|
|
+}
|
|
|
+.card-left {
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.card-cover {
|
|
|
+ width: 160rpx;
|
|
|
+ height: 160rpx;
|
|
|
+ border-radius: 16rpx;
|
|
|
+ background: linear-gradient(135deg, #FFE5B4, #FFB88C);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 60rpx;
|
|
|
+}
|
|
|
+.card-right {
|
|
|
+ flex: 1;
|
|
|
+ margin-left: 20rpx;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: space-between;
|
|
|
+}
|
|
|
+.card-title {
|
|
|
+ font-size: 30rpx;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #3C2A1E;
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-line-clamp: 1;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.card-desc {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #8B7355;
|
|
|
+ margin-top: 6rpx;
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.card-meta {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-top: 10rpx;
|
|
|
+}
|
|
|
+.card-price {
|
|
|
+ font-size: 30rpx;
|
|
|
+ color: #DE6262;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.card-vendor {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.empty {
|
|
|
+ text-align: center;
|
|
|
+ padding: 100rpx 0;
|
|
|
+ color: #8B7355;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证文件保存成功**
|
|
|
+
|
|
|
+```bash
|
|
|
+rtk wc -l /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/discover/index.vue
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 输出行数(应该 > 100)
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/pages/discover/index.vue
|
|
|
+git commit -m "feat(phase1): 发现页 - 从占位改为活动+课程列表"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T9: 商品详情页(公共组件,商城和发现共用)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-frontend/pages/discover/product-detail.vue`
|
|
|
+- Modify: `zxyj-frontend/pages.json`(注册新页面)
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 pages/discover/product-detail.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="detail-page">
|
|
|
+ <view v-if="product" class="content">
|
|
|
+ <view class="cover-section">
|
|
|
+ <view v-if="product.coverImage" class="cover">
|
|
|
+ <image :src="product.coverImage" mode="aspectFill" class="cover-img" />
|
|
|
+ </view>
|
|
|
+ <view v-else class="cover cover-placeholder">
|
|
|
+ <text class="cover-emoji">{{ domainEmoji(product.domain) }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="info-section">
|
|
|
+ <view class="name">{{ product.name }}</view>
|
|
|
+ <view class="price-row">
|
|
|
+ <text class="price">¥{{ product.price }}</text>
|
|
|
+ <text v-if="product.memberPrice" class="member-price">会员价 ¥{{ product.memberPrice }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="vendor">由 {{ product.vendorName || '平台官方' }} 提供</view>
|
|
|
+ <view v-if="product.description" class="description">{{ product.description }}</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="action-bar">
|
|
|
+ <button class="btn-buy" @click="onBuy">立即购买</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-else class="loading">加载中...</view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { productDetail, productOrderCreate, productOrderPay } from '@/utils/api.js';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ product: null,
|
|
|
+ productId: null
|
|
|
+ };
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this.productId = options.id;
|
|
|
+ this.loadDetail();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadDetail() {
|
|
|
+ try {
|
|
|
+ const res = await productDetail({ id: this.productId });
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.product = res.data;
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('loadDetail failed', e);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async onBuy() {
|
|
|
+ if (!this.product) return;
|
|
|
+ uni.showLoading({ title: '创建订单中...' });
|
|
|
+ try {
|
|
|
+ const createRes = await productOrderCreate({
|
|
|
+ productId: this.product.id,
|
|
|
+ quantity: 1,
|
|
|
+ paymentMethod: 'points'
|
|
|
+ });
|
|
|
+ if (createRes.code !== 200) {
|
|
|
+ uni.hideLoading();
|
|
|
+ uni.showToast({ title: createRes.message || '创建订单失败', icon: 'none' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const orderNo = createRes.data.orderNo;
|
|
|
+ // 直接支付(测试模式)
|
|
|
+ const payRes = await productOrderPay({ orderNo: orderNo });
|
|
|
+ uni.hideLoading();
|
|
|
+ if (payRes.code === 200) {
|
|
|
+ uni.showToast({ title: '购买成功', icon: 'success' });
|
|
|
+ setTimeout(() => {
|
|
|
+ uni.redirectTo({ url: '/pages/shop/order-detail?orderNo=' + orderNo });
|
|
|
+ }, 1000);
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: payRes.message || '支付失败', icon: 'none' });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.hideLoading();
|
|
|
+ console.error('onBuy failed', e);
|
|
|
+ uni.showToast({ title: '网络错误', icon: 'none' });
|
|
|
+ }
|
|
|
+ },
|
|
|
+ domainEmoji(domain) {
|
|
|
+ const map = { action: '🌿', mind: '🔥', body: '🌏', wisdom: '⚔️', wealth: '💧' };
|
|
|
+ return map[domain] || '✨';
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.detail-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #F8F4ED;
|
|
|
+ padding-bottom: 120rpx;
|
|
|
+}
|
|
|
+.cover-section {
|
|
|
+ width: 100%;
|
|
|
+ height: 500rpx;
|
|
|
+}
|
|
|
+.cover {
|
|
|
+ width: 100%;
|
|
|
+ height: 500rpx;
|
|
|
+}
|
|
|
+.cover-img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.cover-placeholder {
|
|
|
+ background: linear-gradient(135deg, #FFE5B4, #FFB88C);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+.cover-emoji {
|
|
|
+ font-size: 200rpx;
|
|
|
+}
|
|
|
+.info-section {
|
|
|
+ background: #FFFFFF;
|
|
|
+ padding: 30rpx;
|
|
|
+ border-radius: 30rpx 30rpx 0 0;
|
|
|
+ margin-top: -30rpx;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.name {
|
|
|
+ font-size: 36rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #3C2A1E;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+.price-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: baseline;
|
|
|
+ margin-top: 20rpx;
|
|
|
+}
|
|
|
+.price {
|
|
|
+ font-size: 44rpx;
|
|
|
+ color: #DE6262;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.member-price {
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #FF8C00;
|
|
|
+ margin-left: 16rpx;
|
|
|
+ background: #FFF3D6;
|
|
|
+ padding: 4rpx 12rpx;
|
|
|
+ border-radius: 12rpx;
|
|
|
+}
|
|
|
+.vendor {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #8B7355;
|
|
|
+ margin-top: 12rpx;
|
|
|
+}
|
|
|
+.description {
|
|
|
+ margin-top: 24rpx;
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #3C2A1E;
|
|
|
+ line-height: 1.6;
|
|
|
+ white-space: pre-wrap;
|
|
|
+}
|
|
|
+.action-bar {
|
|
|
+ position: fixed;
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ background: #FFFFFF;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+ box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.08);
|
|
|
+}
|
|
|
+.btn-buy {
|
|
|
+ background: linear-gradient(135deg, #FFB88C, #DE6262);
|
|
|
+ color: #FFFFFF;
|
|
|
+ border: none;
|
|
|
+ border-radius: 50rpx;
|
|
|
+ font-size: 30rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ height: 88rpx;
|
|
|
+ line-height: 88rpx;
|
|
|
+}
|
|
|
+.loading {
|
|
|
+ text-align: center;
|
|
|
+ padding: 200rpx 0;
|
|
|
+ color: #8B7355;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 pages.json 中注册新页面**
|
|
|
+
|
|
|
+打开 `zxyj-frontend/pages.json`,找到 `pages` 数组,在最后添加:
|
|
|
+
|
|
|
+```json
|
|
|
+ {
|
|
|
+ "path": "pages/discover/product-detail",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "商品详情"
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 验证文件存在**
|
|
|
+
|
|
|
+```bash
|
|
|
+rtk test -f /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/discover/product-detail.vue
|
|
|
+rtk grep "product-detail" /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages.json
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 第二个命令输出包含 `"pages/discover/product-detail"`
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/pages/discover/product-detail.vue zxyj-frontend/pages.json
|
|
|
+git commit -m "feat(phase1): 商品详情页(商城/发现共用)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T10: 订单列表 + 订单详情页
|
|
|
+
|
|
|
+### Task 10.1: 订单列表页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-frontend/pages/shop/order-list.vue`
|
|
|
+- Modify: `zxyj-frontend/pages.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 pages/shop/order-list.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="order-list-page">
|
|
|
+ <view class="tabs">
|
|
|
+ <text v-for="t in statusOptions" :key="t.value"
|
|
|
+ :class="['tab', currentStatus === t.value ? 'active' : '']"
|
|
|
+ @click="onTabChange(t.value)">{{ t.label }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="orders.length === 0 && !loading" class="empty">暂无订单</view>
|
|
|
+
|
|
|
+ <view class="list">
|
|
|
+ <view v-for="o in orders" :key="o.id" class="order-card" @click="goDetail(o.orderNo)">
|
|
|
+ <view class="order-header">
|
|
|
+ <text class="order-no">订单号:{{ o.orderNo }}</text>
|
|
|
+ <text :class="['status', 'status-' + o.status]">{{ statusLabel(o.status) }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="order-body">
|
|
|
+ <view class="product-name">{{ o.productName }}</view>
|
|
|
+ <view class="order-meta">
|
|
|
+ <text class="quantity">x{{ o.quantity }}</text>
|
|
|
+ <text class="amount">¥{{ o.totalAmount }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="order-time">{{ formatTime(o.createdAt) }}</view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { productOrderMy } from '@/utils/api.js';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ orders: [],
|
|
|
+ loading: false,
|
|
|
+ currentStatus: '',
|
|
|
+ statusOptions: [
|
|
|
+ { label: '全部', value: '' },
|
|
|
+ { label: '待支付', value: 'pending' },
|
|
|
+ { label: '已支付', value: 'paid' },
|
|
|
+ { label: '已完成', value: 'completed' },
|
|
|
+ { label: '已取消', value: 'cancelled' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+ onShow() {
|
|
|
+ this.loadOrders();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadOrders() {
|
|
|
+ this.loading = true;
|
|
|
+ try {
|
|
|
+ const res = await productOrderMy({ status: this.currentStatus });
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.orders = res.data || [];
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('loadOrders failed', e);
|
|
|
+ }
|
|
|
+ this.loading = false;
|
|
|
+ },
|
|
|
+ onTabChange(value) {
|
|
|
+ this.currentStatus = value;
|
|
|
+ this.loadOrders();
|
|
|
+ },
|
|
|
+ goDetail(orderNo) {
|
|
|
+ uni.navigateTo({ url: '/pages/shop/order-detail?orderNo=' + orderNo });
|
|
|
+ },
|
|
|
+ statusLabel(s) {
|
|
|
+ const map = { pending: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消', refunded: '已退款' };
|
|
|
+ return map[s] || s;
|
|
|
+ },
|
|
|
+ formatTime(t) {
|
|
|
+ if (!t) return '';
|
|
|
+ const d = new Date(t);
|
|
|
+ return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate() + ' ' + d.getHours() + ':' + d.getMinutes();
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.order-list-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #F8F4ED;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+}
|
|
|
+.tabs {
|
|
|
+ display: flex;
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ padding: 10rpx;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
|
|
|
+}
|
|
|
+.tab {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ padding: 16rpx 0;
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #6B4423;
|
|
|
+ border-radius: 16rpx;
|
|
|
+}
|
|
|
+.tab.active {
|
|
|
+ background: linear-gradient(135deg, #FFB88C, #DE6262);
|
|
|
+ color: #FFFFFF;
|
|
|
+}
|
|
|
+.empty {
|
|
|
+ text-align: center;
|
|
|
+ padding: 100rpx 0;
|
|
|
+ color: #8B7355;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+.list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 20rpx;
|
|
|
+}
|
|
|
+.order-card {
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ padding: 24rpx;
|
|
|
+ box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
|
|
+}
|
|
|
+.order-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding-bottom: 16rpx;
|
|
|
+ border-bottom: 1rpx solid #F0E5D8;
|
|
|
+}
|
|
|
+.order-no {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.status {
|
|
|
+ font-size: 24rpx;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.status-pending { color: #FF8C00; }
|
|
|
+.status-paid { color: #4CAF50; }
|
|
|
+.status-completed { color: #2196F3; }
|
|
|
+.status-cancelled { color: #999; }
|
|
|
+.status-refunded { color: #F44336; }
|
|
|
+.order-body {
|
|
|
+ padding: 20rpx 0;
|
|
|
+}
|
|
|
+.product-name {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #3C2A1E;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.order-meta {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-top: 12rpx;
|
|
|
+}
|
|
|
+.quantity {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.amount {
|
|
|
+ font-size: 32rpx;
|
|
|
+ color: #DE6262;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.order-time {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #8B7355;
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 pages.json 中注册新页面**
|
|
|
+
|
|
|
+打开 `zxyj-frontend/pages.json`,在 `pages` 数组最后添加:
|
|
|
+
|
|
|
+```json
|
|
|
+ {
|
|
|
+ "path": "pages/shop/order-list",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "我的订单"
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/pages/shop/order-list.vue zxyj-frontend/pages.json
|
|
|
+git commit -m "feat(phase1): 订单列表页"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 10.2: 订单详情页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-frontend/pages/shop/order-detail.vue`
|
|
|
+- Modify: `zxyj-frontend/pages.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 pages/shop/order-detail.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="order-detail-page">
|
|
|
+ <view v-if="order">
|
|
|
+ <view class="status-section" :class="'status-' + order.status">
|
|
|
+ <text class="status-text">{{ statusLabel(order.status) }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="section">
|
|
|
+ <view class="row">
|
|
|
+ <text class="label">商品</text>
|
|
|
+ <text class="value">{{ order.productName }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="row">
|
|
|
+ <text class="label">数量</text>
|
|
|
+ <text class="value">x{{ order.quantity }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="row">
|
|
|
+ <text class="label">单价</text>
|
|
|
+ <text class="value">¥{{ order.unitPrice }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="row">
|
|
|
+ <text class="label">订单号</text>
|
|
|
+ <text class="value">{{ order.orderNo }}</text>
|
|
|
+ </view>
|
|
|
+ <view v-if="order.paidAt" class="row">
|
|
|
+ <text class="label">支付时间</text>
|
|
|
+ <text class="value">{{ formatTime(order.paidAt) }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="row">
|
|
|
+ <text class="label">创建时间</text>
|
|
|
+ <text class="value">{{ formatTime(order.createdAt) }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="amount-section">
|
|
|
+ <text class="amount-label">实付金额</text>
|
|
|
+ <text class="amount-value">¥{{ order.totalAmount }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="order.status === 'pending'" class="action-bar">
|
|
|
+ <button class="btn-cancel" @click="onCancel">取消订单</button>
|
|
|
+ <button class="btn-pay" @click="onPay">立即支付</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-else class="loading">加载中...</view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { productOrderDetail, productOrderPay, productOrderCancel } from '@/utils/api.js';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ order: null,
|
|
|
+ orderNo: null
|
|
|
+ };
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this.orderNo = options.orderNo;
|
|
|
+ this.loadDetail();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadDetail() {
|
|
|
+ try {
|
|
|
+ const res = await productOrderDetail({ orderNo: this.orderNo });
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.order = res.data;
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '加载失败', icon: 'none' });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('loadDetail failed', e);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async onPay() {
|
|
|
+ uni.showLoading({ title: '支付中...' });
|
|
|
+ try {
|
|
|
+ const res = await productOrderPay({ orderNo: this.orderNo });
|
|
|
+ uni.hideLoading();
|
|
|
+ if (res.code === 200) {
|
|
|
+ uni.showToast({ title: '支付成功', icon: 'success' });
|
|
|
+ this.loadDetail();
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '支付失败', icon: 'none' });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.hideLoading();
|
|
|
+ console.error('onPay failed', e);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async onCancel() {
|
|
|
+ uni.showModal({
|
|
|
+ title: '确认取消',
|
|
|
+ content: '确定要取消此订单吗?',
|
|
|
+ success: async (modalRes) => {
|
|
|
+ if (modalRes.confirm) {
|
|
|
+ try {
|
|
|
+ const res = await productOrderCancel({ orderNo: this.orderNo });
|
|
|
+ if (res.code === 200) {
|
|
|
+ uni.showToast({ title: '已取消', icon: 'success' });
|
|
|
+ this.loadDetail();
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '取消失败', icon: 'none' });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('onCancel failed', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+ statusLabel(s) {
|
|
|
+ const map = { pending: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消', refunded: '已退款' };
|
|
|
+ return map[s] || s;
|
|
|
+ },
|
|
|
+ formatTime(t) {
|
|
|
+ if (!t) return '';
|
|
|
+ const d = new Date(t);
|
|
|
+ return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate() + ' ' + d.getHours() + ':' + d.getMinutes();
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.order-detail-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #F8F4ED;
|
|
|
+ padding-bottom: 140rpx;
|
|
|
+}
|
|
|
+.status-section {
|
|
|
+ padding: 40rpx 30rpx;
|
|
|
+ text-align: center;
|
|
|
+ color: #FFFFFF;
|
|
|
+}
|
|
|
+.status-pending { background: linear-gradient(135deg, #FFB88C, #FF8C00); }
|
|
|
+.status-paid { background: linear-gradient(135deg, #81C784, #4CAF50); }
|
|
|
+.status-completed { background: linear-gradient(135deg, #64B5F6, #2196F3); }
|
|
|
+.status-cancelled { background: linear-gradient(135deg, #BDBDBD, #9E9E9E); }
|
|
|
+.status-refunded { background: linear-gradient(135deg, #E57373, #F44336); }
|
|
|
+.status-text {
|
|
|
+ font-size: 36rpx;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.section {
|
|
|
+ background: #FFFFFF;
|
|
|
+ margin: 20rpx 30rpx;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ padding: 30rpx;
|
|
|
+ box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
|
|
+}
|
|
|
+.row {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 16rpx 0;
|
|
|
+ font-size: 28rpx;
|
|
|
+ border-bottom: 1rpx solid #F5F0E8;
|
|
|
+}
|
|
|
+.row:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+}
|
|
|
+.label {
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.value {
|
|
|
+ color: #3C2A1E;
|
|
|
+}
|
|
|
+.amount-section {
|
|
|
+ background: #FFFFFF;
|
|
|
+ margin: 20rpx 30rpx;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ padding: 30rpx;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
|
|
+}
|
|
|
+.amount-label {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #6B4423;
|
|
|
+}
|
|
|
+.amount-value {
|
|
|
+ font-size: 40rpx;
|
|
|
+ color: #DE6262;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.action-bar {
|
|
|
+ position: fixed;
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ display: flex;
|
|
|
+ background: #FFFFFF;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+ gap: 20rpx;
|
|
|
+ box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.08);
|
|
|
+}
|
|
|
+.btn-cancel {
|
|
|
+ flex: 1;
|
|
|
+ background: #FFFFFF;
|
|
|
+ color: #6B4423;
|
|
|
+ border: 2rpx solid #6B4423;
|
|
|
+ border-radius: 50rpx;
|
|
|
+ font-size: 30rpx;
|
|
|
+ height: 88rpx;
|
|
|
+ line-height: 84rpx;
|
|
|
+}
|
|
|
+.btn-pay {
|
|
|
+ flex: 2;
|
|
|
+ background: linear-gradient(135deg, #FFB88C, #DE6262);
|
|
|
+ color: #FFFFFF;
|
|
|
+ border: none;
|
|
|
+ border-radius: 50rpx;
|
|
|
+ font-size: 30rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ height: 88rpx;
|
|
|
+ line-height: 88rpx;
|
|
|
+}
|
|
|
+.loading {
|
|
|
+ text-align: center;
|
|
|
+ padding: 200rpx 0;
|
|
|
+ color: #8B7355;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 pages.json 中注册新页面**
|
|
|
+
|
|
|
+打开 `zxyj-frontend/pages.json`,在 `pages` 数组最后添加:
|
|
|
+
|
|
|
+```json
|
|
|
+ {
|
|
|
+ "path": "pages/shop/order-detail",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "订单详情"
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/pages/shop/order-detail.vue zxyj-frontend/pages.json
|
|
|
+git commit -m "feat(phase1): 订单详情页"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T11: 服务商中心扩展
|
|
|
+
|
|
|
+### Task 11.1: 服务商商品管理页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-frontend/pages/vendor/products.vue`
|
|
|
+- Modify: `zxyj-frontend/pages.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 pages/vendor/products.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="vendor-products-page">
|
|
|
+ <view class="header">
|
|
|
+ <view class="title">我的商品</view>
|
|
|
+ <button class="btn-add" @click="onCreate">+ 发布商品</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="!isVendor" class="not-vendor">
|
|
|
+ <text>请先入驻成为服务商</text>
|
|
|
+ <button @click="goApply">立即入驻</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-else>
|
|
|
+ <view v-if="products.length === 0" class="empty">尚未发布商品</view>
|
|
|
+ <view v-else class="list">
|
|
|
+ <view v-for="p in products" :key="p.id" class="card">
|
|
|
+ <view class="card-header">
|
|
|
+ <text class="name">{{ p.name }}</text>
|
|
|
+ <text :class="['status-tag', 'status-' + p.status]">{{ statusLabel(p.status) }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="card-info">
|
|
|
+ <text class="price">¥{{ p.price }}</text>
|
|
|
+ <text class="stock">库存:{{ p.stock }}</text>
|
|
|
+ <text class="sales">已售:{{ p.salesCount || 0 }}</text>
|
|
|
+ </view>
|
|
|
+ <view v-if="p.rejectReason" class="reject-reason">拒绝原因:{{ p.rejectReason }}</view>
|
|
|
+ <view class="actions">
|
|
|
+ <button v-if="p.status === 'approved' && p.status !== 'on_shelf'" class="btn-act" @click="onShelve(p, 'shelve')">上架</button>
|
|
|
+ <button v-if="p.status === 'on_shelf'" class="btn-act" @click="onShelve(p, 'unshelve')">下架</button>
|
|
|
+ <button class="btn-act" @click="onEdit(p)">编辑</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { productMy, productShelve, vendorStatus } from '@/utils/api.js';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ products: [],
|
|
|
+ isVendor: false
|
|
|
+ };
|
|
|
+ },
|
|
|
+ onShow() {
|
|
|
+ this.checkVendor();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async checkVendor() {
|
|
|
+ try {
|
|
|
+ const res = await vendorStatus();
|
|
|
+ if (res.code === 200 && res.data && res.data.vendorStatus === 'approved') {
|
|
|
+ this.isVendor = true;
|
|
|
+ this.loadProducts();
|
|
|
+ } else {
|
|
|
+ this.isVendor = false;
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('checkVendor failed', e);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async loadProducts() {
|
|
|
+ try {
|
|
|
+ const res = await productMy({});
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.products = res.data || [];
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('loadProducts failed', e);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onCreate() {
|
|
|
+ uni.showModal({
|
|
|
+ title: '发布商品',
|
|
|
+ content: '商品发布功能:\n1. 名称、价格、库存、类型\n2. 提交后需管理员审核\n\n请通过管理端/Web 端发布,或在后续 Phase 完善。',
|
|
|
+ showCancel: false
|
|
|
+ });
|
|
|
+ },
|
|
|
+ onEdit(p) {
|
|
|
+ uni.showToast({ title: '编辑功能待实现', icon: 'none' });
|
|
|
+ },
|
|
|
+ async onShelve(p, action) {
|
|
|
+ try {
|
|
|
+ const res = await productShelve({ productId: p.id, action: action });
|
|
|
+ if (res.code === 200) {
|
|
|
+ uni.showToast({ title: action === 'shelve' ? '已上架' : '已下架', icon: 'success' });
|
|
|
+ this.loadProducts();
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '操作失败', icon: 'none' });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('onShelve failed', e);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ goApply() {
|
|
|
+ uni.navigateTo({ url: '/pages/vendor/apply' });
|
|
|
+ },
|
|
|
+ statusLabel(s) {
|
|
|
+ const map = {
|
|
|
+ draft: '草稿', pending: '待审核', approved: '已通过', rejected: '已拒绝',
|
|
|
+ on_shelf: '已上架', off_shelf: '已下架'
|
|
|
+ };
|
|
|
+ return map[s] || s;
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.vendor-products-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #F8F4ED;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+}
|
|
|
+.header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 30rpx;
|
|
|
+}
|
|
|
+.title {
|
|
|
+ font-size: 36rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #6B4423;
|
|
|
+}
|
|
|
+.btn-add {
|
|
|
+ background: linear-gradient(135deg, #FFB88C, #DE6262);
|
|
|
+ color: #FFFFFF;
|
|
|
+ border: none;
|
|
|
+ border-radius: 30rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+ padding: 12rpx 24rpx;
|
|
|
+ height: auto;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+.not-vendor {
|
|
|
+ text-align: center;
|
|
|
+ padding: 100rpx 0;
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.not-vendor button {
|
|
|
+ margin-top: 30rpx;
|
|
|
+ background: linear-gradient(135deg, #FFB88C, #DE6262);
|
|
|
+ color: #FFFFFF;
|
|
|
+ border: none;
|
|
|
+ border-radius: 50rpx;
|
|
|
+ width: 60%;
|
|
|
+}
|
|
|
+.empty {
|
|
|
+ text-align: center;
|
|
|
+ padding: 100rpx 0;
|
|
|
+ color: #8B7355;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+.list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 20rpx;
|
|
|
+}
|
|
|
+.card {
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ padding: 24rpx;
|
|
|
+ box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
|
|
+}
|
|
|
+.card-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.name {
|
|
|
+ font-size: 30rpx;
|
|
|
+ color: #3C2A1E;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.status-tag {
|
|
|
+ font-size: 22rpx;
|
|
|
+ padding: 4rpx 12rpx;
|
|
|
+ border-radius: 12rpx;
|
|
|
+}
|
|
|
+.status-pending { background: #FFF3D6; color: #FF8C00; }
|
|
|
+.status-approved { background: #D6F5D6; color: #4CAF50; }
|
|
|
+.status-rejected { background: #FFD6D6; color: #F44336; }
|
|
|
+.status-on_shelf { background: #D6E5F5; color: #2196F3; }
|
|
|
+.status-off_shelf { background: #E0E0E0; color: #757575; }
|
|
|
+.card-info {
|
|
|
+ display: flex;
|
|
|
+ gap: 20rpx;
|
|
|
+ margin-top: 12rpx;
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.price {
|
|
|
+ color: #DE6262;
|
|
|
+ font-weight: bold;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+.reject-reason {
|
|
|
+ margin-top: 12rpx;
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #F44336;
|
|
|
+ background: #FFF0F0;
|
|
|
+ padding: 8rpx 12rpx;
|
|
|
+ border-radius: 8rpx;
|
|
|
+}
|
|
|
+.actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 16rpx;
|
|
|
+ margin-top: 20rpx;
|
|
|
+ border-top: 1rpx solid #F0E5D8;
|
|
|
+ padding-top: 16rpx;
|
|
|
+}
|
|
|
+.btn-act {
|
|
|
+ flex: 1;
|
|
|
+ background: #F8F4ED;
|
|
|
+ color: #6B4423;
|
|
|
+ border: 1rpx solid #E0D5C0;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ font-size: 24rpx;
|
|
|
+ height: 60rpx;
|
|
|
+ line-height: 60rpx;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 pages.json 中注册新页面**
|
|
|
+
|
|
|
+打开 `zxyj-frontend/pages.json`,在 `pages` 数组最后添加:
|
|
|
+
|
|
|
+```json
|
|
|
+ {
|
|
|
+ "path": "pages/vendor/products",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "商品管理"
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/pages/vendor/products.vue zxyj-frontend/pages.json
|
|
|
+git commit -m "feat(phase1): 服务商商品管理页"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 11.2: 服务商订单管理页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-frontend/pages/vendor/orders.vue`
|
|
|
+- Modify: `zxyj-frontend/pages.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 pages/vendor/orders.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="vendor-orders-page">
|
|
|
+ <view class="header">
|
|
|
+ <view class="title">收到的订单</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="orders.length === 0 && !loading" class="empty">暂无订单</view>
|
|
|
+
|
|
|
+ <view v-else class="list">
|
|
|
+ <view v-for="o in orders" :key="o.id" class="card">
|
|
|
+ <view class="card-header">
|
|
|
+ <text class="order-no">{{ o.orderNo }}</text>
|
|
|
+ <text :class="['status', 'status-' + o.status]">{{ statusLabel(o.status) }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="card-body">
|
|
|
+ <view class="product-name">{{ o.productName }}</view>
|
|
|
+ <view class="meta">
|
|
|
+ <text class="quantity">x{{ o.quantity }}</text>
|
|
|
+ <text class="amount">¥{{ o.totalAmount }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="card-actions">
|
|
|
+ <button v-if="o.status === 'paid'" class="btn-confirm" @click="onConfirm(o)">确认完成</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { productOrderVendor, productOrderConfirm } from '@/utils/api.js';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ orders: [],
|
|
|
+ loading: false
|
|
|
+ };
|
|
|
+ },
|
|
|
+ onShow() {
|
|
|
+ this.loadOrders();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadOrders() {
|
|
|
+ this.loading = true;
|
|
|
+ try {
|
|
|
+ const res = await productOrderVendor({});
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.orders = res.data || [];
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('loadOrders failed', e);
|
|
|
+ }
|
|
|
+ this.loading = false;
|
|
|
+ },
|
|
|
+ async onConfirm(o) {
|
|
|
+ try {
|
|
|
+ const res = await productOrderConfirm({ orderNo: o.orderNo });
|
|
|
+ if (res.code === 200) {
|
|
|
+ uni.showToast({ title: '已确认', icon: 'success' });
|
|
|
+ this.loadOrders();
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '操作失败', icon: 'none' });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('onConfirm failed', e);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ statusLabel(s) {
|
|
|
+ const map = { pending: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消', refunded: '已退款' };
|
|
|
+ return map[s] || s;
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.vendor-orders-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #F8F4ED;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+}
|
|
|
+.header {
|
|
|
+ margin-bottom: 30rpx;
|
|
|
+}
|
|
|
+.title {
|
|
|
+ font-size: 36rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #6B4423;
|
|
|
+}
|
|
|
+.empty {
|
|
|
+ text-align: center;
|
|
|
+ padding: 100rpx 0;
|
|
|
+ color: #8B7355;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+.list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 20rpx;
|
|
|
+}
|
|
|
+.card {
|
|
|
+ background: #FFFFFF;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ padding: 24rpx;
|
|
|
+ box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
|
|
+}
|
|
|
+.card-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding-bottom: 12rpx;
|
|
|
+ border-bottom: 1rpx solid #F0E5D8;
|
|
|
+}
|
|
|
+.order-no {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.status {
|
|
|
+ font-size: 24rpx;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.status-pending { color: #FF8C00; }
|
|
|
+.status-paid { color: #4CAF50; }
|
|
|
+.status-completed { color: #2196F3; }
|
|
|
+.status-cancelled { color: #999; }
|
|
|
+.card-body {
|
|
|
+ padding: 16rpx 0;
|
|
|
+}
|
|
|
+.product-name {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #3C2A1E;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.meta {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-top: 8rpx;
|
|
|
+}
|
|
|
+.quantity {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #8B7355;
|
|
|
+}
|
|
|
+.amount {
|
|
|
+ font-size: 30rpx;
|
|
|
+ color: #DE6262;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.card-actions {
|
|
|
+ border-top: 1rpx solid #F0E5D8;
|
|
|
+ padding-top: 16rpx;
|
|
|
+}
|
|
|
+.btn-confirm {
|
|
|
+ width: 100%;
|
|
|
+ background: linear-gradient(135deg, #FFB88C, #DE6262);
|
|
|
+ color: #FFFFFF;
|
|
|
+ border: none;
|
|
|
+ border-radius: 30rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+ height: 70rpx;
|
|
|
+ line-height: 70rpx;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 pages.json 中注册新页面**
|
|
|
+
|
|
|
+打开 `zxyj-frontend/pages.json`,在 `pages` 数组最后添加:
|
|
|
+
|
|
|
+```json
|
|
|
+ {
|
|
|
+ "path": "pages/vendor/orders",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "订单管理"
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/pages/vendor/orders.vue zxyj-frontend/pages.json
|
|
|
+git commit -m "feat(phase1): 服务商订单管理页"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 11.3: 服务商中心菜单扩展
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `zxyj-frontend/pages/vendor/center.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 找到 vendor/center.vue 中"菜单项"区域**
|
|
|
+
|
|
|
+打开 `zxyj-frontend/pages/vendor/center.vue`,找到 menu-items 列表(如 `<view class="menu">...</view>` 或类似),在每个菜单项 `<view class="menu-item">` 之后添加新菜单项。
|
|
|
+
|
|
|
+如果菜单项使用 `methods.goXxx`,在 `methods` 中添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+goProductManage() {
|
|
|
+ uni.navigateTo({ url: '/pages/vendor/products' });
|
|
|
+},
|
|
|
+goOrderManage() {
|
|
|
+ uni.navigateTo({ url: '/pages/vendor/orders' });
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+具体位置请根据现有 center.vue 的代码结构进行调整。参考现有代码(已在 Phase 0 实现)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 验证文件被修改**
|
|
|
+
|
|
|
+```bash
|
|
|
+rtk grep -c "goProductManage\|goOrderManage" /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/vendor/center.vue
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 输出 >= 1(说明有新增的菜单项处理函数)
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-frontend/pages/vendor/center.vue
|
|
|
+git commit -m "feat(phase1): 服务商中心添加商品管理/订单管理入口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T12: Web 端 — 商品管理
|
|
|
+
|
|
|
+### Task 12.1: Web API 封装
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-web/src/api/product.js`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 src/api/product.js**
|
|
|
+
|
|
|
+```javascript
|
|
|
+import request from '@/utils/request';
|
|
|
+
|
|
|
+// 管理员:商品待审核列表
|
|
|
+export const adminProductPending = (params) => request({ url: '/api/admin/product/pending', method: 'post', data: params });
|
|
|
+
|
|
|
+// 管理员:商品列表
|
|
|
+export const adminProductList = (params) => request({ url: '/api/admin/product/list', method: 'post', data: params });
|
|
|
+
|
|
|
+// 管理员:商品审核
|
|
|
+export const adminProductReview = (params) => request({ url: '/api/admin/product/review', method: 'post', data: params });
|
|
|
+
|
|
|
+// 商品列表(公开)
|
|
|
+export const productList = (params) => request({ url: '/api/product/list', method: 'post', data: params });
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-web/src/api/product.js
|
|
|
+git commit -m "feat(phase1): Web 端 product API 封装"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 12.2: Web 商品管理页面
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-web/src/views/admin/ProductManage.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 src/views/admin/ProductManage.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="product-manage">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header" class="clearfix">
|
|
|
+ <span>商品管理</span>
|
|
|
+ <el-select v-model="statusFilter" placeholder="按状态筛选" clearable style="width: 200px; margin-left: 20px;" @change="loadList">
|
|
|
+ <el-option label="待审核" value="pending" />
|
|
|
+ <el-option label="已通过" value="approved" />
|
|
|
+ <el-option label="已上架" value="on_shelf" />
|
|
|
+ <el-option label="已下架" value="off_shelf" />
|
|
|
+ <el-option label="已拒绝" value="rejected" />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-table :data="products" border style="width: 100%">
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="name" label="商品名称" min-width="200" />
|
|
|
+ <el-table-column prop="productType" label="类型" width="100">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-tag>{{ typeLabel(scope.row.productType) }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="price" label="价格" width="100">
|
|
|
+ <template slot-scope="scope">¥{{ scope.row.price }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="memberPrice" label="会员价" width="100">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <span v-if="scope.row.memberPrice">¥{{ scope.row.memberPrice }}</span>
|
|
|
+ <span v-else>-</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="stock" label="库存" width="80" />
|
|
|
+ <el-table-column prop="salesCount" label="销量" width="80" />
|
|
|
+ <el-table-column prop="vendorName" label="服务商" width="150" />
|
|
|
+ <el-table-column prop="status" label="状态" width="120">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-tag :type="statusTagType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="200">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-button v-if="scope.row.status === 'pending'" size="mini" type="success" @click="onReview(scope.row, 'approve')">通过</el-button>
|
|
|
+ <el-button v-if="scope.row.status === 'pending'" size="mini" type="danger" @click="onReview(scope.row, 'reject')">拒绝</el-button>
|
|
|
+ <el-button v-if="scope.row.status === 'rejected' && scope.row.rejectReason" size="mini" @click="showRejectReason(scope.row.rejectReason)">查看原因</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="500px">
|
|
|
+ <el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入拒绝原因" />
|
|
|
+ <div slot="footer">
|
|
|
+ <el-button @click="rejectDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="confirmReject">确认拒绝</el-button>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { adminProductList, adminProductReview } from '@/api/product';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ products: [],
|
|
|
+ statusFilter: '',
|
|
|
+ rejectDialogVisible: false,
|
|
|
+ rejectReason: '',
|
|
|
+ currentRejectProduct: null
|
|
|
+ };
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.loadList();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadList() {
|
|
|
+ try {
|
|
|
+ const res = await adminProductList({ status: this.statusFilter });
|
|
|
+ if (res.data && res.data.code === 200) {
|
|
|
+ this.products = res.data.data || [];
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.$message.error('加载失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onReview(product, action) {
|
|
|
+ if (action === 'approve') {
|
|
|
+ this.$confirm('确认通过此商品审核?', '提示', {
|
|
|
+ confirmButtonText: '确认',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }).then(async () => {
|
|
|
+ await this.submitReview(product, 'approve', null);
|
|
|
+ }).catch(() => {});
|
|
|
+ } else {
|
|
|
+ this.currentRejectProduct = product;
|
|
|
+ this.rejectReason = '';
|
|
|
+ this.rejectDialogVisible = true;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async confirmReject() {
|
|
|
+ if (!this.rejectReason.trim()) {
|
|
|
+ this.$message.warning('请输入拒绝原因');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await this.submitReview(this.currentRejectProduct, 'reject', this.rejectReason);
|
|
|
+ this.rejectDialogVisible = false;
|
|
|
+ },
|
|
|
+ async submitReview(product, action, rejectReason) {
|
|
|
+ try {
|
|
|
+ const res = await adminProductReview({
|
|
|
+ productId: product.id,
|
|
|
+ action: action,
|
|
|
+ rejectReason: rejectReason
|
|
|
+ });
|
|
|
+ if (res.data && res.data.code === 200) {
|
|
|
+ this.$message.success('操作成功');
|
|
|
+ this.loadList();
|
|
|
+ } else {
|
|
|
+ this.$message.error(res.data.message || '操作失败');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.$message.error('操作失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ showRejectReason(reason) {
|
|
|
+ this.$alert(reason, '拒绝原因', { confirmButtonText: '关闭' });
|
|
|
+ },
|
|
|
+ typeLabel(type) {
|
|
|
+ const map = { activity: '活动', course: '课程', physical: '实物', digital: '数字', service: '服务' };
|
|
|
+ return map[type] || type;
|
|
|
+ },
|
|
|
+ statusLabel(s) {
|
|
|
+ const map = { draft: '草稿', pending: '待审核', approved: '已通过', rejected: '已拒绝', on_shelf: '已上架', off_shelf: '已下架' };
|
|
|
+ return map[s] || s;
|
|
|
+ },
|
|
|
+ statusTagType(s) {
|
|
|
+ const map = { pending: 'warning', approved: 'success', rejected: 'danger', on_shelf: 'success', off_shelf: 'info' };
|
|
|
+ return map[s] || '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.product-manage {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-web/src/views/admin/ProductManage.vue
|
|
|
+git commit -m "feat(phase1): Web 端商品管理页面"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 12.3: Web 路由 + 侧边栏菜单
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `zxyj-web/src/router/index.js`
|
|
|
+- Modify: `zxyj-web/src/views/Layout.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 router/index.js 添加商品管理路由**
|
|
|
+
|
|
|
+打开 `zxyj-web/src/router/index.js`,找到路由数组(如 `adminRoutes` 或类似),添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+{
|
|
|
+ path: '/admin/product',
|
|
|
+ component: () => import('@/views/admin/ProductManage.vue'),
|
|
|
+ meta: { title: '商品管理', icon: 'goods' }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 Layout.vue 侧边栏添加菜单项**
|
|
|
+
|
|
|
+打开 `zxyj-web/src/views/Layout.vue`,找到菜单列表,在"系统"或"运营"子菜单下添加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-menu-item index="/admin/product">
|
|
|
+ <i class="el-icon-goods"></i>
|
|
|
+ <span slot="title">商品管理</span>
|
|
|
+</el-menu-item>
|
|
|
+```
|
|
|
+
|
|
|
+具体位置请根据现有 Layout.vue 的菜单结构调整。
|
|
|
+
|
|
|
+- [ ] **Step 3: 验证文件修改**
|
|
|
+
|
|
|
+```bash
|
|
|
+rtk grep "ProductManage\|商品管理" /mnt/e/workspace/dan/zxyj/zxyj-web/src/router/index.js
|
|
|
+rtk grep "商品管理" /mnt/e/workspace/dan/zxyj/zxyj-web/src/views/Layout.vue
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 两个 grep 都有输出
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-web/src/router/index.js zxyj-web/src/views/Layout.vue
|
|
|
+git commit -m "feat(phase1): Web 端商品管理路由 + 菜单"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T13: Web 端 — 订单管理
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-web/src/views/admin/OrderManage.vue`
|
|
|
+- Modify: `zxyj-web/src/router/index.js`
|
|
|
+- Modify: `zxyj-web/src/views/Layout.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 src/views/admin/OrderManage.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="order-manage">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header" class="clearfix">
|
|
|
+ <span>订单管理</span>
|
|
|
+ <el-select v-model="statusFilter" placeholder="按状态筛选" clearable style="width: 200px; margin-left: 20px;" @change="loadList">
|
|
|
+ <el-option label="待支付" value="pending" />
|
|
|
+ <el-option label="已支付" value="paid" />
|
|
|
+ <el-option label="已完成" value="completed" />
|
|
|
+ <el-option label="已取消" value="cancelled" />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-table :data="orders" border style="width: 100%">
|
|
|
+ <el-table-column prop="orderNo" label="订单号" min-width="200" />
|
|
|
+ <el-table-column prop="productName" label="商品" min-width="200" />
|
|
|
+ <el-table-column prop="buyerId" label="买家ID" width="100" />
|
|
|
+ <el-table-column prop="quantity" label="数量" width="80" />
|
|
|
+ <el-table-column prop="totalAmount" label="金额" width="100">
|
|
|
+ <template slot-scope="scope">¥{{ scope.row.totalAmount }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="status" label="状态" width="120">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-tag :type="statusTagType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="paymentMethod" label="支付方式" width="120" />
|
|
|
+ <el-table-column prop="createdAt" label="创建时间" width="180" />
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { adminOrderList } from '@/api/product';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ orders: [],
|
|
|
+ statusFilter: ''
|
|
|
+ };
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.loadList();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadList() {
|
|
|
+ try {
|
|
|
+ const res = await adminOrderList({ status: this.statusFilter });
|
|
|
+ if (res.data && res.data.code === 200) {
|
|
|
+ this.orders = res.data.data || [];
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.$message.error('加载失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ statusLabel(s) {
|
|
|
+ const map = { pending: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消', refunded: '已退款' };
|
|
|
+ return map[s] || s;
|
|
|
+ },
|
|
|
+ statusTagType(s) {
|
|
|
+ const map = { pending: 'warning', paid: 'primary', completed: 'success', cancelled: 'info', refunded: 'danger' };
|
|
|
+ return map[s] || '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.order-manage {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 添加路由**
|
|
|
+
|
|
|
+在 `zxyj-web/src/router/index.js` 中添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+{
|
|
|
+ path: '/admin/order',
|
|
|
+ component: () => import('@/views/admin/OrderManage.vue'),
|
|
|
+ meta: { title: '订单管理', icon: 'list' }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 添加菜单项**
|
|
|
+
|
|
|
+在 `zxyj-web/src/views/Layout.vue` 菜单中"商品管理"附近添加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-menu-item index="/admin/order">
|
|
|
+ <i class="el-icon-list"></i>
|
|
|
+ <span slot="title">订单管理</span>
|
|
|
+</el-menu-item>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-web/src/views/admin/OrderManage.vue zxyj-web/src/router/index.js zxyj-web/src/views/Layout.vue
|
|
|
+git commit -m "feat(phase1): Web 端订单管理页面+路由+菜单"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T14: Web 端 — 系统配置
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `zxyj-web/src/views/admin/SysConfig.vue`
|
|
|
+- Modify: `zxyj-web/src/router/index.js`
|
|
|
+- Modify: `zxyj-web/src/views/Layout.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 src/views/admin/SysConfig.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="sys-config">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header">
|
|
|
+ <span>系统配置</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-table :data="configs" border style="width: 100%">
|
|
|
+ <el-table-column prop="configKey" label="配置键" min-width="200" />
|
|
|
+ <el-table-column prop="configValue" label="配置值" min-width="300">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-input v-model="scope.row.configValue" :disabled="!isEditing[scope.row.id]" size="small" />
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="description" label="说明" min-width="300" />
|
|
|
+ <el-table-column prop="updatedAt" label="更新时间" width="180" />
|
|
|
+ <el-table-column label="操作" width="200">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-button v-if="!isEditing[scope.row.id]" size="mini" @click="startEdit(scope.row)">编辑</el-button>
|
|
|
+ <template v-else>
|
|
|
+ <el-button size="mini" type="primary" @click="saveConfig(scope.row)">保存</el-button>
|
|
|
+ <el-button size="mini" @click="cancelEdit(scope.row)">取消</el-button>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { adminConfigList, adminConfigUpdate } from '@/api/product';
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ configs: [],
|
|
|
+ isEditing: {}
|
|
|
+ };
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.loadConfigs();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async loadConfigs() {
|
|
|
+ try {
|
|
|
+ const res = await adminConfigList({});
|
|
|
+ if (res.data && res.data.code === 200) {
|
|
|
+ this.configs = res.data.data || [];
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.$message.error('加载失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ startEdit(row) {
|
|
|
+ this.$set(this.isEditing, row.id, true);
|
|
|
+ },
|
|
|
+ cancelEdit(row) {
|
|
|
+ this.$set(this.isEditing, row.id, false);
|
|
|
+ this.loadConfigs();
|
|
|
+ },
|
|
|
+ async saveConfig(row) {
|
|
|
+ try {
|
|
|
+ const res = await adminConfigUpdate({
|
|
|
+ configKey: row.configKey,
|
|
|
+ configValue: row.configValue
|
|
|
+ });
|
|
|
+ if (res.data && res.data.code === 200) {
|
|
|
+ this.$message.success('保存成功');
|
|
|
+ this.$set(this.isEditing, row.id, false);
|
|
|
+ this.loadConfigs();
|
|
|
+ } else {
|
|
|
+ this.$message.error(res.data.message || '保存失败');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.$message.error('保存失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.sys-config {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 添加路由**
|
|
|
+
|
|
|
+在 `zxyj-web/src/router/index.js` 中添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+{
|
|
|
+ path: '/admin/config',
|
|
|
+ component: () => import('@/views/admin/SysConfig.vue'),
|
|
|
+ meta: { title: '系统配置', icon: 'setting' }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 添加菜单项**
|
|
|
+
|
|
|
+在 `zxyj-web/src/views/Layout.vue` 菜单中添加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-menu-item index="/admin/config">
|
|
|
+ <i class="el-icon-setting"></i>
|
|
|
+ <span slot="title">系统配置</span>
|
|
|
+</el-menu-item>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add zxyj-web/src/views/admin/SysConfig.vue zxyj-web/src/router/index.js zxyj-web/src/views/Layout.vue
|
|
|
+git commit -m "feat(phase1): Web 端系统配置页面+路由+菜单"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## T15: 端到端验证
|
|
|
+
|
|
|
+**Files:** 无(仅运行验证命令)
|
|
|
+
|
|
|
+- [ ] **Step 1: 后端编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `mvn: ok`(无错误)
|
|
|
+
|
|
|
+- [ ] **Step 2: 启动后端**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 杀掉旧进程(如有)
|
|
|
+pkill -f "zxyj-backend" 2>/dev/null || true
|
|
|
+sleep 2
|
|
|
+
|
|
|
+# 启动新进程
|
|
|
+cd /mnt/e/workspace/dan/zxyj/zxyj-backend && rtk mvn spring-boot:run > /tmp/zxyj-backend.log 2>&1 &
|
|
|
+echo "Started, PID: $!"
|
|
|
+
|
|
|
+# 等待启动完成
|
|
|
+sleep 30
|
|
|
+rtk grep "Started" /tmp/zxyj-backend.log
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 输出包含 "Started ZxyjApplication"
|
|
|
+
|
|
|
+- [ ] **Step 3: 测试商品列表 API**
|
|
|
+
|
|
|
+```bash
|
|
|
+curl -s -X POST http://localhost:8080/api/product/list \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -d '{"page":1,"size":10}' | rtk python3 -m json.tool
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 返回 `code: 200`,data.records 含 5 个种子商品
|
|
|
+
|
|
|
+- [ ] **Step 4: 测试系统配置 API**
|
|
|
+
|
|
|
+```bash
|
|
|
+curl -s -X POST http://localhost:8080/api/admin/config/list \
|
|
|
+ -H "Content-Type: application/json" -d '{}' | rtk python3 -m json.tool
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 返回 `code: 200`,data 含 8 条配置记录
|
|
|
+
|
|
|
+- [ ] **Step 5: 验证前端文件可解析**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 检查所有新增的 .vue 文件能被读出
|
|
|
+for f in \
|
|
|
+ /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/shop/index.vue \
|
|
|
+ /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/discover/index.vue \
|
|
|
+ /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/discover/product-detail.vue \
|
|
|
+ /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/shop/order-list.vue \
|
|
|
+ /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/shop/order-detail.vue \
|
|
|
+ /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/vendor/products.vue \
|
|
|
+ /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages/vendor/orders.vue
|
|
|
+do
|
|
|
+ rtk head -1 "$f" > /dev/null && echo "OK: $f" || echo "FAIL: $f"
|
|
|
+done
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 全部输出 "OK: ..."
|
|
|
+
|
|
|
+- [ ] **Step 6: 验证 pages.json 注册了所有新页面**
|
|
|
+
|
|
|
+```bash
|
|
|
+for p in \
|
|
|
+ "pages/discover/product-detail" \
|
|
|
+ "pages/shop/order-list" \
|
|
|
+ "pages/shop/order-detail" \
|
|
|
+ "pages/vendor/products" \
|
|
|
+ "pages/vendor/orders"
|
|
|
+do
|
|
|
+ rtk grep -q "\"$p\"" /mnt/e/workspace/dan/zxyj/zxyj-frontend/pages.json && echo "OK: $p" || echo "MISSING: $p"
|
|
|
+done
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 全部输出 "OK: ..."
|
|
|
+
|
|
|
+- [ ] **Step 7: 验证 Web 端路由**
|
|
|
+
|
|
|
+```bash
|
|
|
+for p in "ProductManage" "OrderManage" "SysConfig"; do
|
|
|
+ rtk grep -q "$p" /mnt/e/workspace/dan/zxyj/zxyj-web/src/router/index.js && echo "OK: $p" || echo "MISSING: $p"
|
|
|
+done
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 全部输出 "OK: ..."
|
|
|
+
|
|
|
+- [ ] **Step 8: 编写 Phase 1 完成报告**
|
|
|
+
|
|
|
+在 `docs/superpowers/plans/2026-06-04-phase1-completion-report.md` 写入:
|
|
|
+
|
|
|
+```markdown
|
|
|
+# Phase 1 完成报告
|
|
|
+
|
|
|
+> **日期:** 2026-06-04
|
|
|
+> **范围:** 溪福俱乐部 Phase 1 — 商品系统
|
|
|
+> **依赖:** Phase 0 角色模型 + TabBar + 服务商入驻
|
|
|
+
|
|
|
+## 交付清单
|
|
|
+
|
|
|
+### 后端 (10 新建 + 2 修改)
|
|
|
+- [x] `entity/Product.java` — 商品实体
|
|
|
+- [x] `entity/ProductOrder.java` — 订单实体
|
|
|
+- [x] `entity/SysConfig.java` — 系统配置实体
|
|
|
+- [x] `mapper/ProductMapper.java` / `ProductOrderMapper.java` / `SysConfigMapper.java`
|
|
|
+- [x] `dto/ProductDTO.java` / `ProductListQueryDTO.java`
|
|
|
+- [x] `dto/ProductOrderDTO.java` / `CreateProductOrderDTO.java`
|
|
|
+- [x] `dto/SysConfigDTO.java`
|
|
|
+- [x] `service/ProductService.java` — 商品 CRUD/审核/上下架/库存
|
|
|
+- [x] `service/ProductOrderService.java` — 订单创建/支付/取消/完成
|
|
|
+- [x] `service/SysConfigService.java` — 配置 CRUD
|
|
|
+- [x] `controller/product/ProductController.java`
|
|
|
+- [x] `controller/product/ProductOrderController.java`
|
|
|
+- [x] `controller/config/SysConfigController.java`
|
|
|
+- [x] `controller/admin/AdminController.java` — 新增 product/order 审核端点
|
|
|
+- [x] `config/DatabaseInitializer.java` — 3 张表 DDL + 商品+配置种子
|
|
|
+
|
|
|
+### 前端 (7 新建 + 1 修改)
|
|
|
+- [x] `pages/shop/index.vue` — 商城页(从占位 → 商品列表)
|
|
|
+- [x] `pages/discover/index.vue` — 发现页(从占位 → 活动+课程)
|
|
|
+- [x] `pages/discover/product-detail.vue` — 商品详情
|
|
|
+- [x] `pages/shop/order-list.vue` — 订单列表
|
|
|
+- [x] `pages/shop/order-detail.vue` — 订单详情
|
|
|
+- [x] `pages/vendor/products.vue` — 服务商商品管理
|
|
|
+- [x] `pages/vendor/orders.vue` — 服务商订单管理
|
|
|
+- [x] `pages/vendor/center.vue` — 添加商品/订单菜单项
|
|
|
+- [x] `utils/api.js` — 新增 19 个商品/订单/配置 API
|
|
|
+- [x] `pages.json` — 注册 5 个新页面
|
|
|
+
|
|
|
+### Web 管理端 (3 新建 + 2 修改)
|
|
|
+- [x] `api/product.js` — Web API 封装
|
|
|
+- [x] `views/admin/ProductManage.vue` — 商品管理
|
|
|
+- [x] `views/admin/OrderManage.vue` — 订单管理
|
|
|
+- [x] `views/admin/SysConfig.vue` — 系统配置
|
|
|
+- [x] `router/index.js` — 3 个新路由
|
|
|
+- [x] `views/Layout.vue` — 3 个菜单项
|
|
|
+
|
|
|
+## 测试结果
|
|
|
+
|
|
|
+- [x] 后端编译通过 (`mvn clean compile`)
|
|
|
+- [x] 后端启动成功 (Started ZxyjApplication)
|
|
|
+- [x] 商品列表 API 返回 5 条种子数据
|
|
|
+- [x] 系统配置 API 返回 8 条配置
|
|
|
+- [x] 前端所有新增页面文件可读
|
|
|
+- [x] pages.json 注册完整
|
|
|
+- [x] Web 端路由注册完整
|
|
|
+
|
|
|
+## 已知限制
|
|
|
+
|
|
|
+- 商品发布功能:Phase 1 仅提供 Web 端/管理端入口;小程序端 `onCreate` 为占位提示(待 Phase 2+ 完善编辑表单)
|
|
|
+- 真实微信支付:Phase 1+2 均使用测试模式(直接标记为已支付);Phase 3+ 接入真实支付
|
|
|
+- SKU 多规格:未实现(设计 Phase 0-2 暂不支持)
|
|
|
+
|
|
|
+## Phase 2 启动条件
|
|
|
+
|
|
|
+- [x] Product 列表可访问
|
|
|
+- [x] 商品已上架(on_shelf)
|
|
|
+- [x] 用户可购买(订单流程已通)
|
|
|
+- [ ] **Phase 2 计划待编写**
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 9: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /mnt/e/workspace/dan/zxyj && git add docs/superpowers/plans/2026-06-04-phase1-completion-report.md
|
|
|
+git commit -m "docs(phase1): Phase 1 商品系统完成报告"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 总结
|
|
|
+
|
|
|
+Phase 1 完成后:
|
|
|
+
|
|
|
+1. ✅ 用户可在小程序浏览/购买商品
|
|
|
+2. ✅ 服务商可发布商品(需审核)
|
|
|
+3. ✅ 管理员可在 Web 端审核商品/管理订单/配置系统参数
|
|
|
+4. ✅ 5 个种子商品 + 8 个系统配置已就位
|
|
|
+5. ✅ 30 个任务全部完成
|
|
|
+6. ✅ Phase 2 启动条件满足
|
|
|
+
|
|
|
+**下一步:编写 Phase 2 计划文档(在 Phase 1 验收通过后开始)**
|