2026-06-03-phase0-skeleton.md 39 KB

Phase 0: 骨架 — 角色模型 + TabBar + 服务商入驻

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: Extend User entity with vendor/service provider fields, add VendorController for vendor application flow, change TabBar from 首页/任务/心愿单/我的 to 首页/发现/商城/我的, create placeholder pages, add membership banner to homepage, and add vendor review page to Web admin.

Architecture: Phase 0 is the foundation — all subsequent phases depend on the role model extension and TabBar skeleton. Backend: add 4 fields to User + DatabaseInitializer migration + new VendorController/VendorService. Frontend: change pages.json TabBar + 3 new placeholder pages + update profile + update index. Web: add vendor review route + page.

Tech Stack: Java 1.8 + Spring Boot 2.7 + MyBatis-Plus + uni-app Vue 2 + Element UI

Spec reference: docs/superpowers/specs/2026-06-02-care-family-club-design.md (溪艾福 Care Family Club / CFC)

Key constraints:

  • All entities use java.util.Date and implements Serializable
  • All controllers use @RequestMapping("/api/...") + @PostMapping
  • DI via @Resource
  • No indexes, no constraints, no custom SQL (DDL in DatabaseInitializer only)
  • Vue 2 Options API, uni-app tags, rpx units, NO optional chaining ?.

Task 1: User entity — add vendor/service provider fields

Files:

  • Modify: zxyj-backend/src/main/java/com/zxyj/entity/User.java

  • [ ] Step 1: Add 4 fields to User.java

Add after the existing familyRole field (before createdAt):

    // 服务商类型: null=普通用户 / planner / activity_provider / product_supplier
    private String vendorType;

    // 服务商审核状态: null=未申请 / pending / approved / rejected
    private String vendorStatus;

    // 服务商审核拒绝原因
    private String vendorRejectReason;

    // 家庭管理员标记
    private Boolean isFamilyAdmin;
  • Step 2: Verify compilation

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS


Task 2: DatabaseInitializer — add vendor columns migration

Files:

  • Modify: zxyj-backend/src/main/java/com/zxyj/config/DatabaseInitializer.java

  • [ ] Step 1: Find the runMigrations() method and add ALTER TABLE statements

Search for private void runMigrations() in DatabaseInitializer.java. Add at the end of the migration SQL list:

// Phase 0: 溪福俱乐部 — 服务商字段
"ALTER TABLE users ADD COLUMN IF NOT EXISTS vendor_type VARCHAR(32) DEFAULT NULL COMMENT '服务商类型'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS vendor_status VARCHAR(16) DEFAULT NULL COMMENT '服务商审核状态'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS vendor_reject_reason VARCHAR(500) DEFAULT NULL COMMENT '审核拒绝原因'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_family_admin TINYINT DEFAULT 0 COMMENT '家庭管理员'",

If ADD COLUMN IF NOT EXISTS is not supported by the MySQL version (8.0 should support it), use the try-catch pattern that already exists in the file. Inspect existing migrations to see the pattern used.

  • Step 2: Add teacher→vendor data migration

At the end of runMigrations(), add data migration for existing teachers:

// Phase 0: 迁移已有规划师到服务商体系
jdbcTemplate.update(
    "UPDATE users SET vendor_type = 'planner', vendor_status = 'approved' WHERE teacher_status = 'approved' AND vendor_type IS NULL"
);
  • Step 3: Verify compilation

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS


Task 3: VendorController + VendorService (backend)

Files:

  • Create: zxyj-backend/src/main/java/com/zxyj/controller/vendor/VendorController.java
  • Create: zxyj-backend/src/main/java/com/zxyj/service/VendorService.java
  • Modify: zxyj-backend/src/main/java/com/zxyj/mapper/UserMapper.java (only if methods are missing, likely already has basic CRUD)

  • [ ] Step 1: Create VendorService

    package com.zxyj.service;
    
    import com.zxyj.common.Result;
    import com.zxyj.entity.User;
    import com.zxyj.mapper.UserMapper;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.Date;
    
    @Service
    public class VendorService {
    
    @Resource
    private UserMapper userMapper;
    
    /**
     * 提交服务商入驻申请
     */
    public Result apply(Long userId, String vendorType, String vendorInfo) {
        User user = userMapper.selectById(userId);
        if (user == null) {
            return Result.error("用户不存在");
        }
        if (user.getVendorStatus() != null && !"rejected".equals(user.getVendorStatus())) {
            return Result.error("已有入驻申请,请勿重复提交");
        }
        user.setVendorType(vendorType);
        user.setVendorStatus("pending");
        user.setVendorRejectReason(null);
        user.setUpdatedAt(new Date());
        userMapper.updateById(user);
        return Result.success("入驻申请已提交,请等待审核");
    }
    
    /**
     * 查询入驻审核状态
     */
    public Result getStatus(Long userId) {
        User user = userMapper.selectById(userId);
        if (user == null) {
            return Result.error("用户不存在");
        }
        java.util.Map<String, Object> data = new java.util.HashMap<>();
        data.put("vendorType", user.getVendorType());
        data.put("vendorStatus", user.getVendorStatus());
        data.put("vendorRejectReason", user.getVendorRejectReason());
        return Result.success(data);
    }
    
    /**
     * 获取/编辑服务商信息(仅供已通过服务商使用)
     */
    public Result getVendorInfo(Long userId) {
        User user = userMapper.selectById(userId);
        if (user == null) {
            return Result.error("用户不存在");
        }
        if (!"approved".equals(user.getVendorStatus())) {
            return Result.error("服务商资格未通过审核");
        }
        java.util.Map<String, Object> data = new java.util.HashMap<>();
        data.put("vendorType", user.getVendorType());
        data.put("vendorInfo", user.getVendorInfo());
        data.put("realName", user.getRealName());
        data.put("phone", user.getPhone());
        return Result.success(data);
    }
    }
    
  • [ ] Step 2: Create VendorController

    package com.zxyj.controller.vendor;
    
    import com.zxyj.common.Result;
    import com.zxyj.service.VendorService;
    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.Map;
    
    @RestController
    @RequestMapping("/api/vendor")
    public class VendorController {
    
    @Resource
    private VendorService vendorService;
    
    /**
     * 提交服务商入驻申请
     * POST /api/vendor/apply
     * Body: { "vendorType": "planner", "vendorInfo": "..." }
     */
    @PostMapping("/apply")
    public Result apply(@RequestBody Map<String, Object> params) {
        Long userId = getCurrentUserId();
        String vendorType = (String) params.get("vendorType");
        String vendorInfo = (String) params.getOrDefault("vendorInfo", "");
        return vendorService.apply(userId, vendorType, vendorInfo);
    }
    
    /**
     * 查询入驻审核状态
     * POST /api/vendor/status
     */
    @PostMapping("/status")
    public Result getStatus() {
        Long userId = getCurrentUserId();
        return vendorService.getStatus(userId);
    }
    
    /**
     * 获取服务商信息
     * POST /api/vendor/info
     */
    @PostMapping("/info")
    public Result getVendorInfo() {
        Long userId = getCurrentUserId();
        return vendorService.getVendorInfo(userId);
    }
    
    /**
     * 从请求头获取当前用户ID
     * 与现有AuthController中模式一致
     */
    private Long getCurrentUserId() {
        String userIdStr = org.springframework.web.context.request.RequestContextHolder
            .currentRequestAttributes()
            .getRequest()
            .getHeader("X-User-Id");
        return userIdStr != null ? Long.parseLong(userIdStr) : null;
    }
    }
    
  • [ ] Step 3: Verify compilation

Run: cd zxyj-backend && mvn clean compile -q Check: The new directory controller/vendor/ is picked up by Spring component scan. Verify no bean name conflicts.


Task 4: Admin vendor API — Web端审核服务商入驻

Files:

  • Modify: zxyj-backend/src/main/java/com/zxyj/controller/admin/AdminController.java

  • [ ] Step 1: Add admin vendor review endpoints to AdminController

Find the AdminController file. Add the following methods:

/**
 * 服务商入驻申请列表
 * POST /api/admin/vendor/list
 */
@PostMapping("/vendor/list")
public Result vendorList(@RequestBody Map<String, Object> params) {
    // 查询所有vendor_status非空的用户
    Integer page = (Integer) params.getOrDefault("page", 1);
    Integer size = (Integer) params.getOrDefault("size", 20);
    String status = (String) params.get("status"); // pending / approved / rejected / null=全部
    // 使用MyBatis-Plus QueryWrapper
    com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User> wrapper = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
    wrapper.isNotNull("vendor_type");
    if (status != null && !status.isEmpty()) {
        wrapper.eq("vendor_status", status);
    }
    wrapper.orderByDesc("updated_at");
    com.baomidou.mybatisplus.extension.plugins.pagination.Page<User> pageObj = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
    com.baomidou.mybatisplus.core.metadata.IPage<User> result = userMapper.selectPage(pageObj, wrapper);
    java.util.Map<String, Object> data = new java.util.HashMap<>();
    data.put("list", result.getRecords());
    data.put("total", result.getTotal());
    data.put("page", page);
    data.put("size", size);
    return Result.success(data);
}

/**
 * 审核服务商入驻
 * POST /api/admin/vendor/review
 * Body: { "userId": 1, "action": "approve" / "reject", "reason": "拒绝原因" }
 */
@PostMapping("/vendor/review")
public Result vendorReview(@RequestBody Map<String, Object> params) {
    Long userId = Long.valueOf(params.get("userId").toString());
    String action = (String) params.get("action");
    String reason = (String) params.getOrDefault("reason", "");
    User user = userMapper.selectById(userId);
    if (user == null) {
        return Result.error("用户不存在");
    }
    if ("approve".equals(action)) {
        user.setVendorStatus("approved");
        user.setVendorRejectReason(null);
    } else if ("reject".equals(action)) {
        user.setVendorStatus("rejected");
        user.setVendorRejectReason(reason);
    } else {
        return Result.error("无效的审核操作");
    }
    user.setUpdatedAt(new Date());
    userMapper.updateById(user);
    return Result.success("审核完成");
}

Add the required imports at the top:

import com.zxyj.entity.User;
import com.zxyj.mapper.UserMapper;
import java.util.Date;

And confirm @Resource private UserMapper userMapper; is present in the class.

  • Step 2: Verify compilation

Run: cd zxyj-backend && mvn clean compile -q Expected: BUILD SUCCESS


Task 5: Frontend API additions — vendor module

Files:

  • Modify: zxyj-frontend/utils/api.js

  • [ ] Step 1: Add vendor API calls to api.js

Find the end of file. Add:

// ===================== 溪福俱乐部 - 服务商模块 =====================

// 提交服务商入驻申请
export const vendorApply = (data) => {
  return request('/api/vendor/apply', 'POST', data)
}

// 查询入驻审核状态
export const vendorStatus = () => {
  return request('/api/vendor/status', 'POST', {})
}

// 获取服务商信息
export const vendorInfo = () => {
  return request('/api/vendor/info', 'POST', {})
}

Task 6: TabBar change — pages.json

Files:

  • Modify: zxyj-frontend/pages.json

  • [ ] Step 1: Change TabBar list and add new page registrations

Replace the tabBar.list with:

"tabBar": {
    "color": "#7A7E83",
    "selectedColor": "#FF6B6B",
    "borderStyle": "black",
    "backgroundColor": "#FFFFFF",
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页"
      },
      {
        "pagePath": "pages/discover/index",
        "text": "发现"
      },
      {
        "pagePath": "pages/shop/index",
        "text": "商城"
      },
      {
        "pagePath": "pages/profile/profile",
        "text": "我的"
      }
    ]
  }

Add new page registrations in the pages array (before the globalStyle section). Place after the last existing entry:

    // ===== 溪福俱乐部 Phase 0 =====
    {
      "path": "pages/discover/index",
      "style": {
        "navigationBarTitleText": "发现"
      }
    },
    {
      "path": "pages/shop/index",
      "style": {
        "navigationBarTitleText": "商城"
      }
    },
    {
      "path": "pages/vendor/apply",
      "style": {
        "navigationBarTitleText": "服务商入驻"
      }
    },
    {
      "path": "pages/vendor/center",
      "style": {
        "navigationBarTitleText": "服务商中心"
      }
    }

Task 7: Discover placeholder page

Files:

  • Create: zxyj-frontend/pages/discover/index.vue

  • [ ] Step 1: Create discover placeholder page

    <template>
    <view class="container">
    <view class="placeholder">
      <text class="icon">🔍</text>
      <text class="title">发现</text>
      <text class="desc">活动与课程即将上线,敬请期待</text>
    </view>
    </view>
    </template>
    
    <script>
    export default {
    data() {
    return {}
    },
    onLoad() {}
    }
    </script>
    
    <style>
    .container {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    background: #f8f8f8;
    }
    .placeholder {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 100rpx 40rpx;
    }
    .icon {
    font-size: 120rpx;
    margin-bottom: 30rpx;
    }
    .title {
    font-size: 40rpx;
    font-weight: bold;
    color: #333;
    margin-bottom: 20rpx;
    }
    .desc {
    font-size: 28rpx;
    color: #999;
    text-align: center;
    }
    </style>
    

Task 8: Shop placeholder page

Files:

  • Create: zxyj-frontend/pages/shop/index.vue

  • [ ] Step 1: Create shop placeholder page

    <template>
    <view class="container">
    <view class="placeholder">
      <text class="icon">🛒</text>
      <text class="title">商城</text>
      <text class="desc">商品与服务即将上线,敬请期待</text>
    </view>
    </view>
    </template>
    
    <script>
    export default {
    data() {
    return {}
    },
    onLoad() {}
    }
    </script>
    
    <style>
    .container {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    background: #f8f8f8;
    }
    .placeholder {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 100rpx 40rpx;
    }
    .icon {
    font-size: 120rpx;
    margin-bottom: 30rpx;
    }
    .title {
    font-size: 40rpx;
    font-weight: bold;
    color: #333;
    margin-bottom: 20rpx;
    }
    .desc {
    font-size: 28rpx;
    color: #999;
    text-align: center;
    }
    </style>
    

Task 9: Vendor apply page (frontend)

Files:

  • Create: zxyj-frontend/pages/vendor/apply.vue

  • [ ] Step 1: Create vendor application form page

    <template>
    <view class="container">
    <view class="header">
      <text class="title">服务商入驻</text>
      <text class="subtitle">选择服务商类型,提交入驻申请</text>
    </view>
    
    <view class="form">
      <view class="form-item">
        <text class="label">服务商类型</text>
        <picker mode="selector" :range="vendorTypes" range-key="label" @change="onTypeChange">
          <view class="picker">
            <text v-if="formData.vendorType">{{ getTypeLabel(formData.vendorType) }}</text>
            <text v-else class="placeholder">请选择服务商类型</text>
            <text class="arrow">›</text>
          </view>
        </picker>
      </view>
    
      <view class="form-item">
        <text class="label">资质说明</text>
        <textarea
          class="textarea"
          v-model="formData.vendorInfo"
          placeholder="请填写您的资质信息、服务经验等"
          maxlength="500"
        />
        <text class="counter">{{ formData.vendorInfo.length }}/500</text>
      </view>
    
      <button class="btn-submit" :disabled="!formData.vendorType || submitting" @click="handleSubmit">
        {{ submitting ? '提交中...' : '提交申请' }}
      </button>
    </view>
    </view>
    </template>
    
    <script>
    import { vendorApply } from '../../utils/api'
    
    export default {
    data() {
    return {
      vendorTypes: [
        { value: 'planner', label: '成长规划师' },
        { value: 'activity_provider', label: '活动提供商' },
        { value: 'product_supplier', label: '商品供应商' }
      ],
      formData: {
        vendorType: '',
        vendorInfo: ''
      },
      submitting: false
    }
    },
    methods: {
    onTypeChange(e) {
      this.formData.vendorType = this.vendorTypes[e.detail.value].value
    },
    getTypeLabel(value) {
      const item = this.vendorTypes.find(t => t.value === value)
      return item ? item.label : value
    },
    async handleSubmit() {
      this.submitting = true
      try {
        const res = await vendorApply({
          vendorType: this.formData.vendorType,
          vendorInfo: this.formData.vendorInfo
        })
        uni.showToast({ title: '提交成功', icon: 'success' })
        setTimeout(() => {
          uni.navigateBack()
        }, 1500)
      } catch (e) {
        // api.js already shows error toast
      } finally {
        this.submitting = false
      }
    }
    }
    }
    </script>
    
    <style>
    .container {
    padding: 30rpx;
    background: #f8f8f8;
    min-height: 100vh;
    }
    .header {
    text-align: center;
    padding: 40rpx 0;
    }
    .title {
    font-size: 40rpx;
    font-weight: bold;
    color: #333;
    }
    .subtitle {
    font-size: 26rpx;
    color: #999;
    margin-top: 10rpx;
    display: block;
    }
    .form {
    background: #fff;
    border-radius: 20rpx;
    padding: 30rpx;
    }
    .form-item {
    margin-bottom: 30rpx;
    }
    .label {
    font-size: 28rpx;
    color: #333;
    font-weight: bold;
    display: block;
    margin-bottom: 15rpx;
    }
    .picker {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20rpx 0;
    border-bottom: 2rpx solid #eee;
    }
    .placeholder {
    color: #ccc;
    }
    .arrow {
    color: #ccc;
    font-size: 36rpx;
    }
    .textarea {
    width: 100%;
    height: 200rpx;
    padding: 20rpx;
    border: 2rpx solid #eee;
    border-radius: 10rpx;
    font-size: 28rpx;
    box-sizing: border-box;
    }
    .counter {
    display: block;
    text-align: right;
    font-size: 24rpx;
    color: #ccc;
    margin-top: 10rpx;
    }
    .btn-submit {
    width: 100%;
    height: 88rpx;
    line-height: 88rpx;
    background: linear-gradient(135deg, #FF6B6B, #FF8E53);
    color: #fff;
    border-radius: 44rpx;
    font-size: 32rpx;
    margin-top: 40rpx;
    }
    .btn-submit[disabled] {
    opacity: 0.5;
    }
    </style>
    

Task 10: Vendor center page (frontend)

Files:

  • Create: zxyj-frontend/pages/vendor/center.vue

  • [ ] Step 1: Create vendor center page

    <template>
    <view class="container">
    <view class="header">
      <text class="icon">🏪</text>
      <text class="title">服务商中心</text>
    </view>
    
    <view class="info-card" v-if="vendorInfoData">
      <view class="info-row">
        <text class="info-label">服务商类型</text>
        <text class="info-value">{{ getTypeLabel(vendorInfoData.vendorType) }}</text>
      </view>
      <view class="info-row">
        <text class="info-label">真实姓名</text>
        <text class="info-value">{{ vendorInfoData.realName || '未设置' }}</text>
      </view>
      <view class="info-row">
        <text class="info-label">联系电话</text>
        <text class="info-value">{{ vendorInfoData.phone || '未设置' }}</text>
      </view>
    </view>
    
    <view class="menu-list">
      <view class="menu-item" @click="goToProductManage">
        <text>📦 商品管理</text>
        <text class="arrow">›</text>
      </view>
      <view class="menu-item" @click="goToOrderManage">
        <text>📋 订单管理</text>
        <text class="arrow">›</text>
      </view>
    </view>
    
    <view class="coming-soon">
      <text>更多功能即将上线...</text>
    </view>
    </view>
    </template>
    
    <script>
    import { vendorInfo } from '../../utils/api'
    
    export default {
    data() {
    return {
      vendorTypes: [
        { value: 'planner', label: '成长规划师' },
        { value: 'activity_provider', label: '活动提供商' },
        { value: 'product_supplier', label: '商品供应商' }
      ],
      vendorInfoData: null
    }
    },
    onLoad() {
    this.loadVendorInfo()
    },
    methods: {
    getTypeLabel(value) {
      const item = this.vendorTypes.find(t => t.value === value)
      return item ? item.label : value
    },
    async loadVendorInfo() {
      try {
        const res = await vendorInfo()
        this.vendorInfoData = res.data
      } catch (e) {
        // handled by api.js
      }
    },
    goToProductManage() {
      uni.showToast({ title: '即将上线', icon: 'none' })
    },
    goToOrderManage() {
      uni.showToast({ title: '即将上线', icon: 'none' })
    }
    }
    }
    </script>
    
    <style>
    .container {
    padding: 30rpx;
    background: #f8f8f8;
    min-height: 100vh;
    }
    .header {
    text-align: center;
    padding: 40rpx 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    }
    .icon {
    font-size: 100rpx;
    margin-bottom: 20rpx;
    }
    .title {
    font-size: 36rpx;
    font-weight: bold;
    color: #333;
    }
    .info-card {
    background: #fff;
    border-radius: 20rpx;
    padding: 30rpx;
    margin-bottom: 30rpx;
    }
    .info-row {
    display: flex;
    justify-content: space-between;
    padding: 15rpx 0;
    border-bottom: 2rpx solid #f5f5f5;
    }
    .info-row:last-child {
    border-bottom: none;
    }
    .info-label {
    font-size: 28rpx;
    color: #999;
    }
    .info-value {
    font-size: 28rpx;
    color: #333;
    }
    .menu-list {
    background: #fff;
    border-radius: 20rpx;
    }
    .menu-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 30rpx;
    border-bottom: 2rpx solid #f5f5f5;
    font-size: 30rpx;
    }
    .menu-item:last-child {
    border-bottom: none;
    }
    .arrow {
    color: #ccc;
    font-size: 36rpx;
    }
    .coming-soon {
    text-align: center;
    padding: 60rpx;
    color: #ccc;
    font-size: 26rpx;
    }
    </style>
    

Task 11: Profile page — add membership + vendor entries

Files:

  • Modify: zxyj-frontend/pages/profile/profile.vue

  • [ ] Step 1: Add membership entry and vendor entry after "积分记录" menu item

Find the 📊 积分记录 menu-item block. Add after it:

      <!-- 会员中心(溪艾福) -->
      <view class="menu-item" @click="goToMembership">
        <text>👑 会员中心</text>
        <text class="arrow">›</text>
      </view>
      <!-- 服务商中心(仅已通过服务商可见) -->
      <view class="menu-item" v-if="isVendor" @click="goToVendorCenter">
        <text>🏪 服务商中心</text>
        <text class="arrow">›</text>
      </view>
  • Step 2: Add isVendor data property and handler methods

In the data() function, add:

isVendor: false,

In the methods section, add:

    goToMembership() {
      uni.showToast({ title: '即将上线', icon: 'none' })
    },
    goToVendorCenter() {
      uni.navigateTo({ url: '/pages/vendor/center' })
    }
  • Step 3: Load vendor status on page show

In the onShow() lifecycle hook, add vendor status check:

    // 检查服务商状态
    this.checkVendorStatus()

Add the method:

    async checkVendorStatus() {
      try {
        const { vendorStatus } = require('../../utils/api')
        const res = await vendorStatus()
        this.isVendor = res.data && res.data.vendorStatus === 'approved'
      } catch (e) {
        this.isVendor = false
      }
    },

Also add the import at top of <script> section:

import { vendorStatus } from '../../utils/api'

Task 12: Homepage 五行能量沙盘(五角星)+ membership banner

Files:

  • Modify: zxyj-frontend/pages/index/parent-index.vue
  • Modify: zxyj-frontend/pages/index/child-index.vue

  • [ ] Step 1: Add 五行能量沙盘 (pentagram) component after welcome section

Read the file first to find the exact insertion point. Add the sandbox component at the top of the content area:

    <!-- 五行能量沙盘(溪艾福) -->
    <view class="wuxing-sandbox">
      <view class="sandbox-header">
        <text class="sandbox-title">五行能量沙盘</text>
        <text class="sandbox-badge">综合成长力 0%</text>
      </view>
      <view class="sandbox-star">
        <!-- 五角星SVG/CSS布局 -->
        <view class="star-point star-point-top" @click="goToDomain('mind')">
          <text class="point-icon">🔥</text>
          <text class="point-label">心·火</text>
          <text class="point-status inactive">待激活</text>
        </view>
        <view class="star-point star-point-left" @click="goToDomain('action')">
          <text class="point-icon">🌿</text>
          <text class="point-label">行·木</text>
          <text class="point-status inactive">待激活</text>
        </view>
        <view class="star-point star-point-right" @click="goToDomain('wealth')">
          <text class="point-icon">💧</text>
          <text class="point-label">富·水</text>
          <text class="point-status inactive">待激活</text>
        </view>
        <view class="star-point star-point-bl" @click="goToDomain('wisdom')">
          <text class="point-icon">⚔️</text>
          <text class="point-label">智·金</text>
          <text class="point-status inactive">待激活</text>
        </view>
        <view class="star-point star-point-br" @click="goToDomain('body')">
          <text class="point-icon">🌏</text>
          <text class="point-label">身·土</text>
          <text class="point-status inactive">待激活</text>
        </view>
        <!-- 中心徽章 -->
        <view class="star-center">
          <text class="center-text">五行平衡</text>
          <text class="center-sub">共同成长</text>
        </view>
      </view>
      <!-- 相生指引 -->
      <view class="shenke-hint">
        <text class="hint-text">🌱 完成菌群检测,点亮「身·土」</text>
      </view>
      <!-- 维度状态条 -->
      <view class="dimension-bar">
        <view class="dim-item inactive">🌿行</view>
        <text class="dim-arrow">→</text>
        <view class="dim-item inactive">🔥心</view>
        <text class="dim-arrow">→</text>
        <view class="dim-item inactive">🌏身</view>
        <text class="dim-arrow">→</text>
        <view class="dim-item inactive">⚔️智</view>
        <text class="dim-arrow">→</text>
        <view class="dim-item inactive">💧富</view>
      </view>
    </view>

    <!-- 会员Banner(溪艾福) -->
    <view class="membership-banner" @click="goToMembership">
      <view class="banner-content">
        <text class="banner-icon">👑</text>
        <view class="banner-text">
          <text class="banner-title">{{ membershipText }}</text>
          <text class="banner-subtitle">点击查看会员权益</text>
        </view>
        <text class="banner-arrow">›</text>
      </view>
    </view>

Note: The pentagram layout uses position: absolute/fixed inside a relative container. The five star-point-* classes position each point according to a regular pentagram. Use display: flex; justify-content: center; for horizontal centering of the star-center.

  • Step 2: Add data and methods

In data():

membershipText: '开通家庭会员 畅享全部权益',

In methods():

goToDomain(domain) {
  uni.showToast({ title: '即将上线', icon: 'none' })
},
goToMembership() {
  uni.showToast({ title: '即将上线', icon: 'none' })
},
  • [ ] Step 3: Add styles for 五行沙盘

    /* ===== 五行能量沙盘 ===== */
    .wuxing-sandbox {
    margin: 20rpx 30rpx;
    padding: 30rpx;
    background: linear-gradient(135deg, #1a1a2e, #16213e);
    border-radius: 24rpx;
    position: relative;
    overflow: hidden;
    }
    .sandbox-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 30rpx;
    }
    .sandbox-title {
    font-size: 28rpx;
    font-weight: bold;
    color: #FFD700;
    }
    .sandbox-badge {
    font-size: 22rpx;
    color: #aaa;
    background: rgba(255,255,255,0.1);
    padding: 6rpx 16rpx;
    border-radius: 20rpx;
    }
    .sandbox-star {
    position: relative;
    height: 400rpx;
    display: flex;
    justify-content: center;
    align-items: center;
    }
    .star-point {
    position: absolute;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 120rpx;
    }
    .star-point-top {
    top: 0;
    left: 50%;
    transform: translateX(-50%);
    }
    .star-point-left {
    top: 120rpx;
    left: 20rpx;
    }
    .star-point-right {
    top: 120rpx;
    right: 20rpx;
    }
    .star-point-bl {
    bottom: 40rpx;
    left: 60rpx;
    }
    .star-point-br {
    bottom: 40rpx;
    right: 60rpx;
    }
    .point-icon {
    font-size: 50rpx;
    margin-bottom: 6rpx;
    }
    .point-label {
    font-size: 22rpx;
    color: #fff;
    font-weight: bold;
    }
    .point-status {
    font-size: 18rpx;
    margin-top: 4rpx;
    }
    .point-status.inactive {
    color: #666;
    }
    .point-status.active {
    color: #4CAF50;
    }
    .star-center {
    width: 120rpx;
    height: 120rpx;
    background: radial-gradient(circle, rgba(255,215,0,0.2), rgba(255,215,0,0.05));
    border: 2rpx solid rgba(255,215,0,0.3);
    border-radius: 50%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    }
    .center-text {
    font-size: 22rpx;
    color: #FFD700;
    font-weight: bold;
    }
    .center-sub {
    font-size: 18rpx;
    color: #aaa;
    }
    .shenke-hint {
    text-align: center;
    margin: 20rpx 0;
    }
    .hint-text {
    font-size: 24rpx;
    color: #FFD700;
    opacity: 0.8;
    }
    .dimension-bar {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 8rpx;
    }
    .dim-item {
    padding: 6rpx 16rpx;
    border-radius: 20rpx;
    font-size: 22rpx;
    }
    .dim-item.inactive {
    background: rgba(255,255,255,0.1);
    color: #666;
    }
    .dim-item.active {
    background: rgba(76,175,80,0.3);
    color: #4CAF50;
    }
    .dim-arrow {
    color: #444;
    font-size: 20rpx;
    }
    
    /* ===== 会员Banner ===== */
    .membership-banner {
    margin: 20rpx 30rpx;
    padding: 30rpx;
    background: linear-gradient(135deg, #FFD700, #FFA500);
    border-radius: 20rpx;
    box-shadow: 0 4rpx 20rpx rgba(255, 165, 0, 0.3);
    }
    .banner-content {
    display: flex;
    align-items: center;
    }
    .banner-icon {
    font-size: 60rpx;
    margin-right: 20rpx;
    }
    .banner-text {
    flex: 1;
    }
    .banner-title {
    font-size: 30rpx;
    font-weight: bold;
    color: #8B4513;
    display: block;
    }
    .banner-subtitle {
    font-size: 24rpx;
    color: #A0522D;
    margin-top: 6rpx;
    display: block;
    }
    .banner-arrow {
    font-size: 40rpx;
    color: #8B4513;
    }
    
  • [ ] Step 4: Same change in child-index.vue

Read the file and replicate the same 五行沙盘 + membership banner insertion in the same position. The child version can simplify or remove the membership banner (children don't buy memberships), but keep the 五行沙盘 with child-appropriate guidance text.


Task 13: Web admin — vendor review page

Files:

  • Create: zxyj-web/src/views/admin/VendorReview.vue
  • Modify: zxyj-web/src/router/index.js
  • Create: zxyj-web/src/api/vendor.js

  • [ ] Step 1: Create vendor API file

    // zxyj-web/src/api/vendor.js
    import request from '@/utils/request'
    
    // 服务商入驻申请列表
    export function getVendorList(params) {
    return request({
    url: '/api/admin/vendor/list',
    method: 'post',
    data: params
    })
    }
    
    // 审核服务商入驻
    export function reviewVendor(data) {
    return request({
    url: '/api/admin/vendor/review',
    method: 'post',
    data: data
    })
    }
    

Check zxyj-web/src/utils/request.js to confirm the request wrapper pattern matches.

  • [ ] Step 2: Create VendorReview.vue

    <template>
    <div class="vendor-review">
    <div class="header">
      <h2>服务商入驻审核</h2>
      <el-select v-model="statusFilter" placeholder="审核状态" @change="loadList" style="width: 150px;">
        <el-option label="全部" value="" />
        <el-option label="待审核" value="pending" />
        <el-option label="已通过" value="approved" />
        <el-option label="已拒绝" value="rejected" />
      </el-select>
    </div>
    
    <el-table :data="list" v-loading="loading" border stripe>
      <el-table-column prop="id" label="用户ID" width="80" />
      <el-table-column prop="nickname" label="昵称" width="120" />
      <el-table-column prop="realName" label="真实姓名" width="120" />
      <el-table-column prop="phone" label="手机号" width="140" />
      <el-table-column label="服务商类型" width="140">
        <template slot-scope="{ row }">
          {{ vendorTypeLabel(row.vendorType) }}
        </template>
      </el-table-column>
      <el-table-column prop="vendorInfo" label="资质说明" min-width="200" show-overflow-tooltip />
      <el-table-column label="审核状态" width="100">
        <template slot-scope="{ row }">
          <el-tag :type="statusType(row.vendorStatus)">{{ statusLabel(row.vendorStatus) }}</el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="vendorRejectReason" label="拒绝原因" width="150" show-overflow-tooltip />
      <el-table-column label="操作" width="200" fixed="right">
        <template slot-scope="{ row }">
          <el-button v-if="row.vendorStatus === 'pending'" type="success" size="small" @click="handleReview(row, 'approve')">通过</el-button>
          <el-button v-if="row.vendorStatus === 'pending'" type="danger" size="small" @click="showRejectDialog(row)">拒绝</el-button>
          <span v-else class="no-op">—</span>
        </template>
      </el-table-column>
    </el-table>
    
    <el-pagination
      @size-change="onPageChange"
      @current-change="onPageChange"
      :current-page="page"
      :page-size="size"
      :total="total"
      layout="total, prev, pager, next"
      style="margin-top: 20px; text-align: right;"
    />
    
    <!-- 拒绝弹窗 -->
    <el-dialog title="填写拒绝原因" :visible.sync="rejectDialogVisible" width="400px">
      <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
      <span slot="footer">
        <el-button @click="rejectDialogVisible = false">取消</el-button>
        <el-button type="danger" @click="confirmReject">确认拒绝</el-button>
      </span>
    </el-dialog>
    </div>
    </template>
    
    <script>
    import { getVendorList, reviewVendor } from '@/api/vendor'
    
    export default {
    data() {
    return {
      list: [],
      loading: false,
      page: 1,
      size: 20,
      total: 0,
      statusFilter: 'pending',
      rejectDialogVisible: false,
      rejectTarget: null,
      rejectReason: ''
    }
    },
    mounted() {
    this.loadList()
    },
    methods: {
    vendorTypeLabel(type) {
      const map = { planner: '成长规划师', activity_provider: '活动提供商', product_supplier: '商品供应商' }
      return map[type] || type
    },
    statusType(status) {
      const map = { pending: 'warning', approved: 'success', rejected: 'danger' }
      return map[status] || 'info'
    },
    statusLabel(status) {
      const map = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
      return map[status] || status
    },
    async loadList() {
      this.loading = true
      try {
        const res = await getVendorList({ page: this.page, size: this.size, status: this.statusFilter })
        this.list = res.data.list || []
        this.total = res.data.total || 0
      } catch (e) {
        this.$message.error('加载失败')
      } finally {
        this.loading = false
      }
    },
    onPageChange(val) {
      this.page = val
      this.loadList()
    },
    async handleReview(row, action) {
      try {
        await reviewVendor({ userId: row.id, action })
        this.$message.success('审核完成')
        this.loadList()
      } catch (e) {
        this.$message.error('操作失败')
      }
    },
    showRejectDialog(row) {
      this.rejectTarget = row
      this.rejectReason = ''
      this.rejectDialogVisible = true
    },
    async confirmReject() {
      if (!this.rejectReason) {
        this.$message.warning('请填写拒绝原因')
        return
      }
      await this.handleReview(this.rejectTarget, 'reject')
      this.rejectDialogVisible = false
    }
    }
    }
    </script>
    
    <style scoped>
    .header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
    }
    .no-op {
    color: #ccc;
    }
    </style>
    
  • [ ] Step 3: Add route in router/index.js

Add the import and route at the end of the children array (before the closing ]):

      {
        path: 'vendor-review',
        name: 'VendorReview',
        component: () => import('@/views/admin/VendorReview.vue'),
        meta: { title: '服务商审核' }
      },

Add 'VendorReview' to the adminRoutes array in the route guard section:

    'ServiceTypes', 'ServiceContents', 'PackageTemplates',
    'AssessmentAdmin', 'VendorReview'

Task 14: Web admin — add sidebar menu for vendor review

Files:

  • Modify: zxyj-web/src/views/Layout.vue

  • [ ] Step 1: Add sidebar menu item

Read Layout.vue first. Find the admin menu section (likely near other admin menu items like 服务类型管理). Add:

<el-menu-item index="/vendor-review">
  <i class="el-icon-s-check"></i>
  <span slot="title">服务商审核</span>
</el-menu-item>

Verify the existing pattern for menu items by reading the file first.


Self-review

Run this after completing all tasks:

  1. Spec coverage: Does Phase 0 deliver role model expansion (vendorType/vendorStatus/isFamilyAdmin), TabBar change, vendor apply/status/info endpoints, discover/shop placeholders, profile updates, membership banner, Web admin review? ✅ Yes — Tasks 1-14 cover all.

  2. Placeholder scan: No "TBD", "TODO", "implement later" in code blocks. All code is complete.

  3. Type consistency: vendorType (String), vendorStatus (String), isFamilyAdmin (Boolean) — consistent across all tasks. VendorService.apply returns Result. VendorController endpoints use @PostMapping. All consistent.

  4. Dependencies: Tasks 1-2 must precede Task 3 (User entity + DB migration before VendorService). Task 5 (pages.json) must precede Tasks 7-8 (pages must be declared before they can be navigated to). Tasks 9-10 (vendor pages) depend on Task 4 (API calls). Task 11 (profile) depends on Task 4. Task 14 (sidebar) depends on Task 13.