|
|
@@ -0,0 +1,1367 @@
|
|
|
+# 服务角色统一申请 — 实施计划
|
|
|
+
|
|
|
+> **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` 末尾追加:
|
|
|
+
|
|
|
+```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` 注释附近追加:
|
|
|
+
|
|
|
+```sql
|
|
|
+-- 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()` 方法末尾(最后一个 `// 迁移` 之后)追加:
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```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`:
|
|
|
+
|
|
|
+```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`:
|
|
|
+
|
|
|
+```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` 附近):
|
|
|
+
|
|
|
+```java
|
|
|
+/** 文章管理员状态: pending/approved/rejected */
|
|
|
+private String articleAdminStatus;
|
|
|
+
|
|
|
+/** 活动提供商状态: pending/approved/rejected */
|
|
|
+private String activityProviderStatus;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```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`:
|
|
|
+
|
|
|
+```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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```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: 检查路由重复:
|
|
|
+```bash
|
|
|
+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: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 函数放在一起)追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 服务角色统一申请
|
|
|
+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 数组中追加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "service-role-apply",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "服务角色申请"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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` 的"个人"分组中,在"报告管理"之后追加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<view class="menu-item" v-if="role === 'parent'" @click="goToServiceRoleApply">
|
|
|
+ <text>📋 服务角色申请</text>
|
|
|
+ <text class="arrow">›</text>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 添加 methods 方法**
|
|
|
+
|
|
|
+在 `methods` 中添加:
|
|
|
+
|
|
|
+```js
|
|
|
+goToServiceRoleApply() {
|
|
|
+ uni.navigateTo({ url: '/pages/profile/service-role-apply' })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```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 中):
|
|
|
+
|
|
|
+```java
|
|
|
+@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` 中添加路由:
|
|
|
+
|
|
|
+```javascript
|
|
|
+{
|
|
|
+ path: '/service/service-role-applications',
|
|
|
+ name: 'ServiceRoleApplications',
|
|
|
+ component: () => import('@/views/service/ServiceRoleApplications.vue'),
|
|
|
+ meta: { title: '服务角色申请审核', roles: ['admin'] }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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` 或页面顶部添加重定向逻辑:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 例:在 butler/apply.vue 的 onLoad 中添加
|
|
|
+onLoad() {
|
|
|
+ uni.redirectTo({ url: '/pages/profile/service-role-apply' })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+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"
|
|
|
+```
|