|
@@ -0,0 +1,1037 @@
|
|
|
|
|
+# 实施计划:新用户注册引导 + 个人信息按需补充与回写
|
|
|
|
|
+
|
|
|
|
|
+- **日期**: 2026-08-04
|
|
|
|
|
+- **依据**: `docs/superpowers/specs/2026-08-04-registration-profile-backfill-design.md`(3 个默认值按推荐值执行:统一 backfill 接口 / 外部报名人不回写 / 14 字段口径)
|
|
|
|
|
+- **状态**: 待实施
|
|
|
|
|
+- **验证基线**: 后端 `mvn clean compile`(在 `cfc-backend` 下);前端 `npm run build:mp-weixin`(在 `cfc-frontend` 下)
|
|
|
|
|
+
|
|
|
|
|
+## 0. 总览
|
|
|
|
|
+
|
|
|
|
|
+新用户注册不再自动创建家庭(`familyId=null`),注册引导只收集头像+昵称(可跳过);个人中心新增「已完善 X/14 字段」卡片;功能场景(试点:活动报名)实现「选择用户 → 预填 → 就地补充 → 自动回写」,新增统一 backfill 接口(后端字段白名单)。
|
|
|
|
|
+
|
|
|
|
|
+- 迁移:`users.family_id` 允许 NULL(迁移 155)
|
|
|
|
|
+- 后端:5 条注册路径删除建家庭逻辑;`LoginResultDTO` 新增 `needsFamily`;新增 `POST /api/user/profile/backfill`
|
|
|
|
|
+- 前端:`user-edit.vue` 新用户流程最小化;`profile.vue` 补充卡片;`utils/profile-field-usage.js` + `utils/profile-backfill.js`;活动报名接入回写
|
|
|
|
|
+- P2:无家庭引导组件 `components/family-guard.vue` 接入 3 个核心页
|
|
|
|
|
+
|
|
|
|
|
+## 1. 关键参考代码(实施前必读)
|
|
|
|
|
+
|
|
|
|
|
+| 参考 | 路径 | 用途 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| 迁移模式 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(末尾迁移154块) | 新增迁移155模板 |
|
|
|
|
|
+| 注册路径 | `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java` `wechatLogin`(L71-168)/`phoneLogin`(L173-247)/`directRegister`(L382-430)/`registerWithIdCard`(L742-809)/`registerWithInviteCode`(L1181+) | 删除建家庭逻辑 |
|
|
|
|
|
+| 懒建家庭 | 同上 `ensureUserHasFamily`(L922-938) + 调用点 L143/L227/L289/L325 | 删除(连同调用点) |
|
|
|
|
|
+| 登录响应装配 | 同上各 login 方法末尾 `LoginResultDTO result = new LoginResultDTO()` | 加 `needsFamily` |
|
|
|
|
|
+| birthday 解析 | 同上 `updateUserInfo`(L962) `SimpleDateFormat("yyyy-MM-dd")` | backfill 复用同款解析 |
|
|
|
|
|
+| 实体字段 | `cfc-backend/src/main/java/com/etotem/cfc/entity/User.java`(birthHour L53/gender L55/dietPreferences L87/address L79/phone L43)、`FamilyMember.java`(phone L30/gender L36/birthday L39/birthHour L42) | 白名单字段 |
|
|
|
|
|
+| 控制器 | `cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java`(`/api/user`,L66 `/info`、L77 `/update`) | 新增 `/profile/backfill` |
|
|
|
|
|
+| schema.sql | `cfc-backend/src/main/resources/schema.sql` L19 `family_id BIGINT NOT NULL` | 改为 `BIGINT NULL` |
|
|
|
|
|
+| 前端表单 | `cfc-frontend/pages/user-edit/user-edit.vue`(家庭绑定区 L212-233、`saveUserInfo` L463、`skip` L635) | 新用户流程最小化 |
|
|
|
|
|
+| 个人中心 | `cfc-frontend/pages/profile/profile.vue`(L17 ProfileHeader + `@avatar-click="goToEditInfo"`,**goToEditInfo 方法缺失需补**) | 加补充卡片 |
|
|
|
|
|
+| 活动报名 | `cfc-frontend/pages/activity/activity-detail/activity-detail.vue`(`loadFamilyMembers` L570-602、`toggleFamilyMember` L609-622、`addManualEntry` L623-630、`submitRegistration` L671、`doCreateOrder` L708) | 接入回写 |
|
|
|
|
|
+| API 封装 | `cfc-frontend/utils/api.js`(L252 getUserInfo、L264 createFamily、L269 getFamilyMembers) | 加 backfillProfile |
|
|
|
|
|
+| 无家庭页 | `cfc-frontend/pages/wealth-sub/insurance-planning.vue`、`cfc-frontend/pages/profile/components/ProfileMenu.vue`、`cfc-frontend/pages/growth/index.vue`(均有「请先加入家庭」) | P2 接入引导 |
|
|
|
|
|
+
|
|
|
|
|
+**项目约定(必须遵守)**:
|
|
|
|
|
+- 接口一律 `@PostMapping`;DI 用 `@Resource`;响应统一 `Result<T>`;实体 `@TableName` + `@TableId(type = IdType.AUTO)` + `@Data`
|
|
|
|
|
+- 迁移编号递增:本计划新增为 **迁移155**(当前最大 154)
|
|
|
|
|
+- 迁移幂等:try-catch 包裹,注释 `// 迁移155: ...`
|
|
|
|
|
+- schema.sql 必须与 DatabaseInitializer 同步
|
|
|
|
|
+- 前端禁止可选链 `?.`、禁止 `:key` 表达式、禁止 CSS Grid、禁止 `new Date(string)`(用 `parseDate()`)
|
|
|
|
|
+- 提交信息风格 `feat: 注册引导-xxx`
|
|
|
|
|
+- 后端验证以 `mvn clean compile` 为准(测试目录有 ~166 个预先存在失败,**不要**尝试修复,也不要把本次功能写进现有测试套件)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 1:数据库迁移 155 + schema.sql 同步
|
|
|
|
|
+
|
|
|
|
|
+### 1.1 修改 `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
|
|
|
|
|
+
|
|
|
|
|
+在 `runMigrations()` 中**最后一个迁移块(迁移154)之后、方法闭合 `}` 之前**追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // 迁移155: users.family_id 允许 NULL(注册不再自动创建家庭)
|
|
|
|
|
+ try {
|
|
|
|
|
+ jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN family_id BIGINT NULL COMMENT '所属家庭ID(注册时可为空,首次使用家庭功能时创建/加入)'");
|
|
|
|
|
+ log.info("已修改users.family_id允许NULL");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ // 忽略
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 1.2 同步 `cfc-backend/src/main/resources/schema.sql`
|
|
|
|
|
+
|
|
|
|
|
+在 `users` 表定义中把(L19):
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+family_id BIGINT NOT NULL,
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+改为:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+family_id BIGINT NULL COMMENT '所属家庭ID(注册时可为空,首次使用家庭功能时创建/加入)',
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `mvn clean compile`(在 `cfc-backend`)退出码 0。
|
|
|
|
|
+**提交**: `feat: 注册引导-迁移155 users.family_id可空`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 2:LoginResultDTO 新增 needsFamily
|
|
|
|
|
+
|
|
|
|
|
+### 2.1 修改 `cfc-backend/src/main/java/com/etotem/cfc/dto/LoginResultDTO.java`
|
|
|
|
|
+
|
|
|
|
|
+在 `needsRoleSelect` 字段之后(L16 后)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ private Boolean needsFamily; // familyId 为空或 0 时为 true,前端据此决定是否引导建家庭
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 2.2 修改 `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java` 各登录方法
|
|
|
|
|
+
|
|
|
|
|
+在每个 `LoginResultDTO result = new LoginResultDTO();` 装配块中,于 `result.setNeedsRoleSelect(...)`(无则放 `result.setNickname(...)` 之后)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ result.setNeedsFamily(user.getFamilyId() == null || user.getFamilyId() == 0L);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**涉及装配点(8 处)**:
|
|
|
|
|
+1. `wechatLogin`(~L153-167)
|
|
|
|
|
+2. `phoneLogin`(~L235-246)
|
|
|
|
|
+3. `silentLogin`(~L294-305)
|
|
|
|
|
+4. `autoLoginByOpenid`(~L329-339)
|
|
|
|
|
+5. `directRegister`(~L419-429)
|
|
|
|
|
+6. `registerWithIdCard`(~L800-808)
|
|
|
|
|
+7. `registerWithInviteCode` 分销分支(~L1212-1221)
|
|
|
|
|
+8. `registerWithInviteCode` 普通分支(~L1287-1292)
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `mvn clean compile` 退出码 0。
|
|
|
|
|
+**提交**: `feat: 注册引导-登录响应新增needsFamily标记`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 3:注册路径删除建家庭逻辑(核心解耦)
|
|
|
|
|
+
|
|
|
|
|
+修改 `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java`。
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 `wechatLogin`(L103-108 + L113)
|
|
|
|
|
+
|
|
|
|
|
+删除新用户分支中的建家庭代码块:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ Family family = new Family();
|
|
|
|
|
+ family.setName("我的家庭");
|
|
|
|
|
+ family.setInviteCode(generateInviteCode());
|
|
|
|
|
+ family.setCreatedAt(new Date());
|
|
|
|
|
+ family.setUpdatedAt(new Date());
|
|
|
|
|
+ familyMapper.insert(family);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+并把 L113 `user.setFamilyId(family.getId());` 删除(保持默认 null)。
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 `phoneLogin`(L192-197 + L202)
|
|
|
|
|
+
|
|
|
|
|
+删除同样的建家庭代码块(L192-197)和 L202 `user.setFamilyId(family.getId());`。
|
|
|
|
|
+删除 L216-217 `family.setCreatorId(user.getId()); familyMapper.updateById(family);`(family 变量已不存在)。
|
|
|
|
|
+
|
|
|
|
|
+### 3.3 `directRegister`(L384-389 + L395)
|
|
|
|
|
+
|
|
|
|
|
+删除建家庭代码块(L384-389)和 L395 `user.setFamilyId(family.getId());`。
|
|
|
|
|
+保留 L408-414 邀请码加入家庭逻辑(`joinFamily(user.getId(), dto.getInviteCode())`)不变。
|
|
|
|
|
+
|
|
|
|
|
+### 3.4 `registerWithIdCard`(L775-780 + L785)
|
|
|
|
|
+
|
|
|
|
|
+**仅新用户分支**(`else` 块)删除建家庭代码块(L775-780)和 L785 `user.setFamilyId(family.getId());`。
|
|
|
|
|
+- L753 `Family family;` 声明删除
|
|
|
|
|
+- 老用户分支 L764 `family = familyMapper.selectById(user.getFamilyId());` 删除(变量已删)
|
|
|
|
|
+- L802 `result.setFamilyId(user.getFamilyId())` 保留(老用户有 familyId 时正常返回)
|
|
|
|
|
+
|
|
|
|
|
+### 3.5 `registerWithInviteCode` 分销分支(L1188-1194 + L1199)
|
|
|
|
|
+
|
|
|
|
|
+**仅 distResult 命中分支**删除建家庭代码块(L1189-1194)和 L1199 `user.setFamilyId(family.getId());`,但保留 `distributionService.registerByInviteCode(inviteCode, user.getId())` 与 token 装配。
|
|
|
|
|
+**普通邀请码分支(L1224+)不动**:该分支语义是「加入已有家庭」(`familyMapper.selectOne(inviteCode)`),新用户 `user.setFamilyId(family.getId())` 保留。
|
|
|
|
|
+
|
|
|
|
|
+### 3.6 删除 `ensureUserHasFamily` 方法及 4 个调用点
|
|
|
|
|
+
|
|
|
|
|
+- 删除方法定义(L917-938,含 javadoc 注释)
|
|
|
|
|
+- 删除调用点:L143(wechatLogin)、L227(phoneLogin else 分支)、L289(silentLogin)、L325(autoLoginByOpenid)
|
|
|
|
|
+
|
|
|
|
|
+**注意**: 调用点在 if/else 块内,删除时保持块结构完整(如 phoneLogin L219-230 的 else 块删除 L227 后仍保留 `user.setUpdatedAt` + `userMapper.updateById`)。
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `mvn clean compile` 退出码 0。
|
|
|
|
|
+**提交**: `feat: 注册引导-注册路径不再自动创建家庭`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 4:统一 backfill 接口
|
|
|
|
|
+
|
|
|
|
|
+### 4.1 新建 `cfc-backend/src/main/java/com/etotem/cfc/dto/ProfileBackfillDTO.java`
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.dto;
|
|
|
|
|
+
|
|
|
|
|
+import lombok.Data;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+@Data
|
|
|
|
|
+public class ProfileBackfillDTO {
|
|
|
|
|
+ /** 回写目标类型: user=本人 / member=家庭成员 */
|
|
|
|
|
+ private String targetType;
|
|
|
|
|
+ /** targetType=member 时的 family_members.id;user 时可为空 */
|
|
|
|
|
+ private Long targetId;
|
|
|
|
|
+ /** 回写字段: { phone: "138...", birthday: "2015-06-01", ... } */
|
|
|
|
|
+ private Map<String, String> fields;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.2 修改 `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java`
|
|
|
|
|
+
|
|
|
|
|
+新增常量与方法(放在 `getUserInfo` 方法 L363 之后):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ /** 回写字段白名单(user 目标) */
|
|
|
|
|
+ private static final String[] USER_BACKFILL_WHITELIST = {"phone", "gender", "birthday", "birthHour", "dietPreferences", "address"};
|
|
|
|
|
+
|
|
|
|
|
+ /** 回写字段白名单(member 目标,family_members 表无 dietPreferences/address 列) */
|
|
|
|
|
+ private static final String[] MEMBER_BACKFILL_WHITELIST = {"phone", "gender", "birthday", "birthHour"};
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 就地补充信息回写(字段白名单校验 + 目标归属校验)
|
|
|
|
|
+ * @return Map: { updatedFields: List<String>, profile: Object(最新完整信息) }
|
|
|
|
|
+ */
|
|
|
|
|
+ public Map<String, Object> backfillProfile(Long userId, ProfileBackfillDTO dto) {
|
|
|
|
|
+ if (dto == null || dto.getFields() == null || dto.getFields().isEmpty()) {
|
|
|
|
|
+ throw new RuntimeException("回写字段不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ User me = userMapper.selectById(userId);
|
|
|
|
|
+ if (me == null) {
|
|
|
|
|
+ throw new RuntimeException("用户不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> updatedFields = new ArrayList<>();
|
|
|
|
|
+ Object profile;
|
|
|
|
|
+
|
|
|
|
|
+ if ("user".equals(dto.getTargetType())) {
|
|
|
|
|
+ // 仅允许回写自己
|
|
|
|
|
+ if (dto.getTargetId() != null && !dto.getTargetId().equals(userId)) {
|
|
|
|
|
+ throw new RuntimeException("仅能回写本人的个人信息");
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String field : USER_BACKFILL_WHITELIST) {
|
|
|
|
|
+ String value = dto.getFields().get(field);
|
|
|
|
|
+ if (value == null || value.isEmpty()) continue;
|
|
|
|
|
+ applyUserBackfillField(me, field, value.trim());
|
|
|
|
|
+ updatedFields.add(field);
|
|
|
|
|
+ }
|
|
|
|
|
+ me.setUpdatedAt(new Date());
|
|
|
|
|
+ userMapper.updateById(me);
|
|
|
|
|
+ profile = me;
|
|
|
|
|
+ } else if ("member".equals(dto.getTargetType())) {
|
|
|
|
|
+ Long memberId = dto.getTargetId();
|
|
|
|
|
+ if (memberId == null) {
|
|
|
|
|
+ throw new RuntimeException("回写家庭成员时 targetId 不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ FamilyMember member = familyMemberMapper.selectById(memberId);
|
|
|
|
|
+ if (member == null) {
|
|
|
|
|
+ throw new RuntimeException("家庭成员不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 归属校验:成员必须属于当前用户家庭
|
|
|
|
|
+ Long myFamilyId = me.getFamilyId();
|
|
|
|
|
+ if (myFamilyId == null || !myFamilyId.equals(member.getFamilyId())) {
|
|
|
|
|
+ throw new RuntimeException("该成员不属于您的家庭");
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String field : MEMBER_BACKFILL_WHITELIST) {
|
|
|
|
|
+ String value = dto.getFields().get(field);
|
|
|
|
|
+ if (value == null || value.isEmpty()) continue;
|
|
|
|
|
+ applyMemberBackfillField(member, field, value.trim());
|
|
|
|
|
+ updatedFields.add(field);
|
|
|
|
|
+ }
|
|
|
|
|
+ familyMemberMapper.updateById(member);
|
|
|
|
|
+ profile = member;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ throw new RuntimeException("targetType 仅支持 user/member");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("updatedFields", updatedFields);
|
|
|
|
|
+ result.put("profile", profile);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void applyUserBackfillField(User user, String field, String value) {
|
|
|
|
|
+ switch (field) {
|
|
|
|
|
+ case "phone":
|
|
|
|
|
+ if (!value.matches("^1[3-9]\\d{9}$")) throw new RuntimeException("手机号格式不正确");
|
|
|
|
|
+ user.setPhone(value);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "gender":
|
|
|
|
|
+ if (!"male".equals(value) && !"female".equals(value)) throw new RuntimeException("性别取值非法");
|
|
|
|
|
+ user.setGender(value);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "birthday":
|
|
|
|
|
+ try {
|
|
|
|
|
+ java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
|
|
|
|
|
+ user.setBirthday(sdf.parse(value));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ throw new RuntimeException("生日格式不正确,应为 yyyy-MM-dd");
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "birthHour":
|
|
|
|
|
+ if (!"子丑寅卯辰巳午未申酉戌亥".contains(value)) throw new RuntimeException("出生时辰取值非法");
|
|
|
|
|
+ user.setBirthHour(value);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "dietPreferences":
|
|
|
|
|
+ user.setDietPreferences(value);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "address":
|
|
|
|
|
+ user.setAddress(value);
|
|
|
|
|
+ break;
|
|
|
|
|
+ default:
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void applyMemberBackfillField(FamilyMember member, String field, String value) {
|
|
|
|
|
+ switch (field) {
|
|
|
|
|
+ case "phone":
|
|
|
|
|
+ if (!value.matches("^1[3-9]\\d{9}$")) throw new RuntimeException("手机号格式不正确");
|
|
|
|
|
+ member.setPhone(value);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "gender":
|
|
|
|
|
+ if (!"male".equals(value) && !"female".equals(value)) throw new RuntimeException("性别取值非法");
|
|
|
|
|
+ member.setGender(value);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "birthday":
|
|
|
|
|
+ try {
|
|
|
|
|
+ java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
|
|
|
|
|
+ member.setBirthday(sdf.parse(value));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ throw new RuntimeException("生日格式不正确,应为 yyyy-MM-dd");
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "birthHour":
|
|
|
|
|
+ if (!"子丑寅卯辰巳午未申酉戌亥".contains(value)) throw new RuntimeException("出生时辰取值非法");
|
|
|
|
|
+ member.setBirthHour(value);
|
|
|
|
|
+ break;
|
|
|
|
|
+ default:
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> 确认 UserService 已 import `java.util.HashMap`/`java.util.Map`/`java.util.List`/`java.util.ArrayList`(文件已使用 Map/List)。
|
|
|
|
|
+
|
|
|
|
|
+### 4.3 修改 `cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java`
|
|
|
|
|
+
|
|
|
|
|
+在 `@PostMapping("/update")`(L105 之后)新增:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @PostMapping("/profile/backfill")
|
|
|
|
|
+ @Operation(summary = "就地补充信息回写(个人资料按需补充)")
|
|
|
|
|
+ public Result<Map<String, Object>> backfillProfile(HttpServletRequest request, @RequestBody ProfileBackfillDTO dto) {
|
|
|
|
|
+ Long userId = (Long) request.getAttribute("userId");
|
|
|
|
|
+ try {
|
|
|
|
|
+ Map<String, Object> result = userService.backfillProfile(userId, dto);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ } catch (RuntimeException e) {
|
|
|
|
|
+ return Result.error(e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+确认文件已 import `com.etotem.cfc.dto.ProfileBackfillDTO`、`java.util.Map`(文件已使用 Map)。
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `mvn clean compile` 退出码 0。
|
|
|
|
|
+**提交**: `feat: 注册引导-新增统一backfill回写接口`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 5:前端 api.js 新增 backfillProfile
|
|
|
|
|
+
|
|
|
|
|
+修改 `cfc-frontend/utils/api.js`,在 `updateUserInfo`(L256-258)之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+// 就地补充信息回写(活动报名等场景按需补充后自动回写个人资料)
|
|
|
|
|
+export const backfillProfile = (data) => {
|
|
|
|
|
+ return request('/api/user/profile/backfill', 'POST', data)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `npm run build:mp-weixin` 通过。
|
|
|
|
|
+**提交**: `feat: 注册引导-前端api新增backfillProfile`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 6:新建 `cfc-frontend/utils/profile-field-usage.js`
|
|
|
|
|
+
|
|
|
|
|
+**新增文件**(完整内容):
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+/**
|
|
|
|
|
+ * 个人信息使用关联表
|
|
|
|
|
+ * 记录个人信息的各个字段在哪些功能场景中被使用。
|
|
|
|
|
+ * 用途:
|
|
|
|
|
+ * 1. 功能页自查「本场景用到哪些字段、当前人是否缺失」
|
|
|
|
|
+ * 2. 回写时校验字段确实属于本场景(只从使用处回写)
|
|
|
|
|
+ * 3. 个人中心卡片统计已填字段数
|
|
|
|
|
+ */
|
|
|
|
|
+module.exports = {
|
|
|
|
|
+ phone: {
|
|
|
|
|
+ label: '手机号',
|
|
|
|
|
+ scenes: [
|
|
|
|
|
+ { scene: 'activity-signup', page: 'pages/activity/activity-detail', usage: '活动报名联系人手机号', backfillTarget: ['user', 'member'] },
|
|
|
|
|
+ { scene: 'shop-order', page: 'pages/shop/checkout', usage: '订单收货人手机号', backfillTarget: ['user'] },
|
|
|
|
|
+ { scene: 'assessment-order', page: 'pages/assessment/purchase', usage: '测评预约联系人手机号', backfillTarget: ['user'] }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ birthday: {
|
|
|
|
|
+ label: '生日',
|
|
|
|
|
+ scenes: [
|
|
|
|
|
+ { scene: 'assessment', page: 'pages/assessment/*', usage: '测评年龄计算', backfillTarget: ['user', 'member'] },
|
|
|
|
|
+ { scene: 'tianpan', page: 'pages/tianpan/*', usage: '天盘命理计算', backfillTarget: ['user', 'member'] },
|
|
|
|
|
+ { scene: 'health-report', page: 'pages/health/*', usage: '健康报告年龄相关', backfillTarget: ['member'] }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ gender: {
|
|
|
|
|
+ label: '性别',
|
|
|
|
|
+ scenes: [
|
|
|
|
|
+ { scene: 'assessment', page: 'pages/assessment/*', usage: '测评维度计算', backfillTarget: ['user', 'member'] },
|
|
|
|
|
+ { scene: 'tianpan', page: 'pages/tianpan/*', usage: '天盘计算', backfillTarget: ['user', 'member'] }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ birthHour: {
|
|
|
|
|
+ label: '出生时辰',
|
|
|
|
|
+ scenes: [
|
|
|
|
|
+ { scene: 'tianpan', page: 'pages/tianpan/*', usage: '天盘命理计算', backfillTarget: ['user', 'member'] }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ dietPreferences: {
|
|
|
|
|
+ label: '饮食偏好',
|
|
|
|
|
+ scenes: [
|
|
|
|
|
+ { scene: 'nutrition-recommend', page: 'pages/health/nutrition-profile', usage: '饮食推荐个性化', backfillTarget: ['user', 'member'] }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ address: {
|
|
|
|
|
+ label: '地址',
|
|
|
|
|
+ scenes: [
|
|
|
|
|
+ { scene: 'shop-order', page: 'pages/shop/checkout', usage: '收货地址', backfillTarget: ['user'] },
|
|
|
|
|
+ { scene: 'activity-signup', page: 'pages/activity/activity-detail', usage: '活动地点信息', backfillTarget: ['user'] }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**验证**: 文件可被 require(语法正确)。`npm run build:mp-weixin` 通过。
|
|
|
|
|
+**提交**: `feat: 注册引导-个人信息使用关联表profile-field-usage`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 7:新建 `cfc-frontend/utils/profile-backfill.js`
|
|
|
|
|
+
|
|
|
|
|
+**新增文件**(完整内容):
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { backfillProfile as apiBackfill } from './api.js'
|
|
|
|
|
+const fieldUsage = require('./profile-field-usage.js')
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 就地补充 → 自动回写个人信息
|
|
|
|
|
+ * @param {Object} opts
|
|
|
|
|
+ * targetType: 'user' | 'member'
|
|
|
|
|
+ * targetId: memberId(targetType=member 时)
|
|
|
|
|
+ * fields: { phone: '138...', birthday: '...' }
|
|
|
|
|
+ * scene: 'activity-signup'(必传,校验字段是否属于本场景)
|
|
|
|
|
+ * prevValues: { phone: '旧值' } 当前人已有值(用于「修改已有值」判定)
|
|
|
|
|
+ * @returns {Promise<Object>} { updatedFields: [], skippedFields: [] }
|
|
|
|
|
+ */
|
|
|
|
|
+export function backfillProfile(opts) {
|
|
|
|
|
+ return new Promise(function(resolve, reject) {
|
|
|
|
|
+ var scene = opts.scene
|
|
|
|
|
+ var fields = opts.fields || {}
|
|
|
|
|
+ var fieldNames = Object.keys(fields)
|
|
|
|
|
+ if (!scene || fieldNames.length === 0) {
|
|
|
|
|
+ resolve({ updatedFields: [], skippedFields: fieldNames })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 场景校验:只回写本场景登记过的字段
|
|
|
|
|
+ var allowedFields = {}
|
|
|
|
|
+ var skippedFields = []
|
|
|
|
|
+ for (var i = 0; i < fieldNames.length; i++) {
|
|
|
|
|
+ var name = fieldNames[i]
|
|
|
|
|
+ var usage = fieldUsage[name]
|
|
|
|
|
+ if (!usage) {
|
|
|
|
|
+ skippedFields.push(name)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ var inScene = false
|
|
|
|
|
+ var targetsOk = false
|
|
|
|
|
+ for (var j = 0; j < usage.scenes.length; j++) {
|
|
|
|
|
+ var sc = usage.scenes[j]
|
|
|
|
|
+ if (sc.scene === scene) {
|
|
|
|
|
+ inScene = true
|
|
|
|
|
+ if (sc.backfillTarget.indexOf(opts.targetType) >= 0) {
|
|
|
|
|
+ targetsOk = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (inScene && targetsOk) {
|
|
|
|
|
+ allowedFields[name] = fields[name]
|
|
|
|
|
+ } else {
|
|
|
|
|
+ skippedFields.push(name)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (Object.keys(allowedFields).length === 0) {
|
|
|
|
|
+ resolve({ updatedFields: [], skippedFields: skippedFields })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 修改已有值 → 弹确认
|
|
|
|
|
+ var prev = opts.prevValues || {}
|
|
|
|
|
+ var changedNames = []
|
|
|
|
|
+ for (var k in allowedFields) {
|
|
|
|
|
+ var oldVal = prev[k]
|
|
|
|
|
+ var newVal = allowedFields[k]
|
|
|
|
|
+ if (oldVal && oldVal !== newVal && !(newVal === '' && oldVal === newVal)) {
|
|
|
|
|
+ changedNames.push(fieldUsage[k].label)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function doBackfill() {
|
|
|
|
|
+ apiBackfill({
|
|
|
|
|
+ targetType: opts.targetType,
|
|
|
|
|
+ targetId: opts.targetId || null,
|
|
|
|
|
+ fields: allowedFields
|
|
|
|
|
+ }).then(function(res) {
|
|
|
|
|
+ if (res && res.code === 200) {
|
|
|
|
|
+ // 3. 成功后更新本地缓存
|
|
|
|
|
+ var profile = (res.data && res.data.profile) || {}
|
|
|
|
|
+ if (opts.targetType === 'user') {
|
|
|
|
|
+ var userInfo = uni.getStorageSync('userInfo') || {}
|
|
|
|
|
+ for (var f in allowedFields) {
|
|
|
|
|
+ if (profile[f] !== undefined) {
|
|
|
|
|
+ userInfo[f] = profile[f]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ uni.setStorageSync('userInfo', userInfo)
|
|
|
|
|
+ }
|
|
|
|
|
+ resolve({
|
|
|
|
|
+ updatedFields: (res.data && res.data.updatedFields) || [],
|
|
|
|
|
+ skippedFields: skippedFields
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ reject(new Error((res && res.message) || '回写失败'))
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch(function(err) {
|
|
|
|
|
+ reject(err)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (changedNames.length > 0) {
|
|
|
|
|
+ uni.showModal({
|
|
|
|
|
+ title: '同步更新资料',
|
|
|
|
|
+ content: '检测到您修改了' + changedNames.join('、') + ',是否同步更新' + (opts.targetType === 'member' ? '该家人的' : '您的') + '个人信息?',
|
|
|
|
|
+ confirmText: '同步更新',
|
|
|
|
|
+ cancelText: '仅本次使用',
|
|
|
|
|
+ success: function(modalRes) {
|
|
|
|
|
+ if (modalRes.confirm) {
|
|
|
|
|
+ doBackfill()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ resolve({ updatedFields: [], skippedFields: skippedFields })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ doBackfill()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**验证**: 语法正确;`npm run build:mp-weixin` 通过。
|
|
|
|
|
+**提交**: `feat: 注册引导-回写工具profile-backfill`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 8:user-edit.vue 新用户流程最小化
|
|
|
|
|
+
|
|
|
|
|
+修改 `cfc-frontend/pages/user-edit/user-edit.vue`。
|
|
|
|
|
+
|
|
|
|
|
+### 8.1 隐藏新用户流程的非最小字段
|
|
|
|
|
+
|
|
|
|
|
+在模板中,给「真实姓名」form-item(L64)所在的整段包裹一个容器并加 `v-if`。具体:在 L62 昵称 form-item 的 `</view>` 之后插入:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <!-- 新用户流程:仅展示头像+昵称,其余字段按需在功能页补充 -->
|
|
|
|
|
+ <view v-if="!isNewUserFlow">
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+并在「饮食偏好」form-item(L155)的 `</view>` 之后、`<!-- 成长规划师额外字段 -->`(L157)之前插入闭合标签 `</view>`。
|
|
|
|
|
+
|
|
|
|
|
+> 注意:`form.role === 'teacher'` 的字段块(L158-188)不在包裹内(teacher 新用户流程仍需展示规划师字段,且 `isNewUserFlow && form.role === 'teacher'` 时家庭绑定区已用 `form.role !== 'teacher'` 排除)。
|
|
|
|
|
+
|
|
|
|
|
+### 8.2 家庭绑定区改造(L212-233)
|
|
|
|
|
+
|
|
|
|
|
+把整个家庭绑定区(L212-233,含邀请码输入 + 创建/加入家庭按钮)替换为:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <!-- 新用户流程:保存资料 / 跳过(不再创建或加入家庭) -->
|
|
|
|
|
+ <view class="family-section" v-if="isEdit && form.role !== 'teacher' && isNewUserFlow">
|
|
|
|
|
+ <view class="family-actions">
|
|
|
|
|
+ <button class="btn btn-primary" @click="saveUserInfo" :loading="loading">
|
|
|
|
|
+ 保存资料
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button class="btn btn-skip" @click="skip">
|
|
|
|
|
+ 跳过
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 8.3 `saveUserInfo` 适配新用户流程(L463-501)
|
|
|
|
|
+
|
|
|
|
|
+替换方法体为:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ async saveUserInfo() {
|
|
|
|
|
+ // 新用户流程:昵称可跳过(跳过时用默认昵称);老用户流程:昵称必填
|
|
|
|
|
+ if (!this.isNewUserFlow && !this.form.nickname) {
|
|
|
|
|
+ uni.showToast({ title: '请输入昵称', icon: 'none' })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.isNewUserFlow && !this.form.nickname) {
|
|
|
|
|
+ var phone = this.form.phone || ''
|
|
|
|
|
+ this.form.nickname = phone ? '用户' + phone.substring(phone.length - 4) : '微信用户'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 从 AddressPicker 读取地址文本
|
|
|
|
|
+ if (this.$refs.addressPicker && this.$refs.addressPicker.fullAddress) {
|
|
|
|
|
+ this.form.address = this.$refs.addressPicker.fullAddress
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.loading = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ await updateUserInfo({
|
|
|
|
|
+ nickname: this.form.nickname,
|
|
|
|
|
+ realName: this.form.realName,
|
|
|
|
|
+ gender: this.form.gender,
|
|
|
|
|
+ idCard: this.form.idCard,
|
|
|
|
|
+ birthday: this.form.birthday,
|
|
|
|
|
+ avatar: this.form.avatar,
|
|
|
|
|
+ mascot: this.form.mascot,
|
|
|
|
|
+ ethnicity: this.form.ethnicity,
|
|
|
|
|
+ bloodType: this.form.bloodType,
|
|
|
|
|
+ highestEducation: this.form.highestEducation,
|
|
|
|
|
+ maritalStatus: this.form.maritalStatus,
|
|
|
|
|
+ hobbies: this.form.hobbies,
|
|
|
|
|
+ dietPreferences: this.form.dietPreferences,
|
|
|
|
|
+ address: this.form.address
|
|
|
|
|
+ })
|
|
|
|
|
+ uni.showToast({ title: '保存成功', icon: 'success' })
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ if (this.isNewUserFlow) {
|
|
|
|
|
+ this.navigateToHome()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.navigateBack()
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 1500)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ uni.showToast({ title: '保存失败', icon: 'none' })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.loading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 8.4 保留逻辑
|
|
|
|
|
+
|
|
|
|
|
+- `joinFamilyAction`(L545)与 `createFamilyAction`(L593)方法体保留(非新用户流程/其他入口可能复用),但模板已无引用——保留方法不报错
|
|
|
|
|
+- `skip()`(L635)与 `navigateToHome()`(L638)不变
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `npm run build:mp-weixin` 通过。
|
|
|
|
|
+**提交**: `feat: 注册引导-user-edit新用户流程最小化`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 9:profile.vue 个人中心补充卡片
|
|
|
|
|
+
|
|
|
|
|
+修改 `cfc-frontend/pages/profile/profile.vue`。
|
|
|
|
|
+
|
|
|
|
|
+### 9.1 模板:ProfileHeader 之后加卡片(L17 后)
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <!-- 个人信息完善卡片(全部填满后隐藏) -->
|
|
|
|
|
+ <view class="profile-card" v-if="isLoggedIn && profileProgress < 14" @click="goToEditInfo">
|
|
|
|
|
+ <view class="profile-card-left">
|
|
|
|
|
+ <text class="profile-card-title">个人信息</text>
|
|
|
|
|
+ <text class="profile-card-desc">已完善 {{ profileProgress }}/14 个字段,点击继续补充</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="profile-card-arrow">›</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 9.2 data 新增(L66 data() 内)
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ profileProgress: 0,
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 9.3 onShow 中加载(L110 `this.loadHealthScoreData()` 之后)
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ this.loadProfileProgress()
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 9.4 methods 新增
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ loadProfileProgress() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ getUserInfo().then(function(res) {
|
|
|
|
|
+ if (res && res.code === 200 && res.data) {
|
|
|
|
|
+ var user = res.data
|
|
|
|
|
+ var fields = ['nickname', 'avatar', 'realName', 'gender', 'birthday', 'birthHour', 'ethnicity', 'bloodType', 'highestEducation', 'maritalStatus', 'address', 'hobbies', 'dietPreferences', 'phone']
|
|
|
|
|
+ var filled = 0
|
|
|
|
|
+ for (var i = 0; i < fields.length; i++) {
|
|
|
|
|
+ var v = user[fields[i]]
|
|
|
|
|
+ if (v !== null && v !== undefined && v !== '') filled++
|
|
|
|
|
+ }
|
|
|
|
|
+ self.profileProgress = filled
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch(function() {})
|
|
|
|
|
+ },
|
|
|
|
|
+ // 修复:ProfileHeader 头像点击进入编辑页(原方法缺失)
|
|
|
|
|
+ goToEditInfo() {
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/user-edit/user-edit' })
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 9.5 import 补充(L46 ProfileHeader import 附近)
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { getUserInfo } from '../../utils/api.js'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> 若文件已 import getUserInfo 则跳过。同时在 `<style scoped>` 末尾追加卡片样式:
|
|
|
|
|
+
|
|
|
|
|
+```css
|
|
|
|
|
+.profile-card {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: row;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ margin: 20rpx 30rpx;
|
|
|
|
|
+ padding: 28rpx 32rpx;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.profile-card-left {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+}
|
|
|
|
|
+.profile-card-title {
|
|
|
|
|
+ font-size: 30rpx;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ color: #333;
|
|
|
|
|
+}
|
|
|
|
|
+.profile-card-desc {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #999;
|
|
|
|
|
+ margin-top: 8rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.profile-card-arrow {
|
|
|
|
|
+ font-size: 40rpx;
|
|
|
|
|
+ color: #ccc;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `npm run build:mp-weixin` 通过。
|
|
|
|
|
+**提交**: `feat: 注册引导-个人中心个人信息完善卡片`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 10:活动报名接入「就地补充 + 回写」(P1)
|
|
|
|
|
+
|
|
|
|
|
+修改 `cfc-frontend/pages/activity/activity-detail/activity-detail.vue`。
|
|
|
|
|
+
|
|
|
|
|
+### 10.1 import(script 顶部)
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { backfillProfile } from '../../../utils/profile-backfill.js'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> 确认相对路径:activity-detail.vue 位于 `pages/activity/activity-detail/`,到 utils 为 `../../../utils/`。
|
|
|
|
|
+
|
|
|
|
|
+### 10.2 `loadFamilyMembers` 成员对象补充 targetType 标记(L579-597)
|
|
|
|
|
+
|
|
|
|
|
+parents 分支(L578-585)的 push 对象追加:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ targetType: 'user',
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+children 分支(L590-597)的 push 对象追加:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ targetType: 'member',
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 10.3 `toggleFamilyMember` 记录原始值(L616-621)
|
|
|
|
|
+
|
|
|
|
|
+push 对象追加 prevValues:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ this.registrants.push({
|
|
|
|
|
+ memberId: member.id,
|
|
|
|
|
+ name: member.name,
|
|
|
|
|
+ phone: member.phone,
|
|
|
|
|
+ childId: member.childId,
|
|
|
|
|
+ targetType: member.targetType,
|
|
|
|
|
+ prevPhone: member.phone
|
|
|
|
|
+ })
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 10.4 `submitRegistration` 后触发回写(L671-707 校验之后、调用 doCreateOrder 之前)
|
|
|
|
|
+
|
|
|
|
|
+在校验循环(L678-689)之后、`if (self.hasFee...)`(L691)之前插入:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ // 就地补充的手机号自动回写(仅家庭成员;外部报名人 memberId=null 不回写)
|
|
|
|
|
+ var backfillTasks = []
|
|
|
|
|
+ for (var b = 0; b < self.registrants.length; b++) {
|
|
|
|
|
+ var entryB = self.registrants[b]
|
|
|
|
|
+ if (!entryB.memberId) continue
|
|
|
|
|
+ var phoneB = (entryB.phone || '').trim()
|
|
|
|
|
+ if (phoneB === '') continue
|
|
|
|
|
+ backfillTasks.push(backfillProfile({
|
|
|
|
|
+ targetType: entryB.targetType,
|
|
|
|
|
+ targetId: entryB.memberId,
|
|
|
|
|
+ fields: { phone: phoneB },
|
|
|
|
|
+ scene: 'activity-signup',
|
|
|
|
|
+ prevValues: { phone: entryB.prevPhone }
|
|
|
|
|
+ }))
|
|
|
|
|
+ }
|
|
|
|
|
+ // 回写失败不阻塞报名主流程,仅静默记录
|
|
|
|
|
+ if (backfillTasks.length > 0) {
|
|
|
|
|
+ Promise.all(backfillTasks).catch(function(err) {
|
|
|
|
|
+ console.warn('[backfill] 活动报名信息回写失败', err)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> memberId 格式说明:`loadFamilyMembers` 中 parents 的 id 为 `'p_' + parents[i].id`(对应 users.id),children 的 id 为 `'c_' + children[j].id`(对应 family_members.id)。`backfillProfile` 会把 `memberId` 直接作为 `targetId` 传给后端——**需要前端转换**:`targetType='user'` 时 targetId 应为去掉 `p_` 前缀的纯数字。因此把 10.4 中的 `targetId` 改为:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ var rawId = String(entryB.memberId)
|
|
|
|
|
+ var realId = rawId.indexOf('_') >= 0 ? rawId.substring(2) : rawId
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+并把 `targetId: entryB.memberId` 替换为 `targetId: realId`。
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `npm run build:mp-weixin` 通过。
|
|
|
|
|
+**提交**: `feat: 注册引导-活动报名接入就地补充回写`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 11:无家庭引导组件 + 核心页接入(P2)
|
|
|
|
|
+
|
|
|
|
|
+### 11.1 新建 `cfc-frontend/components/family-guard.vue`
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view v-if="visible" class="family-guard-mask" @click="close">
|
|
|
|
|
+ <view class="family-guard-panel" @click.stop>
|
|
|
|
|
+ <text class="family-guard-title">创建家庭后即可使用</text>
|
|
|
|
|
+ <text class="family-guard-desc">家庭是任务、测评、成长档案等功能的基础,创建后邀请家人加入</text>
|
|
|
|
|
+ <view class="family-guard-input-wrap">
|
|
|
|
|
+ <input class="family-guard-input" v-model="familyName" placeholder="请输入家庭名称(默认:我的家庭)" maxlength="20" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="family-guard-actions">
|
|
|
|
|
+ <button class="family-guard-btn family-guard-btn-primary" @click="doCreate" :loading="loading">去创建家庭</button>
|
|
|
|
|
+ <button class="family-guard-btn family-guard-btn-plain" @click="close">暂不创建</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { createFamily } from '../utils/api.js'
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ name: 'FamilyGuard',
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ visible: false,
|
|
|
|
|
+ loading: false,
|
|
|
|
|
+ familyName: ''
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ show() {
|
|
|
|
|
+ this.familyName = ''
|
|
|
|
|
+ this.visible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ close() {
|
|
|
|
|
+ this.visible = false
|
|
|
|
|
+ },
|
|
|
|
|
+ doCreate() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ var name = (this.familyName || '').trim() || '我的家庭'
|
|
|
|
|
+ this.loading = true
|
|
|
|
|
+ createFamily(name).then(function(res) {
|
|
|
|
|
+ self.loading = false
|
|
|
|
|
+ if (res && res.code === 200 && res.data) {
|
|
|
|
|
+ uni.setStorageSync('familyId', res.data)
|
|
|
|
|
+ uni.setStorageSync('currentFamilyId', res.data)
|
|
|
|
|
+ // 同步 userInfo 缓存
|
|
|
|
|
+ var userInfo = uni.getStorageSync('userInfo') || {}
|
|
|
|
|
+ userInfo.familyId = res.data
|
|
|
|
|
+ uni.setStorageSync('userInfo', userInfo)
|
|
|
|
|
+ self.visible = false
|
|
|
|
|
+ uni.showToast({ title: '家庭创建成功', icon: 'success' })
|
|
|
|
|
+ if (self.$listeners && self.$listeners.created) {
|
|
|
|
|
+ self.$emit('created', res.data)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.showToast({ title: (res && res.message) || '创建失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch(function() {
|
|
|
|
|
+ self.loading = false
|
|
|
|
|
+ uni.showToast({ title: '网络异常', icon: 'none' })
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.family-guard-mask {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
|
|
+ z-index: 999;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-panel {
|
|
|
|
|
+ width: 600rpx;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 20rpx;
|
|
|
|
|
+ padding: 40rpx 36rpx;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-title {
|
|
|
|
|
+ font-size: 34rpx;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ color: #333;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-desc {
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ color: #999;
|
|
|
|
|
+ margin-top: 16rpx;
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-input-wrap {
|
|
|
|
|
+ margin-top: 30rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-input {
|
|
|
|
|
+ height: 80rpx;
|
|
|
|
|
+ background: #f5f7fa;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ padding: 0 24rpx;
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-actions {
|
|
|
|
|
+ margin-top: 36rpx;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-btn {
|
|
|
|
|
+ height: 84rpx;
|
|
|
|
|
+ border-radius: 42rpx;
|
|
|
|
|
+ font-size: 30rpx;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ margin-top: 16rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-btn-primary {
|
|
|
|
|
+ background: #F97316;
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+}
|
|
|
|
|
+.family-guard-btn-plain {
|
|
|
|
|
+ background: #f5f7fa;
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 11.2 接入 `pages/wealth-sub/insurance-planning.vue`
|
|
|
|
|
+
|
|
|
|
|
+- 模板根节点内(最末尾)加:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <family-guard ref="familyGuard" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- script 加 import 与 components 注册:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import FamilyGuard from '../../components/family-guard.vue'
|
|
|
|
|
+// components: { FamilyGuard }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- 找到现有「请先加入家庭」处理处(`if (!familyId)` / toast),替换为调用引导组件:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+if (!familyId) {
|
|
|
|
|
+ this.$refs.familyGuard.show()
|
|
|
|
|
+ return
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 11.3 接入 `pages/growth/index.vue`
|
|
|
|
|
+
|
|
|
|
|
+- 同样加 `<family-guard ref="familyGuard" />`、import、components 注册
|
|
|
|
|
+- 将现有 `if (!familyId)` 静默返回处替换为 `this.$refs.familyGuard.show(); return`
|
|
|
|
|
+
|
|
|
|
|
+### 11.4 接入 `pages/profile/components/ProfileMenu.vue`
|
|
|
|
|
+
|
|
|
|
|
+- 同样加组件、import、注册
|
|
|
|
|
+- 将「请先加入家庭」toast 处替换为引导弹窗
|
|
|
|
|
+
|
|
|
|
|
+> 三个页面各自保留原业务逻辑(`familyId` 为空时不再静默,改为弹窗引导),创建成功后 `created` 事件回调里刷新页面数据(`onShow` 已有刷新逻辑则无需额外处理)。
|
|
|
|
|
+
|
|
|
|
|
+**验证**: `npm run build:mp-weixin` 通过。
|
|
|
|
|
+**提交**: `feat: 注册引导-无家庭引导组件接入核心页`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 12:验收与文档收尾
|
|
|
|
|
+
|
|
|
|
|
+1. 后端:`mvn clean compile` 退出码 0
|
|
|
|
|
+2. 前端:`npm run build:mp-weixin` 通过
|
|
|
|
|
+3. 检查路由重复:
|
|
|
|
|
+ ```bash
|
|
|
|
|
+ grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep backfill
|
|
|
|
|
+ ```
|
|
|
|
|
+ 应仅出现 `/profile/backfill` 一处(无冲突)
|
|
|
|
|
+4. 检查 Bean 命名:`ProfileBackfillDTO` 为 DTO 无 Bean 冲突;`FamilyGuard` 前端组件名与现有组件无冲突(`grep -rn "FamilyGuard" cfc-frontend/components/`)
|
|
|
|
|
+5. 更新 `docs/superpowers/PROJECT-OVERVIEW.md`:本计划对应规格条目状态改为「已实施」、追加计划文档索引
|
|
|
|
|
+6. 提交:`docs: 注册引导实施计划完成`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 自检记录
|
|
|
|
|
+
|
|
|
|
|
+- **规格覆盖度**:规格第 6.1(任务3)、6.2(任务1)、6.3(任务2)、6.4(任务4)、7.1(任务8)、7.2(任务9)、7.3(任务6)、7.4(任务7)、7.5(任务10)、7.6(任务11)、9 验收标准(任务12)——全部覆盖
|
|
|
|
|
+- **占位符扫描**:无 TODO/待定;每个任务含完整代码
|
|
|
|
|
+- **类型一致性**:`backfillProfile` 前后端签名一致(targetType/targetId/fields);`needsFamily` 在 8 个装配点一致;memberId 前缀转换(`p_`/`c_` → 纯数字 targetId)已在任务 10.4 定义并在 10.2-10.3 建立 targetType
|