2026-07-30-service-role-apply.md 44 KB

服务角色统一申请 — 实施计划

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: 在"我的"页面增加统一的服务角色申请入口,支持多选申请规划师/营养师/管家/服务商/文章管理员/活动提供商,上传身份证和资质证书,填写联系地址,提交后台审核。

Architecture: 新建统一 service_role_applications 表,一张表涵盖所有角色申请。前端统一申请页一次性提交,后端审核通过后按角色分别同步到现有各角色系统(Guide/User.butlerStatus/User.nutritionistStatus/User.vendorStatus 等)。

Tech Stack: Spring Boot 2.7.18 + MyBatis-Plus / uni-app Vue 2 小程序 / Vue 2 + Element UI Web管理端

Global Constraints

  • 所有接口使用 @PostMapping,禁止 @GetMapping/@PutMapping/@DeleteMapping
  • 响应统一使用 Result<T> (code/message/data)
  • DI 使用 @Resource 代替 @Autowired
  • 小程序禁止可选链 ?.(用 && 替代)、禁止 CSS Grid(用 flexbox)
  • DI 字段名必须与类型默认 Bean Name 一致
  • 新表名使用 service_role_applications
  • 迁移编号从 111 开始(当前最新迁移 110)
  • 所有迁移必须幂等(try-catch 忽略已存在)

文件结构

新建文件

文件 用途
cfc-backend/.../entity/ServiceRoleApplication.java 实体类
cfc-backend/.../mapper/ServiceRoleApplicationMapper.java Mapper 接口
cfc-backend/.../dto/ServiceRoleApplyDTO.java 申请提交 DTO
cfc-backend/.../service/ServiceRoleApplicationService.java Service 接口
cfc-backend/.../service/impl/ServiceRoleApplicationServiceImpl.java Service 实现
cfc-backend/.../controller/ServiceRoleApplicationController.java Controller
cfc-frontend/pages/profile/service-role-apply.vue 统一申请页
cfc-web/src/views/service/ServiceRoleApplications.vue 管理端审核页

修改文件

文件 修改内容
cfc-backend/.../entity/User.java 添加 articleAdminStatus、activityProviderStatus 字段
cfc-backend/.../config/DatabaseInitializer.java 添加迁移 111-113
cfc-backend/src/main/resources/schema.sql 添加 service_role_applications 表 + User 表新字段
cfc-frontend/pages/profile/components/ProfileMenu.vue 添加"服务角色申请"菜单项
cfc-frontend/utils/api.js 新增 API 函数
cfc-frontend/pages.json 注册新页面到 profile 子包
cfc-web/src/router/index.js 添加审核页面路由

Task 1: 数据库迁移 — 建表 & User 加字段

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (追加迁移 111-113)
  • Modify: cfc-backend/src/main/resources/schema.sql

Interfaces:

  • Consumes: (none)
  • Produces: service_role_applications 表存在,User 表有 article_admin_status 和 activity_provider_status 列

  • [ ] Step 1: 在 schema.sql 追加新表定义

在 schema.sql 末尾追加:

-- ============================================================
-- 服务角色申请表
-- ============================================================
CREATE TABLE IF NOT EXISTS `service_role_applications` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL COMMENT '申请人ID',
  `roles` varchar(255) NOT NULL COMMENT '申请角色,JSON数组 e.g. ["planner","nutritionist"]',
  `id_card` varchar(18) DEFAULT '' COMMENT '身份证号',
  `id_card_images` text COMMENT '身份证照片,JSON数组[正面URL,反面URL]',
  `certificate_images` text COMMENT '资质证书照片,JSON数组',
  `province` varchar(50) DEFAULT '' COMMENT '省',
  `city` varchar(50) DEFAULT '' COMMENT '市',
  `district` varchar(50) DEFAULT '' COMMENT '区',
  `street` varchar(50) DEFAULT '' COMMENT '街道',
  `address_detail` varchar(200) DEFAULT '' COMMENT '详细地址',
  `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0-pending, 1-approved, 2-rejected',
  `reject_reason` varchar(500) DEFAULT '' COMMENT '驳回原因',
  `reviewed_by` int(11) DEFAULT NULL COMMENT '审核人ID',
  `reviewed_at` datetime DEFAULT NULL COMMENT '审核时间',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表';

在 users 表的 CREATE TABLE 语句中(或表定义注释区)添加字段注释。schema.sql 中 users 表定义在 -- nutritionist_status 注释附近追加:

-- ALTER TABLE users ADD COLUMN article_admin_status VARCHAR(20) DEFAULT NULL COMMENT '文章管理员状态: pending/approved/rejected';
-- ALTER TABLE users ADD COLUMN activity_provider_status VARCHAR(20) DEFAULT NULL COMMENT '活动提供商状态: pending/approved/rejected';
  • Step 2: 在 DatabaseInitializer 添加迁移 111-113

在 runMigrations() 方法末尾(最后一个 // 迁移 之后)追加:

// 迁移111: 创建 service_role_applications 表(服务角色统一申请)
try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS service_role_applications (" +
            "id int(11) NOT NULL AUTO_INCREMENT, " +
            "user_id int(11) NOT NULL COMMENT '申请人ID', " +
            "roles varchar(255) NOT NULL COMMENT '申请角色,JSON数组', " +
            "id_card varchar(18) DEFAULT '' COMMENT '身份证号', " +
            "id_card_images text COMMENT '身份证照片,JSON数组', " +
            "certificate_images text COMMENT '资质证书照片,JSON数组', " +
            "province varchar(50) DEFAULT '' COMMENT '省', " +
            "city varchar(50) DEFAULT '' COMMENT '市', " +
            "district varchar(50) DEFAULT '' COMMENT '区', " +
            "street varchar(50) DEFAULT '' COMMENT '街道', " +
            "address_detail varchar(200) DEFAULT '' COMMENT '详细地址', " +
            "status tinyint(4) NOT NULL DEFAULT 0 COMMENT '0-pending 1-approved 2-rejected', " +
            "reject_reason varchar(500) DEFAULT '' COMMENT '驳回原因', " +
            "reviewed_by int(11) DEFAULT NULL COMMENT '审核人ID', " +
            "reviewed_at datetime DEFAULT NULL COMMENT '审核时间', " +
            "created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
            "updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
            "PRIMARY KEY (id), " +
            "KEY idx_user_id (user_id), " +
            "KEY idx_status (status)" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务角色申请表'");
    log.info("已创建service_role_applications表");
} catch (Exception e) {
    // 表已存在,忽略错误
}

// 迁移112: users表添加article_admin_status字段(文章管理员状态)
try {
    jdbcTemplate.execute("ALTER TABLE users ADD COLUMN article_admin_status VARCHAR(20) DEFAULT NULL COMMENT '文章管理员状态: pending/approved/rejected'");
    log.info("已添加article_admin_status列到users表");
} catch (Exception e) {
    // 列已存在,忽略错误
}

// 迁移113: users表添加activity_provider_status字段(活动提供商状态)
try {
    jdbcTemplate.execute("ALTER TABLE users ADD COLUMN activity_provider_status VARCHAR(20) DEFAULT NULL COMMENT '活动提供商状态: pending/approved/rejected'");
    log.info("已添加activity_provider_status列到users表");
} catch (Exception e) {
    // 列已存在,忽略错误
}
  • [ ] Step 3: 编译验证

    cd cfc-backend && mvn clean compile
    

Expected: BUILD SUCCESS

  • [ ] Step 4: 提交

    git add cfc-backend/src/main/resources/schema.sql cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
    git commit -m "feat(db): add service_role_applications table and User status fields"
    

Task 2: 后端 Entity + Mapper + DTO

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceRoleApplication.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/ServiceRoleApplicationMapper.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/ServiceRoleApplyDTO.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

Interfaces:

  • Consumes: service_role_applications 表 (Task 1)
  • Produces: ServiceRoleApplication entity, ServiceRoleApplicationMapper, ServiceRoleApplyDTO, updated User entity

  • [ ] Step 1: 创建 ServiceRoleApplication 实体

cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceRoleApplication.java:

package com.etotem.cfc.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@TableName("service_role_applications")
public class ServiceRoleApplication {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long userId;
    private String roles;
    private String idCard;
    private String idCardImages;
    private String certificateImages;
    private String province;
    private String city;
    private String district;
    private String street;
    private String addressDetail;
    private Integer status;
    private String rejectReason;
    private Long reviewedBy;
    private LocalDateTime reviewedAt;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
  • Step 2: 创建 Mapper 接口

cfc-backend/src/main/java/com/etotem/cfc/mapper/ServiceRoleApplicationMapper.java:

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.ServiceRoleApplication;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface ServiceRoleApplicationMapper extends BaseMapper<ServiceRoleApplication> {
}
  • Step 3: 创建 DTO

cfc-backend/src/main/java/com/etotem/cfc/dto/ServiceRoleApplyDTO.java:

package com.etotem.cfc.dto;

import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;

@Data
public class ServiceRoleApplyDTO {
    @NotEmpty(message = "至少选择一个角色")
    private List<String> roles;
    private String idCard;
    private List<String> idCardImages;
    private List<String> certificateImages;
    private String province;
    private String city;
    private String district;
    private String street;
    private String addressDetail;
}
  • Step 4: 修改 User 实体添加新字段

在 User.java 中添加两个新字段(放在 nutritionistStatus 附近):

/** 文章管理员状态: pending/approved/rejected */
private String articleAdminStatus;

/** 活动提供商状态: pending/approved/rejected */
private String activityProviderStatus;
  • [ ] Step 5: 编译验证

    cd cfc-backend && mvn clean compile
    

Expected: BUILD SUCCESS

  • [ ] Step 6: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceRoleApplication.java \
       cfc-backend/src/main/java/com/etotem/cfc/mapper/ServiceRoleApplicationMapper.java \
       cfc-backend/src/main/java/com/etotem/cfc/dto/ServiceRoleApplyDTO.java \
       cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
    git commit -m "feat(backend): add ServiceRoleApplication entity, mapper, DTO + User fields"
    

Task 3: 后端 Service 接口 + 实现

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java

Interfaces:

  • Consumes: ServiceRoleApplication entity, ServiceRoleApplicationMapper, ServiceRoleApplyDTO (Task 2)
  • Produces: ServiceRoleApplicationService with submit(), getMyApplication(), approve(), reject() methods

  • [ ] Step 1: 创建 Service 接口

cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java:

package com.etotem.cfc.service;

import com.etotem.cfc.common.Result;
import com.etotem.cfc.dto.ServiceRoleApplyDTO;

public interface ServiceRoleApplicationService {
    Result submit(Long userId, ServiceRoleApplyDTO dto);
    Result getMyApplication(Long userId);
    Result approve(Long id, Long reviewerId);
    Result reject(Long id, String reason, Long reviewerId);
}
  • Step 2: 创建 Service 实现

cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java:

package com.etotem.cfc.service.impl;

import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.dto.ServiceRoleApplyDTO;
import com.etotem.cfc.entity.*;
import com.etotem.cfc.mapper.*;
import com.etotem.cfc.service.ServiceRoleApplicationService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;

@Service
public class ServiceRoleApplicationServiceImpl
        extends ServiceImpl<ServiceRoleApplicationMapper, ServiceRoleApplication>
        implements ServiceRoleApplicationService {

    @Resource
    private UserMapper userMapper;

    @Resource
    private GuideMapper guideMapper;

    @Resource
    private ButlerProfileMapper butlerProfileMapper;

    @Override
    public Result submit(Long userId, ServiceRoleApplyDTO dto) {
        // 检查是否已有 pending 申请
        ServiceRoleApplication existing = this.getOne(
                new LambdaQueryWrapper<ServiceRoleApplication>()
                        .eq(ServiceRoleApplication::getUserId, userId)
                        .eq(ServiceRoleApplication::getStatus, 0));
        if (existing != null) {
            return Result.error("您已提交过申请,请等待审核");
        }

        ServiceRoleApplication app = new ServiceRoleApplication();
        app.setUserId(userId);
        app.setRoles(JSON.toJSONString(dto.getRoles()));
        app.setIdCard(dto.getIdCard());
        app.setIdCardImages(dto.getIdCardImages() != null ? JSON.toJSONString(dto.getIdCardImages()) : null);
        app.setCertificateImages(dto.getCertificateImages() != null ? JSON.toJSONString(dto.getCertificateImages()) : null);
        app.setProvince(dto.getProvince());
        app.setCity(dto.getCity());
        app.setDistrict(dto.getDistrict());
        app.setStreet(dto.getStreet());
        app.setAddressDetail(dto.getAddressDetail());
        app.setStatus(0); // pending
        this.save(app);
        return Result.success("申请提交成功,请等待审核");
    }

    @Override
    public Result getMyApplication(Long userId) {
        ServiceRoleApplication app = this.getOne(
                new LambdaQueryWrapper<ServiceRoleApplication>()
                        .eq(ServiceRoleApplication::getUserId, userId)
                        .orderByDesc(ServiceRoleApplication::getCreatedAt)
                        .last("LIMIT 1"));
        return Result.success(app);
    }

    @Override
    @Transactional
    public Result approve(Long id, Long reviewerId) {
        ServiceRoleApplication app = this.getById(id);
        if (app == null) {
            return Result.error("申请记录不存在");
        }
        if (app.getStatus() != 0) {
            return Result.error("该申请已处理");
        }

        app.setStatus(1); // approved
        app.setReviewedBy(reviewerId);
        app.setReviewedAt(LocalDateTime.now());
        this.updateById(app);

        // 同步各角色状态
        List<String> roles = JSON.parseArray(app.getRoles(), String.class);
        User user = userMapper.selectById(app.getUserId());

        for (String role : roles) {
            switch (role) {
                case "planner":
                    // 创建 Guide 记录
                    Guide guide = new Guide();
                    guide.setUserId(app.getUserId());
                    guide.setStatus("active");
                    guide.setProvince(app.getProvince());
                    guide.setCity(app.getCity());
                    guide.setDistrict(app.getDistrict());
                    guide.setStreet(app.getStreet());
                    guideMapper.insert(guide);
                    break;
                case "nutritionist":
                    user.setNutritionistStatus("approved");
                    break;
                case "butler":
                    user.setButlerStatus("approved");
                    // 创建 ButlerProfile
                    ButlerProfile bp = new ButlerProfile();
                    bp.setUserId(app.getUserId());
                    bp.setStatus("active");
                    butlerProfileMapper.insert(bp);
                    break;
                case "vendor":
                    user.setVendorStatus("approved");
                    break;
                case "article_admin":
                    user.setArticleAdminStatus("approved");
                    break;
                case "activity_provider":
                    user.setActivityProviderStatus("approved");
                    break;
            }
        }
        userMapper.updateById(user);

        return Result.success("审核通过");
    }

    @Override
    @Transactional
    public Result reject(Long id, String reason, Long reviewerId) {
        ServiceRoleApplication app = this.getById(id);
        if (app == null) {
            return Result.error("申请记录不存在");
        }
        if (app.getStatus() != 0) {
            return Result.error("该申请已处理");
        }

        app.setStatus(2); // rejected
        app.setRejectReason(reason);
        app.setReviewedBy(reviewerId);
        app.setReviewedAt(LocalDateTime.now());
        this.updateById(app);
        return Result.success("已驳回");
    }
}
  • [ ] Step 3: 编译验证

    cd cfc-backend && mvn clean compile
    

Expected: BUILD SUCCESS

  • [ ] Step 4: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.java \
       cfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.java
    git commit -m "feat(backend): add ServiceRoleApplicationService with submit/approve/reject"
    

Task 4: 后端 Controller + 图片上传

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java

Interfaces:

  • Consumes: ServiceRoleApplicationService (Task 3)
  • Produces: POST /api/service-role/apply, POST /api/service-role/my-apply, POST /api/service-role/upload-image

  • [ ] Step 1: 创建 Controller

cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java:

package com.etotem.cfc.controller;

import com.etotem.cfc.common.Result;
import com.etotem.cfc.dto.ServiceRoleApplyDTO;
import com.etotem.cfc.service.ServiceRoleApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import javax.validation.Valid;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;

@Tag(name = "服务角色申请", description = "统一服务角色申请入口")
@RestController
@RequestMapping("/api/service-role")
public class ServiceRoleApplicationController {

    @Resource
    private ServiceRoleApplicationService serviceRoleApplicationService;

    @Value("${upload.path:/data/uploads}")
    private String uploadPath;

    @PostMapping("/apply")
    @Operation(summary = "提交服务角色申请")
    public Result apply(@RequestAttribute("userId") Long userId,
                        @Valid @RequestBody ServiceRoleApplyDTO dto) {
        return serviceRoleApplicationService.submit(userId, dto);
    }

    @PostMapping("/my-apply")
    @Operation(summary = "查询我的申请记录")
    public Result myApply(@RequestAttribute("userId") Long userId) {
        return serviceRoleApplicationService.getMyApplication(userId);
    }

    @PostMapping("/upload-image")
    @Operation(summary = "上传图片(身份证/资质证书)")
    public Result uploadImage(@RequestAttribute("userId") Long userId,
                              @RequestParam("file") MultipartFile file) {
        if (file.isEmpty()) {
            return Result.error("请选择文件");
        }
        try {
            String dir = uploadPath + "/service-role/" + userId;
            File dirFile = new File(dir);
            if (!dirFile.exists()) {
                dirFile.mkdirs();
            }
            String ext = file.getOriginalFilename();
            ext = ext != null && ext.contains(".") ? ext.substring(ext.lastIndexOf(".")) : ".jpg";
            String fileName = UUID.randomUUID().toString() + ext;
            File dest = new File(dirFile, fileName);
            file.transferTo(dest);
            String url = "/uploads/service-role/" + userId + "/" + fileName;
            return Result.success(Map.of("url", url));
        } catch (IOException e) {
            return Result.error("上传失败: " + e.getMessage());
        }
    }
}

Note: 检查路由重复:

grep -rn '@PostMapping\|@GetMapping\|@PutMapping\|@DeleteMapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep service-role

Expected: only /api/service-role/* routes, no duplicates.

  • [ ] Step 2: 编译验证

    cd cfc-backend && mvn clean compile
    

Expected: BUILD SUCCESS

  • [ ] Step 3: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java
    git commit -m "feat(backend): add ServiceRoleApplicationController with apply/upload endpoints"
    

Task 5: 前端 API 函数 + 页面注册

Files:

  • Modify: cfc-frontend/utils/api.js
  • Modify: cfc-frontend/pages.json

Interfaces:

  • Consumes: Backend endpoints (Task 4)
  • Produces: API functions submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage; page registered in pages.json

  • [ ] Step 1: 在 api.js 添加 API 函数

在 cfc-frontend/utils/api.js 末尾(与其他 apply 函数放在一起)追加:

// 服务角色统一申请
export const submitServiceRoleApply = (data) => {
    return request('/api/service-role/apply', 'POST', data)
}

export const getMyServiceRoleApply = () => {
    return request('/api/service-role/my-apply', 'POST', {})
}

export const uploadServiceRoleImage = (filePath) => {
    return new Promise((resolve, reject) => {
        uni.uploadFile({
            url: BASE_URL + '/api/service-role/upload-image',
            filePath: filePath,
            name: 'file',
            header: {
                'Authorization': 'Bearer ' + uni.getStorageSync('token')
            },
            success: (res) => {
                if (res.statusCode === 200) {
                    const data = JSON.parse(res.data)
                    resolve(data)
                } else {
                    reject(new Error('上传失败'))
                }
            },
            fail: reject
        })
    })
}
  • Step 2: 在 pages.json 注册新页面

在 pages.json 的 profile 子包 pages 数组中追加:

{
  "path": "service-role-apply",
  "style": {
    "navigationBarTitleText": "服务角色申请"
  }
}
  • [ ] Step 3: 提交

    git add cfc-frontend/utils/api.js cfc-frontend/pages.json
    git commit -m "feat(frontend): add API functions and page registration for service role apply"
    

Task 6: 前端统一申请页

Files:

  • Create: cfc-frontend/pages/profile/service-role-apply.vue

Interfaces:

  • Consumes: API functions from Task 5, <AddressPicker> component from cfc-frontend
  • Produces: Full apply page with role multi-select, ID card upload, certificate upload, address picker, submit

  • [ ] Step 1: 创建 service-role-apply.vue

    <template>
    <view class="page">
    <!-- 角色选择 -->
    <view class="section">
      <view class="section-title">选择申请角色 <text class="required">*</text></view>
      <view class="role-grid">
        <view
          v-for="role in roleList"
          :key="role.key"
          class="role-card"
          :class="{ selected: selectedRoles.includes(role.key) }"
          @click="toggleRole(role.key)"
        >
          <text class="role-icon">{{ role.icon }}</text>
          <text class="role-name">{{ role.name }}</text>
          <text v-if="selectedRoles.includes(role.key)" class="check-mark">✓</text>
        </view>
      </view>
    </view>
    
    <!-- 身份证信息 -->
    <view class="section">
      <view class="section-title">身份证信息</view>
      <input class="input" v-model="form.idCard" placeholder="请输入身份证号码" maxlength="18" />
      <view class="upload-row">
        <view class="upload-item" @click="chooseIdCardImage('front')">
          <image v-if="form.idCardFront" :src="form.idCardFront" mode="aspectFill" class="upload-preview" />
          <view v-else class="upload-placeholder">
            <text class="upload-icon">+</text>
            <text class="upload-label">身份证正面</text>
          </view>
        </view>
        <view class="upload-item" @click="chooseIdCardImage('back')">
          <image v-if="form.idCardBack" :src="form.idCardBack" mode="aspectFill" class="upload-preview" />
          <view v-else class="upload-placeholder">
            <text class="upload-icon">+</text>
            <text class="upload-label">身份证反面</text>
          </view>
        </view>
      </view>
    </view>
    
    <!-- 资质证书 -->
    <view class="section">
      <view class="section-title">资质证书</view>
      <view class="cert-list">
        <view v-for="(img, idx) in form.certificateImages" :key="idx" class="cert-item">
          <image :src="img" mode="aspectFill" class="cert-preview" />
          <text class="cert-del" @click="removeCert(idx)">×</text>
        </view>
        <view class="cert-add" @click="chooseCertificate">
          <text class="upload-icon">+</text>
          <text class="upload-label">添加证书</text>
        </view>
      </view>
    </view>
    
    <!-- 联系地址 -->
    <view class="section">
      <view class="section-title">联系地址 <text class="required">*</text></view>
      <AddressPicker ref="addressPicker" @change="onAddressChange" />
      <input class="input" v-model="form.addressDetail" placeholder="详细地址(门牌号等)" />
    </view>
    
    <!-- 提交按钮 -->
    <button class="submit-btn" :disabled="submitting" :loading="submitting" @click="handleSubmit">
      {{ submitting ? '提交中...' : '提交申请' }}
    </button>
    </view>
    </template>
    
    <script>
    import { submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage } from '../../../utils/api'
    import AddressPicker from '../../../components/AddressPicker.vue'
    
    export default {
    components: { AddressPicker },
    data() {
    return {
      roleList: [
        { key: 'planner', name: '规划师', icon: '📋' },
        { key: 'nutritionist', name: '营养师', icon: '🥗' },
        { key: 'butler', name: '管家', icon: '🔔' },
        { key: 'vendor', name: '服务商', icon: '🏪' },
        { key: 'article_admin', name: '文章管理员', icon: '📝' },
        { key: 'activity_provider', name: '活动提供商', icon: '🎪' }
      ],
      selectedRoles: [],
      form: {
        idCard: '',
        idCardFront: '',
        idCardBack: '',
        certificateImages: [],
        province: '',
        city: '',
        district: '',
        street: '',
        addressDetail: ''
      },
      submitting: false
    }
    },
    onLoad() {
    this.checkExistingApply()
    },
    methods: {
    async checkExistingApply() {
      try {
        const res = await getMyServiceRoleApply()
        if (res.code === 200 && res.data && res.data.status === 0) {
          uni.showModal({
            title: '提示',
            content: '您已提交过申请,请等待审核',
            showCancel: false,
            success: () => uni.navigateBack()
          })
        }
      } catch (e) {}
    },
    toggleRole(key) {
      const idx = this.selectedRoles.indexOf(key)
      if (idx > -1) {
        this.selectedRoles.splice(idx, 1)
      } else {
        this.selectedRoles.push(key)
      }
    },
    chooseIdCardImage(side) {
      uni.chooseImage({
        count: 1,
        sizeType: ['compressed'],
        sourceType: ['album', 'camera'],
        success: async (res) => {
          uni.showLoading({ title: '上传中...' })
          try {
            const uploadRes = await uploadServiceRoleImage(res.tempFilePaths[0])
            uni.hideLoading()
            if (uploadRes.code === 200) {
              if (side === 'front') {
                this.form.idCardFront = uploadRes.data.url
              } else {
                this.form.idCardBack = uploadRes.data.url
              }
              uni.showToast({ title: '上传成功', icon: 'success' })
            } else {
              uni.showToast({ title: uploadRes.message || '上传失败', icon: 'none' })
            }
          } catch (e) {
            uni.hideLoading()
            uni.showToast({ title: '上传失败', icon: 'none' })
          }
        }
      })
    },
    async chooseCertificate() {
      uni.chooseImage({
        count: 9,
        sizeType: ['compressed'],
        sourceType: ['album', 'camera'],
        success: async (res) => {
          uni.showLoading({ title: '上传中...' })
          try {
            for (const filePath of res.tempFilePaths) {
              const uploadRes = await uploadServiceRoleImage(filePath)
              if (uploadRes.code === 200) {
                this.form.certificateImages.push(uploadRes.data.url)
              }
            }
            uni.hideLoading()
            uni.showToast({ title: '上传成功', icon: 'success' })
          } catch (e) {
            uni.hideLoading()
            uni.showToast({ title: '上传失败', icon: 'none' })
          }
        }
      })
    },
    removeCert(idx) {
      this.form.certificateImages.splice(idx, 1)
    },
    onAddressChange(e) {
      this.form.province = e.province || ''
      this.form.city = e.city || ''
      this.form.district = e.district || ''
      this.form.street = e.street || ''
    },
    async handleSubmit() {
      if (this.selectedRoles.length === 0) {
        uni.showToast({ title: '请至少选择一个角色', icon: 'none' })
        return
      }
      if (!this.form.idCardFront || !this.form.idCardBack) {
        uni.showToast({ title: '请上传身份证正反面照片', icon: 'none' })
        return
      }
      if (!this.form.province) {
        uni.showToast({ title: '请选择联系地址', icon: 'none' })
        return
      }
    
      this.submitting = true
      uni.showLoading({ title: '提交中...' })
      try {
        const res = await submitServiceRoleApply({
          roles: this.selectedRoles,
          idCard: this.form.idCard,
          idCardImages: [this.form.idCardFront, this.form.idCardBack],
          certificateImages: this.form.certificateImages,
          province: this.form.province,
          city: this.form.city,
          district: this.form.district,
          street: this.form.street,
          addressDetail: this.form.addressDetail
        })
        uni.hideLoading()
        if (res.code === 200) {
          uni.showModal({
            title: '提交成功',
            content: '申请已提交,请等待后台审核',
            showCancel: false,
            success: () => uni.navigateBack()
          })
        } else {
          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
        }
      } catch (e) {
        uni.hideLoading()
        uni.showToast({ title: '提交失败,请重试', icon: 'none' })
      } finally {
        this.submitting = false
      }
    }
    }
    }
    </script>
    
    <style>
    .page {
    min-height: 100vh;
    background: #f5f5f5;
    padding: 20rpx 30rpx 100rpx;
    }
    .section {
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 20rpx;
    }
    .section-title {
    font-size: 28rpx;
    font-weight: bold;
    margin-bottom: 20rpx;
    color: #333;
    }
    .required {
    color: #ff4444;
    }
    .role-grid {
    display: flex;
    flex-wrap: wrap;
    gap: 20rpx;
    }
    .role-card {
    width: calc(33.33% - 14rpx);
    background: #f8f8f8;
    border-radius: 12rpx;
    padding: 24rpx 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    position: relative;
    border: 2rpx solid transparent;
    }
    .role-card.selected {
    background: #fff7ed;
    border-color: #f97316;
    }
    .role-icon {
    font-size: 40rpx;
    margin-bottom: 8rpx;
    }
    .role-name {
    font-size: 24rpx;
    color: #333;
    }
    .check-mark {
    position: absolute;
    top: 8rpx;
    right: 12rpx;
    color: #f97316;
    font-size: 28rpx;
    font-weight: bold;
    }
    .input {
    width: 100%;
    height: 72rpx;
    border: 2rpx solid #eee;
    border-radius: 8rpx;
    padding: 0 20rpx;
    font-size: 26rpx;
    box-sizing: border-box;
    margin-bottom: 16rpx;
    }
    .upload-row {
    display: flex;
    gap: 20rpx;
    }
    .upload-item {
    flex: 1;
    height: 200rpx;
    border: 2rpx dashed #ddd;
    border-radius: 12rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    }
    .upload-placeholder {
    display: flex;
    flex-direction: column;
    align-items: center;
    }
    .upload-icon {
    font-size: 48rpx;
    color: #ccc;
    }
    .upload-label {
    font-size: 22rpx;
    color: #999;
    margin-top: 8rpx;
    }
    .upload-preview {
    width: 100%;
    height: 100%;
    }
    .cert-list {
    display: flex;
    flex-wrap: wrap;
    gap: 16rpx;
    }
    .cert-item {
    width: 160rpx;
    height: 160rpx;
    position: relative;
    border-radius: 8rpx;
    overflow: hidden;
    }
    .cert-preview {
    width: 100%;
    height: 100%;
    }
    .cert-del {
    position: absolute;
    top: 4rpx;
    right: 4rpx;
    width: 32rpx;
    height: 32rpx;
    background: rgba(0,0,0,0.5);
    color: #fff;
    border-radius: 50%;
    text-align: center;
    line-height: 32rpx;
    font-size: 24rpx;
    }
    .cert-add {
    width: 160rpx;
    height: 160rpx;
    border: 2rpx dashed #ddd;
    border-radius: 8rpx;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    }
    .submit-btn {
    width: 100%;
    height: 88rpx;
    background: linear-gradient(135deg, #f97316, #fb923c);
    color: #fff;
    font-size: 32rpx;
    border-radius: 44rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    position: fixed;
    bottom: 40rpx;
    left: 30rpx;
    width: calc(100% - 60rpx);
    }
    .submit-btn[disabled] {
    opacity: 0.6;
    }
    </style>
    
  • [ ] Step 2: 提交

    git add cfc-frontend/pages/profile/service-role-apply.vue
    git commit -m "feat(frontend): add service role apply page with multi-select, upload, address"
    

Task 7: ProfileMenu 入口

Files:

  • Modify: cfc-frontend/pages/profile/components/ProfileMenu.vue

Interfaces:

  • Consumes: Page created in Task 6
  • Produces: Menu item visible in "个人" group

  • [ ] Step 1: 添加菜单项

在 ProfileMenu.vue 的"个人"分组中,在"报告管理"之后追加:

<view class="menu-item" v-if="role === 'parent'" @click="goToServiceRoleApply">
  <text>📋 服务角色申请</text>
  <text class="arrow">›</text>
</view>
  • Step 2: 添加 methods 方法

在 methods 中添加:

goToServiceRoleApply() {
  uni.navigateTo({ url: '/pages/profile/service-role-apply' })
}
  • [ ] Step 3: 提交

    git add cfc-frontend/pages/profile/components/ProfileMenu.vue
    git commit -m "feat(frontend): add service role apply entry in ProfileMenu"
    

Task 8: Web 管理端审核页面

Files:

  • Create: cfc-web/src/views/service/ServiceRoleApplications.vue
  • Modify: cfc-web/src/router/index.js

Interfaces:

  • Consumes: POST /api/service-role/apply (approve/reject through admin endpoints)
  • Produces: Web admin review page for pending applications

  • [ ] Step 1: 创建审核页面

cfc-web/src/views/service/ServiceRoleApplications.vue:

<template>
  <div class="page-container">
    <el-card>
      <div slot="header">
        <span>服务角色申请审核</span>
      </div>
      <el-table :data="applications" v-loading="loading" style="width: 100%">
        <el-table-column prop="id" label="ID" width="60" />
        <el-table-column prop="userId" label="用户ID" width="80" />
        <el-table-column label="申请角色" width="200">
          <template slot-scope="{ row }">
            <el-tag v-for="role in parseRoles(row.roles)" :key="role" style="margin-right: 4px">
              {{ roleMap[role] || role }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="身份证" width="200">
          <template slot-scope="{ row }">
            <div>{{ row.idCard }}</div>
            <div v-if="row.idCardImages">
              <el-image v-for="(img, idx) in parseImages(row.idCardImages)" :key="idx"
                :src="img" style="width: 60px; height: 40px; margin-right: 4px; cursor: pointer;"
                :preview-src-list="parseImages(row.idCardImages)" />
            </div>
          </template>
        </el-table-column>
        <el-table-column label="资质证书" width="160">
          <template slot-scope="{ row }">
            <el-image v-if="row.certificateImages"
              v-for="(img, idx) in parseImages(row.certificateImages)" :key="idx"
              :src="img" style="width: 60px; height: 40px; margin-right: 4px; cursor: pointer;"
              :preview-src-list="parseImages(row.certificateImages)" />
          </template>
        </el-table-column>
        <el-table-column label="地址" width="200">
          <template slot-scope="{ row }">
            {{ row.province }}{{ row.city }}{{ row.district }}{{ row.street }} {{ row.addressDetail }}
          </template>
        </el-table-column>
        <el-table-column prop="createdAt" label="申请时间" width="160" />
        <el-table-column label="状态" width="80">
          <template slot-scope="{ row }">
            <el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作" width="160">
          <template slot-scope="{ row }">
            <el-button v-if="row.status === 0" type="success" size="mini" @click="handleApprove(row)">批准</el-button>
            <el-button v-if="row.status === 0" type="danger" size="mini" @click="handleReject(row)">驳回</el-button>
          </template>
        </el-table-column>
      </el-table>
    </el-card>

    <el-dialog title="驳回申请" :visible.sync="rejectDialogVisible" width="400px">
      <el-form>
        <el-form-item label="驳回原因">
          <el-input type="textarea" v-model="rejectReason" rows="3" placeholder="请输入驳回原因" />
        </el-form-item>
      </el-form>
      <div slot="footer">
        <el-button @click="rejectDialogVisible = false">取消</el-button>
        <el-button type="danger" @click="confirmReject">确认驳回</el-button>
      </div>
    </el-dialog>
  </div>
</template>

<script>
import request from '@/utils/request'

export default {
  data() {
    return {
      applications: [],
      loading: false,
      rejectDialogVisible: false,
      rejectReason: '',
      currentRejectId: null,
      roleMap: {
        planner: '规划师',
        nutritionist: '营养师',
        butler: '管家',
        vendor: '服务商',
        article_admin: '文章管理员',
        activity_provider: '活动提供商'
      }
    }
  },
  mounted() {
    this.fetchApplications()
  },
  methods: {
    async fetchApplications() {
      this.loading = true
      try {
        const res = await request({ url: '/api/service-role/admin/list', method: 'post' })
        this.applications = res.data || []
      } catch (e) {
        this.$message.error('获取列表失败')
      } finally {
        this.loading = false
      }
    },
    parseRoles(roles) {
      try { return JSON.parse(roles) } catch { return [] }
    },
    parseImages(images) {
      try { return JSON.parse(images) } catch { return [] }
    },
    statusType(status) {
      return { 0: 'warning', 1: 'success', 2: 'danger' }[status] || 'info'
    },
    statusText(status) {
      return { 0: '待审核', 1: '已通过', 2: '已驳回' }[status] || '未知'
    },
    async handleApprove(row) {
      try {
        await request({ url: '/api/service-role/admin/approve', method: 'post', data: { id: row.id } })
        this.$message.success('已批准')
        this.fetchApplications()
      } catch (e) {
        this.$message.error('操作失败')
      }
    },
    handleReject(row) {
      this.currentRejectId = row.id
      this.rejectReason = ''
      this.rejectDialogVisible = true
    },
    async confirmReject() {
      try {
        await request({ url: '/api/service-role/admin/reject', method: 'post', data: { id: this.currentRejectId, reason: this.rejectReason } })
        this.$message.success('已驳回')
        this.rejectDialogVisible = false
        this.fetchApplications()
      } catch (e) {
        this.$message.error('操作失败')
      }
    }
  }
}
</script>
  • Step 2: 添加后端管理端接口

在 ServiceRoleApplicationController 中添加管理端接口(追加到同一个 Controller 中):

@PostMapping("/admin/list")
@Operation(summary = "管理端: 获取申请列表")
public Result list(@RequestParam(required = false, defaultValue = "0") Integer status) {
    List<ServiceRoleApplication> list = serviceRoleApplicationService.list(
            new LambdaQueryWrapper<ServiceRoleApplication>()
                    .eq(status != null, ServiceRoleApplication::getStatus, status)
                    .orderByDesc(ServiceRoleApplication::getCreatedAt));
    return Result.success(list);
}

@PostMapping("/admin/approve")
@Operation(summary = "管理端: 批准申请")
public Result adminApprove(@RequestBody Map<String, Object> params,
                           @RequestAttribute("userId") Long adminId) {
    Long id = Long.valueOf(params.get("id").toString());
    return serviceRoleApplicationService.approve(id, adminId);
}

@PostMapping("/admin/reject")
@Operation(summary = "管理端: 驳回申请")
public Result adminReject(@RequestBody Map<String, Object> params,
                          @RequestAttribute("userId") Long adminId) {
    Long id = Long.valueOf(params.get("id").toString());
    String reason = params.get("reason") != null ? params.get("reason").toString() : "";
    return serviceRoleApplicationService.reject(id, reason, adminId);
}
  • Step 3: 添加管理端路由

在 cfc-web/src/router/index.js 中添加路由:

{
  path: '/service/service-role-applications',
  name: 'ServiceRoleApplications',
  component: () => import('@/views/service/ServiceRoleApplications.vue'),
  meta: { title: '服务角色申请审核', roles: ['admin'] }
}
  • [ ] Step 4: 提交

    git add cfc-web/src/views/service/ServiceRoleApplications.vue \
       cfc-web/src/router/index.js \
       cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java
    git commit -m "feat(admin): add service role application review page and admin endpoints"
    

Task 9: 替换旧申请页(可选)

Files:

  • Modify: cfc-frontend/pages/butler/apply.vue
  • Modify: cfc-frontend/pages/nutritionist/apply.vue
  • Modify: cfc-frontend/pages/vendor/apply.vue
  • Modify: cfc-frontend/pages/guide/register/index.vue

  • [ ] Step 1: 将旧申请页重定向到统一入口

在每个旧页面的 onLoad 或页面顶部添加重定向逻辑:

// 例:在 butler/apply.vue 的 onLoad 中添加
onLoad() {
  uni.redirectTo({ url: '/pages/profile/service-role-apply' })
}
  • [ ] Step 2: 提交

    git add cfc-frontend/pages/butler/apply.vue \
       cfc-frontend/pages/nutritionist/apply.vue \
       cfc-frontend/pages/vendor/apply.vue \
       cfc-frontend/pages/guide/register/index.vue
    git commit -m "refactor: redirect old apply pages to unified service role apply page"