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管理端
@PostMapping,禁止 @GetMapping/@PutMapping/@DeleteMappingResult<T> (code/message/data)@Resource 代替 @Autowired?.(用 && 替代)、禁止 CSS Grid(用 flexbox)service_role_applications| 文件 | 用途 |
|---|---|
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 |
添加审核页面路由 |
Files:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (追加迁移 111-113)cfc-backend/src/main/resources/schema.sqlInterfaces:
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';
在 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"
Files:
cfc-backend/src/main/java/com/etotem/cfc/entity/ServiceRoleApplication.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ServiceRoleApplicationMapper.javacfc-backend/src/main/java/com/etotem/cfc/dto/ServiceRoleApplyDTO.javacfc-backend/src/main/java/com/etotem/cfc/entity/User.javaInterfaces:
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;
}
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> {
}
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;
}
在 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"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ServiceRoleApplicationService.javacfc-backend/src/main/java/com/etotem/cfc/service/impl/ServiceRoleApplicationServiceImpl.javaInterfaces:
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);
}
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"
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.javaInterfaces:
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"
Files:
cfc-frontend/utils/api.jscfc-frontend/pages.jsonInterfaces:
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
})
})
}
在 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"
Files:
cfc-frontend/pages/profile/service-role-apply.vueInterfaces:
<AddressPicker> component from cfc-frontendProduces: 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"
Files:
cfc-frontend/pages/profile/components/ProfileMenu.vueInterfaces:
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>
在 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"
Files:
cfc-web/src/views/service/ServiceRoleApplications.vuecfc-web/src/router/index.jsInterfaces:
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>
在 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);
}
在 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"
Files:
cfc-frontend/pages/butler/apply.vuecfc-frontend/pages/nutritionist/apply.vuecfc-frontend/pages/vendor/apply.vueModify: 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"