Procházet zdrojové kódy

feat: 补任务编排流程任务+保单上传AI解析+统一TabBar+服务中心页(TASK-SYS-248)

E2E Test Bot před 11 hodinami
rodič
revize
984c16cef9

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11151,6 +11151,8 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         seedGuidePopupData();
         addGiftAmountColumn();
         createGiftMembershipProgressTable();
+        addFlowTaskColumnsToTasksTable();
+        addPolicyTermsColumn();
 
         // 迁移327: 创建 report_feedback 表(报告反馈+AI解答)
         try {
@@ -11678,4 +11680,15 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移338: 创建 gift_membership_progress 表失败: {}", e.getMessage());
         }
     }
+
+    // 迁移339: tasks 表加 is_flow_task / successor_task_ids 列(TASK-SYS-248 流程任务前置/后置逻辑)
+    private void addFlowTaskColumnsToTasksTable() {
+        ensureColumn("tasks", "is_flow_task", "TINYINT DEFAULT 0 COMMENT '是否为流程任务:1=是(有前置/后置任务),0=否'");
+        ensureColumn("tasks", "successor_task_ids", "TEXT NULL COMMENT '后置任务IDs JSON: [1,2,3],任意一个完成则解锁(OR 逻辑)'");
+    }
+
+    // 迁移339: insurance_policies 表加 policy_terms 列(保单原始条款,AI 解析后原样保存)
+    private void addPolicyTermsColumn() {
+        ensureColumn("insurance_policies", "policy_terms", "TEXT COMMENT '保单原始条款(AI 解析后原样保存)'");
+    }
 }

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

@@ -328,4 +328,23 @@ public class TaskController {
         boolean success = taskService.rejectTask(id, memberId);
         return Result.success(success);
     }
+
+    /**
+     * 获取家庭成员可关联的任务列表(含所有状态),供创建任务时选择前置/后置任务
+     */
+    @Operation(summary = "获取可关联任务列表(前置/后置任务选择器)")
+    @PostMapping("/list")
+    public Result<List<Task>> getRelatedTaskList(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+        Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
+        String titleKeyword = params.get("titleKeyword") != null ? params.get("titleKeyword").toString() : null;
+        List<Task> tasks = taskService.getRelatedTaskList(familyId, memberId, titleKeyword);
+        return Result.success(tasks);
+    }
 }

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/wealth/InsurancePolicyController.java

@@ -9,7 +9,9 @@ import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
 import java.util.List;
@@ -75,4 +77,28 @@ public class InsurancePolicyController {
         insurancePolicyService.deletePolicy(id);
         return Result.success("删除成功");
     }
+
+    @Operation(summary = "上传保单文件并AI解析(返回提取字段预览)")
+    @PostMapping("/upload")
+    public Result<Map<String, Object>> upload(
+            @RequestParam("file") MultipartFile file,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        if (file.isEmpty()) {
+            return Result.error("文件不能为空");
+        }
+        String originalFilename = file.getOriginalFilename();
+        if (originalFilename == null
+                || (!(originalFilename.toLowerCase().endsWith(".pdf"))
+                    && (file.getContentType() == null
+                        || !file.getContentType().equalsIgnoreCase("application/pdf")))) {
+            return Result.error("仅支持PDF文件");
+        }
+        try {
+            Map<String, Object> result = insurancePolicyService.uploadAndParse(file, userId, familyId);
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error("上传解析失败: " + e.getMessage());
+        }
+    }
 }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateTaskDTO.java

@@ -57,6 +57,12 @@ public class CreateTaskDTO {
     // TASK-SYS-248: 前置任务ID(完成后才解锁本任务)
     private Long prerequisiteTaskId;
 
+    // TASK-SYS-248: 是否为流程任务:1=是(有前置/后置任务),0=否
+    private Integer isFlowTask;
+
+    // TASK-SYS-248: 后置任务IDs JSON: [1,2,3],任意一个完成则解锁(OR 逻辑)
+    private String successorTaskIds;
+
     // TASK-SYS-248: 联动动作: minigame/focus/survey/article/activity/cart/order
     private String actionType;
 

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/InsurancePolicy.java

@@ -43,6 +43,8 @@ public class InsurancePolicy implements Serializable {
 
     private String remark;
 
+    private String policyTerms;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Task.java

@@ -121,6 +121,12 @@ private Date acceptDeadline;
     /** TASK-SYS-248: 前置任务ID:完成后才解锁本任务 */
     private Long prerequisiteTaskId;
 
+    /** TASK-SYS-248: 是否为流程任务:1=是(有前置/后置任务),0=否 */
+    private Integer isFlowTask;
+
+    /** TASK-SYS-248: 后置任务IDs JSON: [1,2,3],任意一个完成则解锁(OR 逻辑) */
+    private String successorTaskIds;
+
     /** TASK-SYS-248: 联动动作: minigame/focus/survey/article/activity/cart/order */
     private String actionType;
 

+ 180 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/InsurancePolicyService.java

@@ -4,13 +4,27 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.InsurancePolicy;
 import com.etotem.cfc.mapper.InsurancePolicyMapper;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.io.File;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import com.etotem.cfc.util.SortUtil;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+import com.fasterxml.jackson.databind.ObjectMapper;
 
 @Slf4j
 @Service
@@ -22,6 +36,15 @@ public class InsurancePolicyService {
     @Resource
     private ReportSummaryService reportSummaryService;
 
+    @Resource
+    private StorageService storageService;
+
+    @Value("${langgraph.base-url:http://localhost:9000}")
+    private String langgraphBaseUrl;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
     public List<InsurancePolicy> getPolicies(Long userId, String status) {
         LambdaQueryWrapper<InsurancePolicy> wrapper = new LambdaQueryWrapper<InsurancePolicy>()
                 .eq(InsurancePolicy::getUserId, userId);
@@ -70,4 +93,161 @@ public class InsurancePolicyService {
             log.warn("标记保单废弃失败 policyId={}", id, e);
         }
     }
+
+    /**
+     * 上传保单PDF文件,调用LangGraph AI解析,返回提取的字段预览(不自动入库)。
+     * 前端拿到结果后展示给用户确认,用户修改后再调 create/update 入库。
+     */
+    public Map<String, Object> uploadAndParse(MultipartFile file, Long userId, Long familyId) throws Exception {
+        // 1. 保存文件
+        String fileUrl = storageService.storeFile(file, "insurance-policies");
+
+        // 2. 读取原始文件字节,用于提取原文条款(PDF全文文本)
+        byte[] fileBytes = file.getBytes();
+        File tempFile = File.createTempFile("policy_", "_" + file.getOriginalFilename());
+        Files.write(tempFile.toPath(), fileBytes);
+        tempFile.deleteOnExit();
+
+        // 3. 调用 LangGraph 通用报告解析端点
+        Map<String, Object> parsedResult = callLangGraphParse(tempFile.getAbsolutePath());
+
+        // 4. 从解析结果中提取保单关键字段(如果有的话)
+        Map<String, Object> extracted = extractInsuranceFields(parsedResult);
+
+        // 5. 提取原始文本作为保单条款
+        String rawText = extractPdfText(fileBytes);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("fileUrl", fileUrl);
+        result.put("originalFilename", file.getOriginalFilename());
+        result.put("extracted", extracted);
+        result.put("rawText", rawText);
+        result.put("langGraphResult", parsedResult);
+        return result;
+    }
+
+    /**
+     * 调用 LangGraph 通用报告解析端点
+     */
+    private Map<String, Object> callLangGraphParse(String filePath) throws Exception {
+        try {
+            String url = langgraphBaseUrl + "/api/v1/report/parse";
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("file_path", filePath);
+            body.put("user_id", null);
+            body.put("family_id", null);
+
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                Map<String, Object> respMap = objectMapper.readValue(response.getBody(), Map.class);
+                if (respMap != null && Integer.valueOf(200).equals(respMap.get("code"))) {
+                    return (Map<String, Object>) respMap.get("data");
+                }
+            }
+        } catch (Exception e) {
+            log.warn("LangGraph 解析保单失败,使用空结果: {}", e.getMessage());
+        }
+        return new LinkedHashMap<>();
+    }
+
+    /**
+     * 从 LangGraph 解析结果中提取保单相关字段
+     */
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> extractInsuranceFields(Map<String, Object> lgResult) {
+        Map<String, Object> extracted = new LinkedHashMap<>();
+        if (lgResult == null) return extracted;
+
+        // 尝试从 sections 或 overview 中提取信息
+        Object overview = lgResult.get("overview");
+        if (overview instanceof Map) {
+            Map<String, Object> ov = (Map<String, Object>) overview;
+            extracted.put("insuranceCompany", ov.get("insuranceCompany"));
+            extracted.put("policyType", ov.get("policyType"));
+            extracted.put("insuredPerson", ov.get("insuredPerson"));
+            extracted.put("policyNo", ov.get("policyNo"));
+            extracted.put("premium", ov.get("premium"));
+            extracted.put("sumInsured", ov.get("sumInsured"));
+        }
+
+        // 尝试从 sections 提取
+        Object sections = lgResult.get("sections");
+        if (sections instanceof List) {
+            for (Object section : (List<?>) sections) {
+                if (section instanceof Map) {
+                    Map<String, Object> s = (Map<String, Object>) section;
+                    String title = String.valueOf(s.getOrDefault("title", ""));
+                    Object content = s.get("items");
+                    if (content instanceof List) {
+                        for (Object item : (List<?>) content) {
+                            if (item instanceof Map) {
+                                Map<String, Object> i = (Map<String, Object>) item;
+                                String name = String.valueOf(i.getOrDefault("name", ""));
+                                String value = String.valueOf(i.getOrDefault("value", ""));
+                                if (name.contains("保险") && !extracted.containsKey("insuranceCompany")) {
+                                    extracted.put("insuranceCompany", value);
+                                } else if (name.contains("保单") && !extracted.containsKey("policyName")) {
+                                    extracted.put("policyName", value);
+                                } else if (name.contains("保额") && !extracted.containsKey("sumInsured")) {
+                                    extracted.put("sumInsured", value);
+                                } else if (name.contains("保费") && !extracted.containsKey("premium")) {
+                                    extracted.put("premium", value);
+                                }
+                            }
+                        }
+                    }
+                    // 也检查 summary 内容
+                    String textContent = String.valueOf(s.getOrDefault("content", ""));
+                    if (!extracted.containsKey("summary") && textContent.length() > 10) {
+                        extracted.put("summary", textContent.substring(0, Math.min(200, textContent.length())));
+                    }
+                }
+            }
+        }
+
+        // 兜底:从 indicators 中提取
+        Object indicators = lgResult.get("indicators");
+        if (indicators instanceof List) {
+            for (Object ind : (List<?>) indicators) {
+                if (ind instanceof Map) {
+                    Map<String, Object> i = (Map<String, Object>) ind;
+                    String name = String.valueOf(i.getOrDefault("name", ""));
+                    String value = String.valueOf(i.getOrDefault("value", ""));
+                    if (name.contains("保险公司") && !extracted.containsKey("insuranceCompany")) {
+                        extracted.put("insuranceCompany", value);
+                    } else if (name.contains("保单号") && !extracted.containsKey("policyNo")) {
+                        extracted.put("policyNo", value);
+                    } else if (name.contains("被保人") && !extracted.containsKey("insuredPerson")) {
+                        extracted.put("insuredPerson", value);
+                    } else if (name.contains("保额") && !extracted.containsKey("sumInsured")) {
+                        extracted.put("sumInsured", value);
+                    } else if (name.contains("保费") && !extracted.containsKey("premium")) {
+                        extracted.put("premium", value);
+                    }
+                }
+            }
+        }
+
+        return extracted;
+    }
+
+    /**
+     * 从 PDF 文件字节中提取纯文本作为保单原始条款
+     */
+    private String extractPdfText(byte[] fileBytes) {
+        try {
+            org.apache.pdfbox.pdmodel.PDDocument document = org.apache.pdfbox.pdmodel.PDDocument.load(fileBytes);
+            org.apache.pdfbox.text.PDFTextStripper stripper = new org.apache.pdfbox.text.PDFTextStripper();
+            String text = stripper.getText(document);
+            document.close();
+            return text != null ? text.trim() : "";
+        } catch (Exception e) {
+            log.warn("提取PDF原文失败: {}", e.getMessage());
+            return "";
+        }
+    }
 }

+ 29 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -283,7 +283,7 @@ task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCate
         // B20: 会员任务标记透传(自建任务 memberOnly=0,不校验会员)
         task.setMemberOnly(dto.getMemberOnly() != null ? dto.getMemberOnly() : 0);
 
-        // TASK-SYS-248: 行为调度中心扩展字段透传
+// TASK-SYS-248: 行为调度中心扩展字段透传
         task.setPrerequisiteTaskId(dto.getPrerequisiteTaskId());
         task.setActionType(dto.getActionType());
         task.setActionConfig(dto.getActionConfig());
@@ -292,7 +292,17 @@ task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCate
         task.setStartCount(0);
         task.setMinDurationSeconds(dto.getMinDurationSeconds() != null ? dto.getMinDurationSeconds() : 0);
 
-task.setSourceType(dto.getSourceType() != null ? dto.getSourceType() : "self");
+        // TASK-SYS-248: 自动计算是否为流程任务
+        // 有前置任务 或 有后置任务 → 标记为流程任务
+        if (dto.getPrerequisiteTaskId() != null
+                || (dto.getSuccessorTaskIds() != null && !dto.getSuccessorTaskIds().isEmpty())) {
+            task.setIsFlowTask(1);
+        } else {
+            task.setIsFlowTask(0);
+        }
+        task.setSuccessorTaskIds(dto.getSuccessorTaskIds());
+
+        task.setSourceType(dto.getSourceType() != null ? dto.getSourceType() : "self");
         task.setSourceId(dto.getSourceId());
         task.setIsDailyProgress(dto.getIsDailyProgress() != null ? dto.getIsDailyProgress() : 0);
         task.setTargetValue(dto.getTargetValue());
@@ -1879,4 +1889,21 @@ return 0; // 达到每日扣分上限
         }
         return result;
     }
+
+    /**
+     * 获取家庭成员可关联的任务列表(含所有状态),供创建任务时选择前置/后置任务
+     */
+    public List<Task> getRelatedTaskList(Long familyId, Long memberId, String titleKeyword) {
+        LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<Task>()
+                .eq(Task::getFamilyId, familyId)
+                .ne(Task::getStatus, "cancelled")
+                .orderByDesc(Task::getCreatedAt);
+        if (memberId != null) {
+            wrapper.eq(Task::getFamilyMemberId, memberId);
+        }
+        if (titleKeyword != null && !titleKeyword.trim().isEmpty()) {
+            wrapper.like(Task::getTitle, titleKeyword.trim());
+        }
+        return taskMapper.selectList(wrapper);
+    }
 }

+ 3 - 0
cfc-backend/src/main/resources/schema.sql

@@ -127,6 +127,8 @@ CREATE TABLE IF NOT EXISTS tasks (
     parent_task_id BIGINT NULL COMMENT '父任务ID(购买任务→每日使用子任务链;子任务在父任务完成前不显示)',
     -- TASK-SYS-248: 行为调度中心扩展字段
     prerequisite_task_id BIGINT NULL COMMENT '前置任务ID:完成后才解锁本任务',
+    is_flow_task TINYINT DEFAULT 0 COMMENT '是否为流程任务:1=是(有前置/后置任务),0=否',
+    successor_task_ids TEXT NULL COMMENT '后置任务IDs JSON: [1,2,3],任意一个完成则解锁(OR 逻辑)',
     action_type VARCHAR(32) NULL COMMENT '联动动作: minigame/focus/survey/article/activity/cart/order',
     action_config JSON NULL COMMENT '联动参数: {"gameCode":"schulte","productId":123,"minSeconds":30,"completeMilestone":"paid"/"received","surveyId":99}',
     require_input TINYINT DEFAULT 0 COMMENT '1=需填写内容才能完成',
@@ -1794,6 +1796,7 @@ CREATE TABLE IF NOT EXISTS insurance_policies (
     status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/expired/cancelled',
     policy_no VARCHAR(100) COMMENT '保单号',
     remark TEXT COMMENT '备注',
+    policy_terms TEXT COMMENT '保单原始条款(AI 解析后原样保存)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user (user_id),

+ 7 - 128
cfc-frontend/custom-tab-bar/index.vue

@@ -1,32 +1,9 @@
 <template>
   <view class="tab-bar-wrap">
-    <!-- 普通用户 TabBar:4 Tab(5等分,中间放按钮) -->
-    <view v-if="currentRole !== 'teacher'" class="tab-bar">
+    <!-- 统一TabBar:4 Tab(首页/健康/成长/我的),所有人同一套入口 -->
+    <view class="tab-bar">
       <view
-        v-for="(item, index) in leftTabs"
-        :key="index"
-        class="tab-item"
-        :class="{ active: isActive(item) }"
-        @click="switchTab(item)"
-      >
-        <image
-          class="tab-icon"
-          :src="isActive(item) ? item.selectedIcon : item.icon"
-          mode="aspectFit"
-        ></image>
-        <text class="tab-text">{{ item.text }}</text>
-      </view>
-
-      <!-- 中间五维按钮(占 20% 槽位) -->
-      <view class="center-btn" @click="openDimPanel">
-        <view class="center-btn-inner">
-          <text class="center-btn-icon">⭐</text>
-        </view>
-        <text class="center-btn-label">五维</text>
-      </view>
-
-      <view
-        v-for="(item, index) in rightTabs"
+        v-for="(item, index) in normalTabs"
         :key="index"
         class="tab-item"
         :class="{ active: isActive(item) }"
@@ -41,25 +18,7 @@
       </view>
     </view>
 
-    <!-- 规划师 TabBar:5 Tab(不变) -->
-    <view v-else class="tab-bar">
-      <view
-        v-for="(item, index) in teacherTabs"
-        :key="index"
-        class="tab-item"
-        :class="{ active: isActive(item) }"
-        @click="switchTab(item)"
-      >
-        <image
-          class="tab-icon"
-          :src="isActive(item) ? item.selectedIcon : item.icon"
-          mode="aspectFit"
-        ></image>
-        <text class="tab-text">{{ item.text }}</text>
-      </view>
-    </view>
-
-    <!-- 五维横排展开 -->
+    <!-- 五维横排展开(五维按钮已移除,保留dim-fan弹窗) -->
     <view class="dim-fan-overlay" v-if="showDimPanel" @click="closeDimPanel">
       <view class="dim-fan-row">
         <view
@@ -99,13 +58,6 @@ export default {
         { text: '成长', pagePath: '/pages/growth-main/index', icon: '/static/tab-growth.png', selectedIcon: '/static/tab-growth-active.png' },
         { text: '我的', pagePath: '/pages/profile-main/profile', icon: '/static/tab-profile.png', selectedIcon: '/static/tab-profile-active.png' }
       ],
-      teacherTabs: [
-        { text: '首页', pagePath: '/pages/teacher/index', icon: '/static/tab-action.png', selectedIcon: '/static/tab-action-active.png' },
-        { text: '家庭', pagePath: '/pages/teacher/families', icon: '/static/tab-family.png', selectedIcon: '/static/tab-family-active.png' },
-        { text: '团队', pagePath: '/pages/teacher/team', icon: '/static/tab-team.png', selectedIcon: '/static/tab-team-active.png' },
-        { text: '成长记录', pagePath: '/pages/growth-main/index', icon: '/static/tab-growth.png', selectedIcon: '/static/tab-growth-active.png' },
-        { text: '我的', pagePath: '/pages/teacher/teacher-profile', icon: '/static/tab-profile.png', selectedIcon: '/static/tab-profile-active.png' }
-      ],
       dimensions: [
         { code: 'wealth', name: '富', desc: '利他创富', icon: '💧', color: '#F59E0B', bgColor: 'rgba(245,158,11,0.15)' },
         { code: 'action', name: '行', desc: '知行合一', icon: '🌿', color: '#10B981', bgColor: 'rgba(16,185,129,0.15)' },
@@ -115,22 +67,8 @@ export default {
       ]
     }
   },
-  computed: {
-    currentRole() {
-      return uni.getStorageSync('currentRole') || 'parent'
-    },
-    leftTabs() {
-      return this.normalTabs.slice(0, 2)
-    },
-    rightTabs() {
-      return this.normalTabs.slice(2)
-    }
-  },
   mounted() {
     this.updateSelected()
-    uni.$on('switchRole', () => {
-      this.updateSelected()
-    })
   },
   methods: {
     isActive(item) {
@@ -144,13 +82,11 @@ export default {
       if (pages.length) {
         var currentPage = pages[pages.length - 1]
         var currentPath = '/' + currentPage.route
-        var tabs = this.currentRole === 'teacher' ? this.teacherTabs : this.normalTabs
-        var idx = tabs.findIndex(function(t) { return t.pagePath === currentPath })
+        var idx = this.normalTabs.findIndex(function(t) { return t.pagePath === currentPath })
         this.currentIndex = idx >= 0 ? idx : 0
       }
     },
     switchTab(item) {
-      // TabBar 注册页用 switchTab,非 TabBar 页用 redirectTo
       var tabBarPages = ['/pages/index-home/index', '/pages/health-main/index', '/pages/growth-main/index', '/pages/profile-main/profile']
       if (tabBarPages.indexOf(item.pagePath) !== -1) {
         uni.switchTab({ url: item.pagePath })
@@ -158,23 +94,7 @@ export default {
         uni.redirectTo({ url: item.pagePath })
       }
     },
-    openDimPanel() {
-      this.showDimPanel = true
-      this.fanActive = false
-      var self = this
-      setTimeout(function() {
-        self.fanActive = true
-      }, 30)
-    },
-    closeDimPanel() {
-      this.fanActive = false
-      var self = this
-      setTimeout(function() {
-        self.showDimPanel = false
-      }, 200)
-    },
     goDimension(dim) {
-      this.closeDimPanel()
       var dimMap = {
         action: '/pages/action-detail/index',
         mind: '/pages/mind-detail/index',
@@ -210,18 +130,14 @@ export default {
   background: #fff;
   padding-bottom: env(safe-area-inset-bottom);
   border-top: 1rpx solid #eee;
-  position: relative;
 }
 
 .tab-item {
-  width: 20%;
-  flex: none;
+  flex: 1;
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  position: relative;
-  z-index: 1;
 }
 
 .tab-icon {
@@ -239,43 +155,6 @@ export default {
   color: #F97316;
 }
 
-/* 中间五维按钮(占 20% 槽位) */
-.center-btn {
-  width: 20%;
-  flex: none;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: flex-start;
-  padding-top: 0;
-  position: relative;
-  z-index: 2;
-}
-
-.center-btn-inner {
-  width: 88rpx;
-  height: 88rpx;
-  border-radius: 50%;
-  background: linear-gradient(135deg, #F97316, #FB923C);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-top: -20rpx;
-  flex-shrink: 0;
-  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.4);
-}
-
-.center-btn-icon {
-  font-size: 40rpx;
-  line-height: 1;
-}
-.center-btn-label {
-  font-size: 20rpx;
-  color: #999;
-  text-align: center;
-  margin-top: 2rpx;
-}
-
 /* 五维横排展开 */
 .dim-fan-overlay {
   position: fixed;
@@ -347,4 +226,4 @@ export default {
   from { opacity: 0; }
   to { opacity: 1; }
 }
-</style>
+</style>

+ 6 - 0
cfc-frontend/pages.json

@@ -48,6 +48,12 @@
           "custom-tab-bar": "/custom-tab-bar/index"
         }
       }
+    },
+    {
+      "path": "pages/service-center/index",
+      "style": {
+        "navigationBarTitleText": "服务中心"
+      }
     }
   ],
   "subPackages": [

+ 7 - 3
cfc-frontend/pages/index-home/index.vue

@@ -1426,16 +1426,20 @@ title: '添加家庭成员',
       const role = uni.getStorageSync('role') || 'parent'
       const currentRole = uni.getStorageSync('currentRole')
 
+      // 所有人统一以家长身份进入,不再进行角色分流
+      // 保留 currentRole 用于 TabBar 显示,但不再做 teacher 专用处理
       if (!currentRole) {
         return role
       }
 
-      // 非规划师账号不允许落到规划师首页
-      if (currentRole === 'teacher' && role !== 'teacher') {
+      // 规则:所有人使用 role(家长为主),currentRole 仅作为备用
+      // 如果是 teacher 身份,仍然使用 role 返回 parent,因为不再有专属 TabBar
+      if (currentRole === 'teacher') {
+        // 教师角色不再有专属 TabBar 入口,返回家长 role
         return role
       }
 
-      // 只有家长账号允许切换到孩子视角
+      // 只有家长账号允许切换到孩子视角(保留原有逻辑)
       if (currentRole === 'child' && role !== 'parent') {
         return role
       }

+ 113 - 48
cfc-frontend/pages/profile-main/profile.vue

@@ -1,6 +1,5 @@
 <template>
   <view class="container">
-
     <!-- 未登录 -->
     <view v-if="!isLoggedIn">
       <view class="login-prompt">
@@ -246,6 +245,29 @@
         </view>
       </view>
 
+      <!-- 规划师服务中心入口(仅规划师可见) -->
+      <view class="service-center-section" v-if="role === 'teacher' && teacherStatus === 'approved'">
+        <view class="service-center-card" @click="goToServiceCenter">
+          <view class="service-center-icon">👨‍🏫</view>
+          <view class="service-center-info">
+            <text class="service-center-title">服务中心</text>
+            <text class="service-center-desc">待处理任务:{{ pendingTasks }} 条 · 消息:{{ unreadMessages }} 条</text>
+          </view>
+          <text class="service-center-arrow">›</text>
+        </view>
+      </view>
+
+      <!-- 规划师待审核提示 -->
+      <view class="service-center-section" v-if="role === 'teacher' && teacherStatus !== 'approved'">
+        <view class="service-center-card disabled-card">
+          <view class="service-center-icon">👨‍🏫</view>
+          <view class="service-center-info">
+            <text class="service-center-title">服务中心</text>
+            <text class="service-center-desc">{{ teacherStatus === 'pending' ? '申请审核中,暂未开放' : teacherStatus === 'rejected' ? '申请未通过,请联系管理员' : '规划师服务' }}</text>
+          </view>
+        </view>
+      </view>
+
       <!-- 设置 + 退出 -->
       <view class="bottom-menu-section">
         <view class="menu-row" @click="goToSettings">
@@ -270,7 +292,8 @@ import {
   getTodayTasksByCategory, getChallengeList, getMyMembership,
   verifyPassword, getPointsBalance, getUserInfo,
   cartCount, getOrderCounts, getReferralSummary,
-  getProfileStats, getMyReferrer, getTeamConsumption
+  getProfileStats, getMyReferrer, getTeamConsumption,
+  getTeacherInfo, getTeacherPendingTasks, getTeacherUnreadCount
 } from '../../utils/api.js'
 
 export default {
@@ -304,9 +327,10 @@ export default {
       cartCount: 0,
       // 待办
       pendingTasks: 0,
+      unreadMessages: 0,
+      teacherStatus: 'pending',
       profileProgress: 0,
       myReferrer: null,
-      
     }
   },
   computed: {
@@ -336,17 +360,44 @@ export default {
       return
     }
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
-    if (role === 'teacher') {
-      uni.redirectTo({ url: '/pages/teacher/teacher-profile' })
-      return
-    }
     this.role = role
     this.isSwitchedChild = uni.getStorageSync('isSwitchedChild') || false
     this.nickname = (uni.getStorageSync('userInfo') || {}).nickname || ''
-        this.loadAllData()
-        this.loadMyReferrer()
-      },
+
+    // 规划师加载额外数据
+    if (role === 'teacher') {
+      this.loadTeacherStatus()
+      this.loadTeacherData()
+    } else {
+      this.loadAllData()
+      this.loadMyReferrer()
+    }
+  },
   methods: {
+    loadTeacherStatus() {
+      var self = this
+      getTeacherInfo().then(function(res) {
+        if (res && res.code === 200 && res.data) {
+          self.teacherStatus = res.data.verificationStatus || res.data.status || 'pending'
+        }
+      }).catch(function() {})
+    },
+    loadTeacherData() {
+      var self = this
+      // 并行加载待处理任务和未读消息
+      getTeacherPendingTasks().then(function(res) {
+        if (res && res.data && Array.isArray(res.data)) {
+          self.pendingTasks = res.data.length
+        } else if (res && res.data && res.data.pendingCount != null) {
+          self.pendingTasks = res.data.pendingCount
+        }
+      }).catch(function() {})
+      getTeacherUnreadCount().then(function(res) {
+        if (res && res.data != null) {
+          self.unreadMessages = res.data
+        }
+      }).catch(function() {})
+    },
     loadAllData() {
       this.loadUserInfo()
       this.loadMembership()
@@ -474,6 +525,9 @@ export default {
         console.error('获取推荐人失败', e)
       }
     },
+    goToServiceCenter() {
+      uni.navigateTo({ url: '/pages/service-center/index' })
+    },
     goTeamTree() {
       uni.navigateTo({ url: '/pages/profile/team-tree' })
     },
@@ -898,7 +952,7 @@ export default {
   white-space: nowrap;
 }
 
-/* 成就统计 */
+/* 个人成就 */
 .achievement-section {
   margin: 0 30rpx 20rpx;
   background: #fff;
@@ -1060,6 +1114,53 @@ export default {
   background: #F2F2F2;
 }
 
+/* 规划师服务中心入口 */
+.service-center-section {
+  margin: 0 30rpx 20rpx;
+}
+.service-center-card {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border-radius: 20rpx;
+  padding: 24rpx;
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  box-shadow: 0 4rpx 16rpx rgba(102,126,234,0.25);
+}
+.service-center-card.disabled-card {
+  background: linear-gradient(135deg, #e2e8f0, #94a3b8);
+  box-shadow: none;
+  opacity: 0.8;
+}
+.service-center-icon {
+  font-size: 48rpx;
+}
+.service-center-info {
+  flex: 1;
+}
+.service-center-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #fff;
+  display: block;
+}
+.service-center-desc {
+  font-size: 22rpx;
+  color: rgba(255,255,255,0.8);
+  margin-top: 6rpx;
+  display: block;
+}
+.service-center-card.disabled-card .service-center-title {
+  color: #fff;
+}
+.service-center-card.disabled-card .service-center-desc {
+  color: rgba(255,255,255,0.9);
+}
+.service-center-arrow {
+  font-size: 40rpx;
+  color: #fff;
+}
+
 /* 设置 + 退出 */
 .bottom-menu-section {
   margin: 0 30rpx;
@@ -1102,42 +1203,6 @@ export default {
   font-weight: 500;
 }
 
-/* 当前意图 + 重新选择 */
-.intent-section {
-  margin: 20rpx 30rpx 0;
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 24rpx;
-  display: flex;
-  align-items: center;
-  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
-}
-.intent-info {
-  flex: 1;
-  display: flex;
-  align-items: center;
-}
-.intent-label {
-  font-size: 24rpx;
-  color: #999;
-  margin-right: 12rpx;
-}
-.intent-value {
-  font-size: 26rpx;
-  color: #F97316;
-  font-weight: bold;
-}
-.intent-reselect {
-  background: #F97316;
-  border-radius: 28rpx;
-  padding: 12rpx 28rpx;
-}
-.intent-reselect-text {
-  font-size: 24rpx;
-  color: #fff;
-  font-weight: 600;
-}
-
 /* 我的推荐人卡片 */
 .referrer-card {
   margin: 0 30rpx 20rpx;
@@ -1163,4 +1228,4 @@ export default {
   font-size: 26rpx; color: #F97316; padding: 8rpx 16rpx; border: 1rpx solid #FED7AA;
   border-radius: 20rpx; flex-shrink: 0;
 }
-</style>
+</style>

+ 261 - 0
cfc-frontend/pages/service-center/index.vue

@@ -0,0 +1,261 @@
+<template>
+  <view class="container">
+    <!-- 头部 -->
+    <view class="header">
+      <view class="header-title">服务中心</view>
+      <view class="header-close" @click="close">
+        <text>✕</text>
+      </view>
+    </view>
+
+    <!-- 统计概览 -->
+    <view class="stats-row">
+      <view class="stat-card blue">
+        <text class="stat-value">{{ serviceFamilyCount }}</text>
+        <text class="stat-label">服务家庭</text>
+      </view>
+      <view class="stat-card green">
+        <text class="stat-value">{{ pendingTaskCount }}</text>
+        <text class="stat-label">待处理任务</text>
+      </view>
+    </view>
+    <view class="stats-row">
+      <view class="stat-card gold">
+        <text class="stat-value">{{ formatPriceWithSymbol(todayIncome) }}</text>
+        <text class="stat-label">今日收入</text>
+      </view>
+      <view class="stat-card purple">
+        <text class="stat-value">{{ teamCount }}</text>
+        <text class="stat-label">团队成员</text>
+      </view>
+    </view>
+
+    <!-- 快捷功能 -->
+    <view class="section" v-if="isTeacherApproved">
+      <view class="section-title">快捷功能</view>
+      <view class="menu-row">
+        <view class="menu-item" @click="goToFamilies">
+          <view class="menu-icon blue">👨‍👩‍👧</view>
+          <text class="menu-label">服务家庭</text>
+        </view>
+        <view class="menu-item" @click="goToServiceTasks">
+          <view class="menu-icon green">⚡</view>
+          <text class="menu-label">待办任务</text>
+        </view>
+        <view class="menu-item" @click="goToMessages">
+          <view class="menu-icon cyan">💬</view>
+          <text class="menu-label">消息中心</text>
+        </view>
+        <view class="menu-item" @click="goToAssessmentResult">
+          <view class="menu-icon purple">📊</view>
+          <text class="menu-label">录入测评结果</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 无权限提示 -->
+    <view class="section" v-else>
+      <view class="section-title">服务中心</view>
+      <view class="empty-tip" v-if="isTeacherPending">申请审核中,暂未开放服务中心功能</view>
+      <view class="empty-tip" v-if="isTeacherRejected">申请未通过,请联系管理员</view>
+      <view class="empty-tip" v-if="!isTeacher && !isTeacherPending && !isTeacherRejected">暂无服务中心权限</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getTeacherPendingTasks, getTeacherUnreadCount } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      isTeacher: false,
+      isTeacherApproved: false,
+      isTeacherPending: false,
+      isTeacherRejected: false,
+      serviceFamilyCount: 0,
+      pendingTaskCount: 0,
+      todayIncome: 0,
+      teamCount: 0
+    }
+  },
+  onLoad() {
+    this.initRole()
+    this.loadTeacherData()
+  },
+  methods: {
+    initRole() {
+      var role = uni.getStorageSync('role') || uni.getStorageSync('currentRole') || 'parent'
+      this.isTeacher = role === 'teacher'
+      var status = uni.getStorageSync('teacherStatus') || ''
+      this.isTeacherPending = role === 'teacher' && status === 'pending'
+      this.isTeacherRejected = role === 'teacher' && status === 'rejected'
+      this.isTeacherApproved = role === 'teacher' && status === 'approved'
+    },
+    async loadTeacherData() {
+      if (!this.isTeacherApproved) return
+      try {
+        var res = await getTeacherPendingTasks()
+        if (res && res.data && Array.isArray(res.data)) {
+          this.pendingTaskCount = res.data.length
+        } else if (res && res.data && res.data.pendingCount != null) {
+          this.pendingTaskCount = res.data.pendingCount
+        }
+      } catch (e) {
+        this.pendingTaskCount = uni.getStorageSync('pendingTaskCount') || 0
+      }
+      // 今日收入/服务家庭/团队数:后端暂无专用接口,暂用存储兜底
+      this.todayIncome = uni.getStorageSync('todayIncome') || 0
+      this.serviceFamilyCount = uni.getStorageSync('serviceFamilyCount') || 0
+      this.teamCount = uni.getStorageSync('teamCount') || 0
+    },
+    formatPriceWithSymbol(value) {
+      if (!value) return '¥0'
+      return '¥' + parseFloat(value).toFixed(2)
+    },
+    close() {
+      uni.navigateBack()
+    },
+    goToFamilies() {
+      uni.navigateTo({ url: '/pages/teacher/families' })
+    },
+    goToServiceTasks() {
+      uni.navigateTo({ url: '/pages/teacher/service-tasks' })
+    },
+    goToMessages() {
+      uni.navigateTo({ url: '/pages/teacher/messages' })
+    },
+    goToAssessmentResult() {
+      uni.navigateTo({ url: '/pages/assessment/execution' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  background: #F8F4EF;
+  min-height: 100vh;
+  padding: 0 0 40rpx;
+}
+
+.header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  background: #fff;
+  border-radius: 16rpx 16rpx 0 0;
+  padding: 24rpx 30rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+
+.header-title {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #1E293B;
+}
+
+.header-close {
+  font-size: 28rpx;
+  color: #999;
+}
+
+/* 统计卡片横排(替代 grid,使用 flexbox) */
+.stats-row {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  padding: 0 24rpx;
+  margin-bottom: 16rpx;
+}
+
+.stat-card {
+  flex: 1;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  align-items: center;
+  display: flex;
+  flex-direction: column;
+}
+
+.stat-card.blue { border-top: 4rpx solid #3B82F6; }
+.stat-card.green { border-top: 4rpx solid #10B981; }
+.stat-card.gold { border-top: 4rpx solid #F59E0B; }
+.stat-card.purple { border-top: 4rpx solid #8B5CF6; }
+
+.stat-value {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #1E293B;
+  display: block;
+  margin-bottom: 8rpx;
+}
+
+.stat-label {
+  font-size: 24rpx;
+  color: #64748B;
+}
+
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin: 0 24rpx 24rpx;
+}
+
+.section-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #1E293B;
+  margin-bottom: 20rpx;
+}
+
+/* 菜单横排(替代 grid,使用 flexbox) */
+.menu-row {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+
+.menu-item {
+  flex: 1 1 45%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 20rpx 0;
+  background: #F8F4EF;
+  border-radius: 16rpx;
+}
+
+.menu-icon {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 30rpx;
+  margin-bottom: 12rpx;
+}
+
+.menu-icon.blue { background: rgba(59, 130, 246, 0.15); color: #3B82F6; }
+.menu-icon.green { background: rgba(16, 185, 129, 0.15); color: #10B981; }
+.menu-icon.cyan { background: rgba(6, 182, 212, 0.15); color: #06B6D4; }
+.menu-icon.purple { background: rgba(139, 92, 246, 0.15); color: #8B5CF6; }
+
+.menu-label {
+  font-size: 24rpx;
+  color: #64748B;
+}
+
+.empty-tip {
+  text-align: center;
+  color: #999;
+  padding: 60rpx 0;
+  font-size: 26rpx;
+}
+</style>

+ 79 - 8
cfc-frontend/pages/tasks/create-task.vue

@@ -104,13 +104,48 @@
 </view>
 
 <button class="btn-primary" :loading="submitting" :disabled="submitting" @click="submit">创建任务</button>
+
+<!-- TASK-SYS-248: 前置/后置任务(流程任务) -->
+<view class="form-item" style="margin-top: 20rpx; padding-top: 20rpx; border-top: 1rpx solid #eee;">
+  <text class="label" style="color: #F97316;">前置任务(可选,多选)</text>
+  <text class="form-hint" style="margin-bottom: 10rpx;">选择本任务执行前必须完成的任务</text>
+  <view style="max-height: 200rpx; overflow-y: auto;">
+    <view v-for="task in prerequisiteTasks" :key="task.id" class="task-checkbox-row" @click="togglePrerequisite(task.id)">
+      <view class="task-checkbox" :class="{ checked: form.prerequisiteTaskIds.indexOf(task.id) > -1 }">
+        <text v-if="form.prerequisiteTaskIds.indexOf(task.id) > -1" class="check-icon">✓</text>
+      </view>
+      <text class="task-checkbox-label">{{ task.title }}</text>
+    </view>
+    <text v-if="prerequisiteTaskList.length === 0" class="form-hint">暂无可用任务</text>
+  </view>
+</view>
+
+<view class="form-item">
+  <text class="label" style="color: #6366F1;">后置任务(可选,多选)</text>
+  <text class="form-hint" style="margin-bottom: 10rpx;">选择本任务完成后自动解锁的任务(任意一个完成即可)</text>
+  <view style="max-height: 200rpx; overflow-y: auto;">
+    <view v-for="task in successorTasks" :key="task.id" class="task-checkbox-row" @click="toggleSuccessor(task.id)">
+      <view class="task-checkbox" :class="{ checked: form.successorTaskIds.indexOf(task.id) > -1 }">
+        <text v-if="form.successorTaskIds.indexOf(task.id) > -1" class="check-icon">✓</text>
+      </view>
+      <text class="task-checkbox-label">{{ task.title }}</text>
+    </view>
+    <text v-if="successorTaskList.length === 0" class="form-hint">暂无可用任务</text>
+  </view>
+</view>
+
+<view class="form-item" v-if="form.prerequisiteTaskIds.length > 0 || form.successorTaskIds.length > 0">
+  <text class="label">流程任务</text>
+  <text style="font-size: 24rpx; color: #F97316;">自动标记为流程任务</text>
+  <text class="form-hint">有前置或后置任务时,本任务将自动保存为流程任务</text>
+</view>
 </view>
 </view>
 </template>
 
 <script>
 import config from '@/config.js'
-import { getChildren, createTask } from '../../utils/api.js'
+import { getChildren, createTask, getTaskListForPicker } from '../../utils/api.js'
 
 export default {
 data() {
@@ -129,13 +164,17 @@ return {
           actionType: '',
           actionConfig: '',
           requireInput: false,
-          startRequired: false
+          startRequired: false,
+          prerequisiteTaskIds: [],
+          successorTaskIds: []
         },
         children: [],
         selectedChildIds: [],
         categories: ['学习类', '生活类', '运动类', '小游戏类', '其他'],
         miniGames: [],
         selectedMinigame: null,
+        prerequisiteTaskList: [],
+        successorTaskList: [],
 repeatTypes: [
 { value: 'none', label: '不重复' },
 { value: 'daily', label: '每天' },
@@ -327,11 +366,41 @@ this.form.actionConfig = ''
 }
 },
 onStartRequiredChange(e) {
-this.form.startRequired = e.detail.value
-},
-onRequireInputChange(e) {
-this.form.requireInput = e.detail.value
-},
+    this.form.startRequired = e.detail.value
+  },
+  onRequireInputChange(e) {
+    this.form.requireInput = e.detail.value
+  },
+  async loadRelatedTasks() {
+    try {
+      var res = await getTaskListForPicker({
+        memberId: this.form.memberIds.length > 0 ? this.form.memberIds[0] : null,
+        titleKeyword: this.form.category
+      })
+      if (res && res.code === 200) {
+        // 过滤掉已经在表单中选择的任务 ID(排除当前正在创建的任务本身)
+        const allTasks = res.data || []
+        this.prerequisiteTaskList = allTasks.filter(t => !this.form.prerequisiteTaskIds.includes(t.id))
+        this.successorTaskList = allTasks.filter(t => !this.form.successorTaskIds.includes(t.id))
+      }
+    } catch (e) {
+      console.error('加载关联任务列表失败:', e)
+    }
+  },
+  togglePrerequisite(taskId) {
+    if (this.form.prerequisiteTaskIds.includes(taskId)) {
+      this.form.prerequisiteTaskIds = this.form.prerequisiteTaskIds.filter(id => id !== taskId)
+    } else {
+      this.form.prerequisiteTaskIds.push(taskId)
+    }
+  },
+  toggleSuccessor(taskId) {
+    if (this.form.successorTaskIds.includes(taskId)) {
+      this.form.successorTaskIds = this.form.successorTaskIds.filter(id => id !== taskId)
+    } else {
+      this.form.successorTaskIds.push(taskId)
+    }
+  },
     async submit() {
       if (this.submitting) return
       if (!this.form.title) {
@@ -369,7 +438,9 @@ this.form.requireInput = e.detail.value
         actionType: this.form.actionType || '',
         actionConfig: this.form.actionConfig || '',
         requireInput: this.form.requireInput ? 1 : 0,
-        startRequired: this.form.startRequired ? 1 : 0
+        startRequired: this.form.startRequired ? 1 : 0,
+        prerequisiteTaskId: this.form.prerequisiteTaskIds.length > 0 ? this.form.prerequisiteTaskIds[0] : null,
+        successorTaskIds: this.form.successorTaskIds.length > 0 ? this.form.successorTaskIds : null
       }
 
       let successCount = 0

+ 404 - 83
cfc-frontend/pages/wealth-sub/insurance-add.vue

@@ -1,6 +1,62 @@
 <template>
   <scroll-view class="container" scroll-y>
-    <view class="form-card">
+    <!-- 上传区域 -->
+    <view class="upload-section" v-if="!isEdit && !parsedResult">
+      <view class="upload-card" @click="chooseFile">
+        <text class="upload-icon">📄</text>
+        <text class="upload-title">上传保单PDF</text>
+        <text class="upload-desc">选择保单文件,AI 自动识别条款内容</text>
+      </view>
+      <view v-if="uploading" class="upload-status">
+        <text class="upload-status-text">正在解析保单...</text>
+        <view class="spinner"></view>
+      </view>
+    </view>
+
+    <!-- 解析结果预览(编辑模式不显示上传区) -->
+    <view v-if="parsedResult && !isEdit" class="parsed-result">
+      <view class="result-header">
+        <text class="result-title">AI 识别结果</text>
+        <text class="result-hint">请确认或修改下方信息</text>
+      </view>
+      <view class="result-summary">
+        <text class="summary-label">保险公司:</text>
+        <text class="summary-value">{{ parsedResult.extracted.insuranceCompany || '未识别' }}</text>
+      </view>
+      <view class="result-summary">
+        <text class="summary-label">保单类型:</text>
+        <text class="summary-value">{{ parsedResult.extracted.policyType || '未识别' }}</text>
+      </view>
+      <view class="result-summary">
+        <text class="summary-label">保额:</text>
+        <text class="summary-value orange">{{ parsedResult.extracted.sumInsured || '未识别' }}</text>
+      </view>
+      <view class="result-summary">
+        <text class="summary-label">保费:</text>
+        <text class="summary-value orange">{{ parsedResult.extracted.premium || '未识别' }}</text>
+      </view>
+      <view class="result-summary" v-if="parsedResult.extracted.summary">
+        <text class="summary-label">摘要:</text>
+        <text class="summary-value">{{ parsedResult.extracted.summary }}</text>
+      </view>
+      <view class="confirm-btn" @click="confirmParsed">
+        <text>确认并使用此结果</text>
+      </view>
+    </view>
+
+    <!-- 保单条款原文展示 -->
+    <view v-if="parsedResult && parsedResult.rawText" class="terms-section">
+      <view class="terms-header">
+        <text class="terms-title">保单条款原文</text>
+        <text class="terms-toggle" @click="showTerms = !showTerms">{{ showTerms ? '收起' : '展开' }}</text>
+      </view>
+      <scroll-view v-if="showTerms" class="terms-content" scroll-y>
+        <text class="terms-text">{{ parsedResult.rawText }}</text>
+      </scroll-view>
+    </view>
+
+    <!-- 表单 -->
+    <view class="form-card" :class="{ 'form-hidden': parsedResult && !isEdit && !showForm }">
       <view class="form-group">
         <text class="form-label">保单名称 <text class="required">*</text></text>
         <input class="form-input" v-model="form.policyName" placeholder="请输入保单名称" />
@@ -76,13 +132,16 @@
 </template>
 
 <script>
-import { getInsuranceDetail, createInsurance, updateInsurance } from '../../utils/api'
+import { getInsuranceDetail, createInsurance, updateInsurance, uploadPolicy } from '../../utils/api'
 
 export default {
   data() {
     return {
       isEdit: false,
       submitting: false,
+      uploading: false,
+      showTerms: false,
+      showForm: true,
       policyTypes: ['医疗险', '重疾险', '意外险', '教育金', '养老险'],
       policyTypeValues: ['medical', 'life', 'accident', 'education', 'endowment'],
       policyTypeIndex: -1,
@@ -101,7 +160,8 @@ export default {
         renewalType: 'annual',
         policyNo: '',
         remark: ''
-      }
+      },
+      parsedResult: null
     }
   },
 
@@ -158,6 +218,98 @@ export default {
       this.form.endDate = e.detail.value
     },
 
+    chooseFile() {
+      var self = this
+      uni.showActionSheet({
+        itemList: ['从聊天选取PDF', '拍照上传'],
+        success: function(res) {
+          if (res.tapIndex === 0) {
+            self.chooseChatFile()
+          } else if (res.tapIndex === 1) {
+            self.takePhoto()
+          }
+        },
+        fail: function() {}
+      })
+    },
+
+    chooseChatFile() {
+      var self = this
+      uni.chooseMessageFile({
+        count: 1,
+        type: 'file',
+        success: function(res) {
+          var files = res.tempFiles
+          if (files && files.length > 0) {
+            self.uploadAndParse(files[0].path, files[0].name || '保单文件')
+          }
+        },
+        fail: function() {}
+      })
+    },
+
+    takePhoto() {
+      var self = this
+      uni.chooseImage({
+        count: 1,
+        sourceType: ['camera', 'album'],
+        success: function(res) {
+          if (res.tempFilePaths && res.tempFilePaths.length > 0) {
+            self.uploadAndParse(res.tempFilePaths[0], '照片')
+          }
+        }
+      })
+    },
+
+    async uploadAndParse(filePath, fileName) {
+      var self = this
+      self.uploading = true
+      uni.showLoading({ title: '正在解析...' })
+      try {
+        var res = await uploadPolicy(filePath)
+        if (res && res.code === 200 && res.data) {
+          self.parsedResult = res.data
+          // 尝试回填表单字段
+          var ext = res.data.extracted || {}
+          if (ext.insuranceCompany) self.form.insuranceCompany = ext.insuranceCompany
+          if (ext.policyType) self.form.policyType = ext.policyType
+          if (ext.insuredPerson) self.form.insuredPerson = ext.insuredPerson
+          if (ext.policyNo) self.form.policyNo = ext.policyNo
+          if (ext.premium) self.form.premium = ext.premium
+          if (ext.sumInsured) self.form.sumInsured = ext.sumInsured
+          if (ext.policyName) self.form.policyName = ext.policyName
+          // 转换保费/保额为数字
+          if (ext.premium) {
+            var p = parseFloat(ext.premium)
+            if (!isNaN(p)) self.form.premium = String(p)
+          }
+          if (ext.sumInsured) {
+            var s = parseFloat(ext.sumInsured)
+            if (!isNaN(s)) self.form.sumInsured = String(s)
+          }
+          // 设置保单类型索引
+          if (ext.policyType) {
+            var idx = self.policyTypeValues.indexOf(ext.policyType)
+            if (idx >= 0) self.policyTypeIndex = idx
+          }
+          uni.hideLoading()
+        } else {
+          uni.hideLoading()
+          uni.showToast({ title: (res && res.message) || '解析失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.hideLoading()
+        console.error('上传解析失败', e)
+        uni.showToast({ title: '上传解析失败,请重试', icon: 'none' })
+      } finally {
+        self.uploading = false
+      }
+    },
+
+    confirmParsed() {
+      this.showForm = true
+    },
+
     async submitForm() {
       if (!this.form.policyName) {
         uni.showToast({ title: '请输入保单名称', icon: 'none' })
@@ -184,6 +336,11 @@ export default {
           remark: this.form.remark
         }
 
+        // 如果有解析结果,保存原始条款
+        if (this.parsedResult && this.parsedResult.rawText) {
+          payload.policyTerms = this.parsedResult.rawText
+        }
+
         var res
         if (this.isEdit) {
           payload.id = this.$mp.query && this.$mp.query.id
@@ -220,96 +377,260 @@ export default {
     overflow-x: hidden;
   }
 
-.form-card {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 30rpx;
-  margin-bottom: 24rpx;
-}
+  /* 上传区域 */
+  .upload-section {
+    margin-bottom: 24rpx;
+  }
 
-.form-group {
-  margin-bottom: 28rpx;
-}
+  .upload-card {
+    background: #fff;
+    border-radius: 20rpx;
+    padding: 60rpx 40rpx;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    border: 2rpx dashed #F97316;
+  }
 
-.form-label {
-  display: block;
-  font-size: 28rpx;
-  color: #333;
-  font-weight: 500;
-  margin-bottom: 12rpx;
-}
+  .upload-icon {
+    font-size: 80rpx;
+    margin-bottom: 20rpx;
+  }
 
-.required {
-  color: #DC2626;
-}
+  .upload-title {
+    font-size: 32rpx;
+    font-weight: 600;
+    color: #333;
+    margin-bottom: 12rpx;
+  }
 
-.form-input {
-  width: 100%;
-  height: 88rpx;
-  line-height: 88rpx;
-  background: #F5F7FA;
-  border-radius: 12rpx;
-  padding: 0 20rpx;
-  font-size: 28rpx;
-  color: #333;
-  box-sizing: border-box;
-}
+  .upload-desc {
+    font-size: 24rpx;
+    color: #999;
+  }
 
-.form-picker {
-  width: 100%;
-  height: 80rpx;
-  line-height: 80rpx;
-  background: #F5F7FA;
-  border-radius: 12rpx;
-  padding: 0 20rpx;
-  box-sizing: border-box;
-}
+  .upload-status {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 40rpx 0;
+  }
 
-.picker-value {
-  font-size: 28rpx;
-  color: #333;
-  line-height: 80rpx;
-}
+  .upload-status-text {
+    font-size: 28rpx;
+    color: #F97316;
+    margin-bottom: 16rpx;
+  }
 
-.form-textarea {
-  width: 100%;
-  height: 160rpx;
-  background: #F5F7FA;
-  border-radius: 12rpx;
-  padding: 20rpx;
-  font-size: 28rpx;
-  color: #333;
-  box-sizing: border-box;
-}
+  .spinner {
+    width: 48rpx;
+    height: 48rpx;
+    border: 4rpx solid #FED7AA;
+    border-top-color: #F97316;
+    border-radius: 50%;
+    animation: spin 1s linear infinite;
+  }
 
-.form-row {
-  display: flex;
-  flex-direction: row;
-  justify-content: space-between;
-}
+  @keyframes spin {
+    0% { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+  }
 
-.form-group.half {
-  width: 48%;
-}
+  /* 解析结果预览 */
+  .parsed-result {
+    background: #fff;
+    border-radius: 20rpx;
+    padding: 30rpx;
+    margin-bottom: 24rpx;
+    border: 2rpx solid #DCFCE7;
+  }
 
-.submit-btn-wrap {
-  padding: 20rpx 0 40rpx;
-}
+  .result-header {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20rpx;
+    padding-bottom: 16rpx;
+    border-bottom: 1rpx solid #f5f5f5;
+  }
 
-.submit-btn {
-  width: 100%;
-  height: 88rpx;
-  line-height: 88rpx;
-  text-align: center;
-  background: #F97316;
-  color: #fff;
-  font-size: 30rpx;
-  font-weight: 600;
-  border-radius: 44rpx;
-  border: none;
-}
+  .result-title {
+    font-size: 30rpx;
+    font-weight: 600;
+    color: #16A34A;
+  }
 
-.submit-btn::after {
-  border: none;
-}
+  .result-hint {
+    font-size: 24rpx;
+    color: #999;
+  }
+
+  .result-summary {
+    display: flex;
+    flex-direction: row;
+    padding: 12rpx 0;
+    font-size: 26rpx;
+  }
+
+  .summary-label {
+    color: #666;
+    width: 160rpx;
+    flex-shrink: 0;
+  }
+
+  .summary-value {
+    color: #333;
+    flex: 1;
+  }
+
+  .summary-value.orange {
+    color: #F97316;
+    font-weight: 600;
+  }
+
+  .confirm-btn {
+    margin-top: 24rpx;
+    padding: 16rpx 0;
+    text-align: center;
+    background: #F0F9FF;
+    border-radius: 12rpx;
+    color: #0284C7;
+    font-size: 28rpx;
+    font-weight: 500;
+  }
+
+  /* 条款原文 */
+  .terms-section {
+    background: #fff;
+    border-radius: 20rpx;
+    padding: 30rpx;
+    margin-bottom: 24rpx;
+    border: 2rpx solid #FEF3C7;
+  }
+
+  .terms-header {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 16rpx;
+  }
+
+  .terms-title {
+    font-size: 28rpx;
+    font-weight: 600;
+    color: #92400E;
+  }
+
+  .terms-toggle {
+    font-size: 24rpx;
+    color: #F59E0B;
+  }
+
+  .terms-content {
+    max-height: 400rpx;
+  }
+
+  .terms-text {
+    font-size: 24rpx;
+    color: #555;
+    line-height: 1.8;
+    white-space: pre-wrap;
+    word-break: break-all;
+  }
+
+  /* 表单 */
+  .form-card {
+    background: #fff;
+    border-radius: 20rpx;
+    padding: 30rpx;
+    margin-bottom: 24rpx;
+  }
+
+  .form-group {
+    margin-bottom: 28rpx;
+  }
+
+  .form-label {
+    display: block;
+    font-size: 28rpx;
+    color: #333;
+    font-weight: 500;
+    margin-bottom: 12rpx;
+  }
+
+  .required {
+    color: #DC2626;
+  }
+
+  .form-input {
+    width: 100%;
+    height: 88rpx;
+    line-height: 88rpx;
+    background: #F5F7FA;
+    border-radius: 12rpx;
+    padding: 0 20rpx;
+    font-size: 28rpx;
+    color: #333;
+    box-sizing: border-box;
+  }
+
+  .form-picker {
+    width: 100%;
+    height: 80rpx;
+    line-height: 80rpx;
+    background: #F5F7FA;
+    border-radius: 12rpx;
+    padding: 0 20rpx;
+    box-sizing: border-box;
+  }
+
+  .picker-value {
+    font-size: 28rpx;
+    color: #333;
+    line-height: 80rpx;
+  }
+
+  .form-textarea {
+    width: 100%;
+    height: 160rpx;
+    background: #F5F7FA;
+    border-radius: 12rpx;
+    padding: 20rpx;
+    font-size: 28rpx;
+    color: #333;
+    box-sizing: border-box;
+  }
+
+  .form-row {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+  }
+
+  .form-group.half {
+    width: 48%;
+  }
+
+  .submit-btn-wrap {
+    padding: 20rpx 0 40rpx;
+  }
+
+  .submit-btn {
+    width: 100%;
+    height: 88rpx;
+    line-height: 88rpx;
+    text-align: center;
+    background: #F97316;
+    color: #fff;
+    font-size: 30rpx;
+    font-weight: 600;
+    border-radius: 44rpx;
+    border: none;
+  }
+
+  .submit-btn::after {
+    border: none;
+  }
 </style>

+ 1 - 1
cfc-frontend/pages/wealth-sub/insurance-list.vue

@@ -99,7 +99,7 @@ export default {
 
     addPolicy() {
       uni.navigateTo({
-        url: '/pages/wealth/insurance-add'
+        url: '/pages/wealth-sub/insurance-add'
       })
     },
 

+ 1 - 4
cfc-frontend/pages/wealth/index.vue

@@ -370,10 +370,7 @@ export default {
     this.isLoggedIn = true
 
     var currentRole = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
-    if (currentRole === 'teacher') {
-      uni.redirectTo({ url: '/pages/teacher/teacher-profile' })
-      return
-    }
+    // 所有人统一以家长身份进入,不再进行角色分流
     this.role = currentRole
 
     this.loadChildren()

+ 33 - 0
cfc-frontend/utils/api.js

@@ -568,6 +568,10 @@ export const createTask = (data) => {
   return request('/api/tasks', 'POST', data)
 }
 
+export const getTaskListForPicker = (params) => {
+  return request('/api/tasks/list', 'POST', params || {})
+}
+
 export const getTodayTasks = (memberId, options) => {
   var body = {}
   if (memberId != null && memberId !== '') {
@@ -2003,6 +2007,35 @@ export const getInsurancePlanning = () => {
   return request('/api/insurance/planning', 'POST', {})
 }
 
+// 上传保单PDF并AI解析,返回提取字段预览和原始条款
+export const uploadPolicy = (filePath) => {
+  return new Promise((resolve, reject) => {
+    const token = uni.getStorageSync('token')
+    uni.uploadFile({
+      url: BASE_URL + '/api/wealth/insurance/upload',
+      filePath: filePath,
+      name: 'file',
+      header: {
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: (res) => {
+        var data
+        try { data = JSON.parse(res.data) } catch (e) { data = res.data }
+        if (data && data.code === 200) {
+          resolve(data)
+        } else {
+          console.warn('[API] 保单上传失败 ' + BASE_URL + '/api/wealth/insurance/upload code=' + (data && data.code || '') + ' msg=' + (data && data.message || ''))
+          reject(data)
+        }
+      },
+      fail: (err) => {
+        console.error('[API] 保单上传网络错误', err)
+        reject(err)
+      }
+    })
+  })
+}
+
 // ===== 鍋ュ悍鎵撳崱 =====
 export const healthCheckinList = (data) => request('/api/health/checkin/list', 'POST', data)
 export const healthCheckinCreate = (data) => request('/api/health/checkin/create', 'POST', data)