Forráskód Böngészése

feat: 关键人拓展章鱼图全链路上线(替换旧章鱼图 + 成效/关键人/分析/拓展 + 三层Canvas)

Sisyphus Agent 1 hete
szülő
commit
1d0d460c5f

+ 105 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java

@@ -0,0 +1,105 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.KeyPersonService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/octopus")
+public class KeyPersonController {
+
+    @Resource
+    private KeyPersonService keyPersonService;
+
+    // ===== 1. 盘点项目 =====
+    @PostMapping("/effect/list")
+    public Result<Map<String, Object>> listEffects(@RequestBody(required = false) Map<String, Object> params,
+                                                   @RequestAttribute("userId") Long userId) {
+        Integer effectType = params != null && params.get("effectType") != null
+                ? Integer.valueOf(params.get("effectType").toString()) : null;
+        Integer page = params != null && params.get("page") != null
+                ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer pageSize = params != null && params.get("pageSize") != null
+                ? Integer.valueOf(params.get("pageSize").toString()) : 20;
+        return keyPersonService.listEffects(userId, effectType, page, pageSize);
+    }
+
+    @PostMapping("/effect/add")
+    public Result<com.etotem.cfc.entity.OctopusEffectRecord> addEffect(@RequestBody Map<String, Object> params,
+                                                                       @RequestAttribute("userId") Long userId) {
+        Long memberOrderId = params.get("memberOrderId") != null
+                ? Long.valueOf(params.get("memberOrderId").toString()) : null;
+        Integer effectType = params.get("effectType") != null
+                ? Integer.valueOf(params.get("effectType").toString()) : 0;
+        BigDecimal effectAmount = params.get("effectAmount") != null
+                ? new BigDecimal(params.get("effectAmount").toString()) : BigDecimal.ZERO;
+        String effectDesc = (String) params.get("effectDesc");
+        return keyPersonService.addEffect(userId, memberOrderId, effectType, effectAmount, effectDesc);
+    }
+
+    @PostMapping("/effect/delete")
+    public Result<String> deleteEffect(@RequestBody Map<String, Object> params,
+                                       @RequestAttribute("userId") Long userId) {
+        Long effectId = params.get("effectId") != null
+                ? Long.valueOf(params.get("effectId").toString()) : null;
+        return keyPersonService.deleteEffect(userId, effectId);
+    }
+
+    // ===== 2. 找关键人 =====
+    @PostMapping("/key-person/add")
+    public Result<com.etotem.cfc.entity.OctopusKeyPerson> addKeyPerson(@RequestBody Map<String, Object> params,
+                                                                       @RequestAttribute("userId") Long userId) {
+        String name = (String) params.get("name");
+        String organization = (String) params.get("organization");
+        String department = (String) params.get("department");
+        String title = (String) params.get("title");
+        String firstMeetScene = (String) params.get("firstMeetScene");
+        String knowWay = (String) params.get("knowWay");
+        String contactInfo = (String) params.get("contactInfo");
+        return keyPersonService.addKeyPerson(userId, name, organization, department, title,
+                firstMeetScene, knowWay, contactInfo);
+    }
+
+    @PostMapping("/key-person/update")
+    public Result<com.etotem.cfc.entity.OctopusKeyPerson> updateKeyPerson(@RequestBody Map<String, Object> params,
+                                                                          @RequestAttribute("userId") Long userId) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        return keyPersonService.updateKeyPerson(userId, id, params);
+    }
+
+    @PostMapping("/key-person/delete")
+    public Result<String> deleteKeyPerson(@RequestBody Map<String, Object> params,
+                                          @RequestAttribute("userId") Long userId) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        return keyPersonService.deleteKeyPerson(userId, id);
+    }
+
+    @PostMapping("/effect/key-persons")
+    public Result<List<Map<String, Object>>> effectKeyPersons(@RequestBody Map<String, Object> params,
+                                                              @RequestAttribute("userId") Long userId) {
+        Long effectId = params.get("effectId") != null ? Long.valueOf(params.get("effectId").toString()) : null;
+        return keyPersonService.listKeyPersonsByEffect(userId, effectId);
+    }
+
+    // ===== 3. 分析关键人 =====
+    @PostMapping("/analysis/keywords")
+    public Result<Map<String, Object>> analysisKeywords(@RequestAttribute("userId") Long userId) {
+        return keyPersonService.analyzeKeywords(userId);
+    }
+
+    @PostMapping("/analysis/summary")
+    public Result<Map<String, Object>> analysisSummary(@RequestAttribute("userId") Long userId) {
+        return keyPersonService.analysisSummary(userId);
+    }
+
+    // ===== 4. 拓展关键人 =====
+    @PostMapping("/expansion/suggest")
+    public Result<List<Map<String, Object>>> expansionSuggest(@RequestAttribute("userId") Long userId) {
+        return keyPersonService.expansionSuggest(userId);
+    }
+}

+ 10 - 23
cfc-backend/src/main/java/com/etotem/cfc/controller/OctopusController.java

@@ -12,52 +12,39 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+/**
+ * @deprecated 旧章鱼图(帮助记录型)已废弃,请使用 KeyPersonController(/api/octopus/effect/* 等)
+ * 所有接口返回 410 Gone。
+ */
+@Deprecated
 @RestController
 @RequestMapping("/api/octopus")
 public class OctopusController {
 
-    @Resource
-    private ContactHelpLogService helpLogService;
-
     /** 章鱼图数据:取前8条触手 */
     @PostMapping("/tentacles")
     public Result<List<Map<String, Object>>> tentacles(@RequestAttribute("userId") Long userId) {
-        return helpLogService.getOctopusTentacles(userId);
+        return Result.error(410, "该接口已废弃,请使用 /api/octopus/effect/list");
     }
 
     /** 新增帮助记录(被帮时调用) */
     @PostMapping("/add")
     public Result<ContactHelpLogDTO> addHelpLog(@RequestBody Map<String, Object> params,
-                                     @RequestAttribute("userId") Long userId) {
-        Long contactId = params.get("contactId") != null ?
-                Long.valueOf(params.get("contactId").toString()) : null;
-        String helpType = (String) params.get("helpType");
-        String amountStr = (String) params.get("amount");
-        BigDecimal amount = amountStr != null && !amountStr.isEmpty()
-                ? new BigDecimal(amountStr) : BigDecimal.ZERO;
-        String description = (String) params.get("description");
-        Long happenedAt = params.get("happenedAt") != null ?
-                Long.valueOf(params.get("happenedAt").toString()) : null;
-        Date happenedAtDate = happenedAt != null ? new Date(happenedAt) : null;
-
-        return helpLogService.addHelpLog(userId, contactId, helpType, amount, description, happenedAtDate);
+                                      @RequestAttribute("userId") Long userId) {
+        return Result.error(410, "该接口已废弃,请使用 /api/octopus/effect/add");
     }
 
     /** 删除帮助记录 */
     @PostMapping("/delete")
     public Result<String> deleteHelpLog(@RequestBody Map<String, Object> params,
                                         @RequestAttribute("userId") Long userId) {
-        Long logId = params.get("logId") != null ?
-                Long.valueOf(params.get("logId").toString()) : null;
-        return helpLogService.deleteHelpLog(userId, logId);
+        return Result.error(410, "该接口已废弃,请使用 /api/octopus/effect/delete");
     }
 
     /** 获取某联系人的帮助记录明细 */
     @PostMapping("/records")
     public Result<Map<String, Object>> records(@RequestBody Map<String, Object> params,
                                                @RequestAttribute("userId") Long userId) {
-        Long contactId = params.get("contactId") != null ?
-                Long.valueOf(params.get("contactId").toString()) : null;
-        return helpLogService.getHelpRecordsByContact(userId, contactId);
+        return Result.error(410, "该接口已废弃,请使用 /api/octopus/effect/key-persons");
     }
 }

+ 353 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

@@ -0,0 +1,353 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.MemberSubscriptionOrder;
+import com.etotem.cfc.entity.OctopusEffectKeyPerson;
+import com.etotem.cfc.entity.OctopusEffectRecord;
+import com.etotem.cfc.entity.OctopusKeyPerson;
+import com.etotem.cfc.mapper.MemberSubscriptionOrderMapper;
+import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
+import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
+import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+public class KeyPersonService {
+
+    @Resource
+    private OctopusEffectRecordMapper effectRecordMapper;
+    @Resource
+    private OctopusKeyPersonMapper keyPersonMapper;
+    @Resource
+    private OctopusEffectKeyPersonMapper ekpMapper;
+    @Resource
+    private MemberSubscriptionOrderMapper memberOrderMapper;
+
+    // ========== 1. 盘点项目 ==========
+
+    public Result<Map<String, Object>> listEffects(Long userId, Integer effectType, Integer page, Integer pageSize) {
+        Page<OctopusEffectRecord> p = new Page<>(page, pageSize);
+        LambdaQueryWrapper<OctopusEffectRecord> q = new LambdaQueryWrapper<OctopusEffectRecord>()
+                .eq(OctopusEffectRecord::getUserId, userId);
+        if (effectType != null && effectType > 0) {
+            q.apply("effect_type & {0}", effectType);
+        }
+        q.orderByDesc(OctopusEffectRecord::getCreatedAt);
+        effectRecordMapper.selectPage(p, q);
+
+        List<Map<String, Object>> list = p.getRecords().stream().map(this::toEffectVO).collect(Collectors.toList());
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", list);
+        data.put("total", p.getTotal());
+        data.put("page", page);
+        data.put("pageSize", pageSize);
+        return Result.success(data);
+    }
+
+    private Map<String, Object> toEffectVO(OctopusEffectRecord r) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("id", r.getId());
+        m.put("memberOrderId", r.getMemberOrderId());
+        m.put("effectType", r.getEffectType());
+        m.put("effectTypeList", parseEffectType(r.getEffectType()));
+        m.put("effectAmount", r.getEffectAmount());
+        m.put("effectDesc", r.getEffectDesc());
+        m.put("status", r.getStatus());
+        m.put("createdAt", r.getCreatedAt() != null ? r.getCreatedAt().toString() : "");
+        return m;
+    }
+
+    private List<String> parseEffectType(Integer type) {
+        List<String> list = new ArrayList<>();
+        if (type == null) return list;
+        if ((type & 1) != 0) list.add("金额大");
+        if ((type & 2) != 0) list.add("频次高");
+        if ((type & 4) != 0) list.add("新成交");
+        return list;
+    }
+
+    public Result<OctopusEffectRecord> addEffect(Long userId, Long memberOrderId, Integer effectType,
+                                                 BigDecimal effectAmount, String effectDesc) {
+        if (memberOrderId != null) {
+            MemberSubscriptionOrder order = memberOrderMapper.selectById(memberOrderId);
+            if (order == null || !order.getUserId().equals(userId)) {
+                return Result.error("会员订单不存在");
+            }
+        }
+        OctopusEffectRecord r = new OctopusEffectRecord();
+        r.setUserId(userId);
+        r.setMemberOrderId(memberOrderId);
+        r.setEffectType(effectType != null ? effectType : 0);
+        r.setEffectAmount(effectAmount != null ? effectAmount : BigDecimal.ZERO);
+        r.setEffectDesc(effectDesc != null ? effectDesc.trim() : null);
+        r.setStatus(1);
+        r.setCreatedAt(new Date());
+        effectRecordMapper.insert(r);
+        return Result.success(r);
+    }
+
+    public Result<String> deleteEffect(Long userId, Long effectId) {
+        OctopusEffectRecord r = effectRecordMapper.selectById(effectId);
+        if (r == null || !r.getUserId().equals(userId)) {
+            return Result.error("成效记录不存在");
+        }
+        // 级联删除关联关系
+        ekpMapper.delete(new LambdaQueryWrapper<OctopusEffectKeyPerson>().eq(OctopusEffectKeyPerson::getEffectId, effectId));
+        effectRecordMapper.deleteById(effectId);
+        return Result.success(null);
+    }
+
+    // ========== 2. 找关键人 ==========
+
+    public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String organization,
+                                                 String department, String title,
+                                                 String firstMeetScene, String knowWay,
+                                                 String contactInfo) {
+        if (name == null || name.trim().isEmpty()) {
+            return Result.error("关键人姓名不能为空");
+        }
+        OctopusKeyPerson kp = new OctopusKeyPerson();
+        kp.setUserId(userId);
+        kp.setName(name.trim());
+        kp.setOrganization(organization != null ? organization.trim() : null);
+        kp.setDepartment(department != null ? department.trim() : null);
+        kp.setTitle(title != null ? title.trim() : null);
+        kp.setFirstMeetScene(firstMeetScene != null ? firstMeetScene.trim() : null);
+        kp.setKnowWay(knowWay != null ? knowWay.trim() : null);
+        kp.setContactInfo(contactInfo != null ? contactInfo.trim() : null);
+        kp.setCreatedAt(new Date());
+        keyPersonMapper.insert(kp);
+        return Result.success(kp);
+    }
+
+    public Result<OctopusKeyPerson> updateKeyPerson(Long userId, Long id, Map<String, Object> params) {
+        OctopusKeyPerson kp = keyPersonMapper.selectById(id);
+        if (kp == null || !kp.getUserId().equals(userId)) {
+            return Result.error("关键人不存在");
+        }
+        if (params.containsKey("name") && params.get("name") != null) kp.setName(params.get("name").toString().trim());
+        if (params.containsKey("organization")) kp.setOrganization(params.get("organization") != null ? params.get("organization").toString().trim() : null);
+        if (params.containsKey("department")) kp.setDepartment(params.get("department") != null ? params.get("department").toString().trim() : null);
+        if (params.containsKey("title")) kp.setTitle(params.get("title") != null ? params.get("title").toString().trim() : null);
+        if (params.containsKey("firstMeetScene")) kp.setFirstMeetScene(params.get("firstMeetScene") != null ? params.get("firstMeetScene").toString().trim() : null);
+        if (params.containsKey("knowWay")) kp.setKnowWay(params.get("knowWay") != null ? params.get("knowWay").toString().trim() : null);
+        if (params.containsKey("contactInfo")) kp.setContactInfo(params.get("contactInfo") != null ? params.get("contactInfo").toString().trim() : null);
+        keyPersonMapper.updateById(kp);
+        return Result.success(kp);
+    }
+
+    public Result<String> deleteKeyPerson(Long userId, Long id) {
+        OctopusKeyPerson kp = keyPersonMapper.selectById(id);
+        if (kp == null || !kp.getUserId().equals(userId)) {
+            return Result.error("关键人不存在");
+        }
+        // 级联删除关联关系
+        ekpMapper.delete(new LambdaQueryWrapper<OctopusEffectKeyPerson>().eq(OctopusEffectKeyPerson::getKeyPersonId, id));
+        keyPersonMapper.deleteById(id);
+        return Result.success(null);
+    }
+
+    public Result<List<Map<String, Object>>> listKeyPersonsByEffect(Long userId, Long effectId) {
+        OctopusEffectRecord eff = effectRecordMapper.selectById(effectId);
+        if (eff == null || !eff.getUserId().equals(userId)) {
+            return Result.error("成效记录不存在");
+        }
+        List<OctopusEffectKeyPerson> rels = ekpMapper.selectList(
+                new LambdaQueryWrapper<OctopusEffectKeyPerson>().eq(OctopusEffectKeyPerson::getEffectId, effectId)
+        );
+        if (rels.isEmpty()) return Result.success(new ArrayList<>());
+
+        List<Long> kpIds = rels.stream().map(OctopusEffectKeyPerson::getKeyPersonId).collect(Collectors.toList());
+        Map<Long, OctopusKeyPerson> kpMap = keyPersonMapper.selectBatchIds(kpIds).stream()
+                .collect(Collectors.toMap(OctopusKeyPerson::getId, k -> k));
+        Map<Long, Integer> roleMap = rels.stream().collect(Collectors.toMap(OctopusEffectKeyPerson::getKeyPersonId, OctopusEffectKeyPerson::getRole));
+
+        List<Map<String, Object>> list = new ArrayList<>();
+        for (Long kpId : kpIds) {
+            OctopusKeyPerson kp = kpMap.get(kpId);
+            if (kp == null) continue;
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", kp.getId());
+            m.put("name", kp.getName());
+            m.put("organization", kp.getOrganization());
+            m.put("department", kp.getDepartment());
+            m.put("title", kp.getTitle());
+            m.put("firstMeetScene", kp.getFirstMeetScene());
+            m.put("knowWay", kp.getKnowWay());
+            m.put("contactInfo", kp.getContactInfo());
+            m.put("role", roleMap.get(kpId));
+            m.put("roleName", roleName(roleMap.get(kpId)));
+            m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : "");
+            list.add(m);
+        }
+        return Result.success(list);
+    }
+
+    // ========== 3. 分析关键人 ==========
+
+    public Result<Map<String, Object>> analyzeKeywords(Long userId) {
+        List<OctopusKeyPerson> kps = keyPersonMapper.selectList(
+                new LambdaQueryWrapper<OctopusKeyPerson>().eq(OctopusKeyPerson::getUserId, userId)
+        );
+        Map<String, Integer> orgFreq = new HashMap<>();
+        Map<String, Integer> deptFreq = new HashMap<>();
+        Map<String, Integer> titleFreq = new HashMap<>();
+        Map<String, Integer> sceneFreq = new HashMap<>();
+        Map<String, Integer> wayFreq = new HashMap<>();
+
+        for (OctopusKeyPerson kp : kps) {
+            inc(orgFreq, kp.getOrganization());
+            inc(deptFreq, kp.getDepartment());
+            inc(titleFreq, kp.getTitle());
+            inc(sceneFreq, kp.getFirstMeetScene());
+            inc(wayFreq, kp.getKnowWay());
+        }
+
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("organization", topN(orgFreq, 20));
+        data.put("department", topN(deptFreq, 20));
+        data.put("title", topN(titleFreq, 20));
+        data.put("firstMeetScene", topN(sceneFreq, 20));
+        data.put("knowWay", topN(wayFreq, 20));
+        return Result.success(data);
+    }
+
+    private void inc(Map<String, Integer> map, String val) {
+        if (val == null || val.trim().isEmpty()) return;
+        map.put(val, map.getOrDefault(val, 0) + 1);
+    }
+
+    private List<Map<String, Object>> topN(Map<String, Integer> map, int n) {
+        return map.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .limit(n)
+                .map(e -> {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("keyword", e.getKey());
+                    m.put("count", e.getValue());
+                    return m;
+                }).collect(Collectors.toList());
+    }
+
+    public Result<Map<String, Object>> analysisSummary(Long userId) {
+        List<OctopusKeyPerson> kps = keyPersonMapper.selectList(
+                new LambdaQueryWrapper<OctopusKeyPerson>().eq(OctopusKeyPerson::getUserId, userId)
+        );
+        List<Map<String, Object>> directions = new ArrayList<>();
+
+        addDirections(directions, kps, "organization", "单位", 8);
+        addDirections(directions, kps, "title", "职务", 8);
+        addDirections(directions, kps, "department", "部门", 8);
+        addDirections(directions, kps, "firstMeetScene", "初识场合", 8);
+        addDirections(directions, kps, "knowWay", "认识路径", 8);
+
+        directions.sort((a, b) -> Integer.compare((Integer) b.get("count"), (Integer) a.get("count")));
+        if (directions.size() > 8) directions = directions.subList(0, 8);
+
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("directions", directions);
+        return Result.success(data);
+    }
+
+    private void addDirections(List<Map<String, Object>> directions, List<OctopusKeyPerson> kps,
+                               String field, String dimName, int maxPerDim) {
+        Map<String, Integer> freq = new HashMap<>();
+        for (OctopusKeyPerson kp : kps) {
+            String val = getField(kp, field);
+            if (val != null && !val.trim().isEmpty()) {
+                freq.put(val, freq.getOrDefault(val, 0) + 1);
+            }
+        }
+        freq.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .limit(maxPerDim)
+                .forEach(e -> {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("dimension", field);
+                    m.put("keyword", e.getKey());
+                    m.put("count", e.getValue());
+                    m.put("reason", genReason(field, e.getKey()));
+                    directions.add(m);
+                });
+    }
+
+    private String getField(OctopusKeyPerson kp, String field) {
+        switch (field) {
+            case "organization": return kp.getOrganization();
+            case "title": return kp.getTitle();
+            case "department": return kp.getDepartment();
+            case "firstMeetScene": return kp.getFirstMeetScene();
+            case "knowWay": return kp.getKnowWay();
+            default: return null;
+        }
+    }
+
+    private String genReason(String field, String keyword) {
+        switch (field) {
+            case "organization": return "该单位关键人高度聚集";
+            case "title": return "该职务人脉密集";
+            case "department": return "该部门关键人多";
+            case "firstMeetScene": return "该场合易识别关键人";
+            case "knowWay": return "该认识路径高频";
+            default: return "";
+        }
+    }
+
+    // ========== 4. 拓展关键人 ==========
+
+    public Result<List<Map<String, Object>>> expansionSuggest(Long userId) {
+        Result<Map<String, Object>> summaryRes = analysisSummary(userId);
+        if (summaryRes.getCode() != 200) return Result.success(new ArrayList<>());
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> dirs = (List<Map<String, Object>>) summaryRes.getData().get("directions");
+        List<Map<String, Object>> suggests = new ArrayList<>();
+        for (Map<String, Object> d : dirs) {
+            Map<String, Object> tpl = new LinkedHashMap<>();
+            tpl.put("dimension", d.get("dimension"));
+            tpl.put("keyword", d.get("keyword"));
+            tpl.put("prefill", buildPrefill((String) d.get("dimension"), (String) d.get("keyword")));
+            suggests.add(tpl);
+        }
+        return Result.success(suggests);
+    }
+
+    private Map<String, Object> buildPrefill(String dim, String keyword) {
+        Map<String, Object> p = new LinkedHashMap<>();
+        switch (dim) {
+            case "organization":
+                p.put("organization", keyword);
+                break;
+            case "title":
+                p.put("title", keyword);
+                break;
+            case "department":
+                p.put("department", keyword);
+                break;
+            case "firstMeetScene":
+                p.put("firstMeetScene", keyword);
+                break;
+            case "knowWay":
+                p.put("knowWay", keyword);
+                break;
+        }
+        return p;
+    }
+
+    private String roleName(Integer role) {
+        if (role == null) return "";
+        switch (role) {
+            case 1: return "引荐";
+            case 2: return "决策";
+            case 3: return "其他";
+            default: return "";
+        }
+    }
+}

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 334 - 616
cfc-frontend/components/OctopusDiagram.vue


+ 14 - 2
cfc-frontend/pages.json

@@ -400,9 +400,21 @@
           }
         },
         {
-          "path": "upload-report",
+          "path": "pearl-add-resource",
           "style": {
-            "navigationBarTitleText": "上传测评报告"
+            "navigationBarTitleText": "登记资源"
+          }
+        },
+        {
+          "path": "octopus-add-effect",
+          "style": {
+            "navigationBarTitleText": "记录成效"
+          }
+        },
+        {
+          "path": "octopus-add-key-person",
+          "style": {
+            "navigationBarTitleText": "添加关键人"
           }
         },
         {

+ 68 - 2
cfc-frontend/pages/action-detail/index.vue

@@ -65,6 +65,28 @@
         :intimacyMap="intimacyMapForGraph"
         :interactive="true" />
     </view>
+    </view>
+
+    <!-- 登录后:关键人拓展(关键人拓展) -->
+    <view class="section" v-if="isLoggedIn">
+      <view class="section-header">
+        <view>
+          <text class="section-title">🧭 关键人拓展</text>
+          <text class="section-sub">从成效出发,挖掘关键人,画出拓展路径</text>
+        </view>
+        <text class="section-link" @tap="goOctopusAddEffect">+ 记录成效</text>
+      </view>
+      <OctopusDiagram
+        ref="octopusDiagram"
+        :selfId="selfId"
+        :effects="octopusEffects"
+        :expansions="octopusExpansions"
+        :interactive="true"
+        @effect-click="onEffectClick"
+        @expansion-click="onExpansionClick"
+        @add-effect="goOctopusAddEffect"
+        @reanalyze="onReanalyze" />
+    </view>
 
     <!-- 登录后:珍珠图(我有什么) -->
     <view class="section" v-if="isLoggedIn">
@@ -164,6 +186,7 @@ import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import PearlDiagram from '../../components/PearlDiagram.vue'
 import AbilityDiagram from '../../components/AbilityDiagram.vue'
+import OctopusDiagram from '../../components/OctopusDiagram.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
@@ -172,10 +195,10 @@ import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import DimensionSubDims from '../../components/DimensionSubDims.vue'
-import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getPearlResources, deletePearlResource, getAbilityMap, getPearlReminders } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getPearlResources, deletePearlResource, getAbilityMap, getPearlReminders, getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest, getOctopusEffectKeyPersons } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, FloatingAvatar, RadarChart, DimensionSubDims },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, FloatingAvatar, RadarChart, DimensionSubDims },
   data() {
     return {
       isLoggedIn: false,
@@ -197,6 +220,9 @@ export default {
       visibleSections: [],
       pearlData: null,
       abilityData: null,
+      octopusEffects: null,
+      octopusExpansions: [],
+      octopusKeyPersons: {},
       overdueMap: null,
       funcList: [
         { icon: '\u{1F4AC}', label: '记录互动', needLogin: true, page: '/pages/action-detail/interaction-log' },
@@ -327,6 +353,7 @@ export default {
       this.loadFamilyMembersVisible()
       this.loadPearlResources()
       this.loadAbilityMap()
+      this.loadOctopusData()
     }
     this._watchPageReady()
   },
@@ -516,6 +543,45 @@ export default {
         self.abilityData = []
       })
     },
+    /** 加载关键人拓展数据 */
+    loadOctopusData: function() {
+      var self = this
+      getOctopusEffects().then(function(res) {
+        self.octopusEffects = (res.code === 200 && res.data) ? res.data : null
+      }).catch(function(e) {
+        console.log('获取关键人拓展数据失败', e)
+        self.octopusEffects = null
+      })
+      getOctopusAnalysisSummary().then(function(res) {
+        if (res.code === 200 && res.data && res.data.directions) {
+          self.octopusExpansions = res.data.directions
+        }
+      }).catch(function(e) {
+        console.log('获取拓展方向失败', e)
+      })
+    },
+    /** 跳转成效录入页 */
+    goOctopusAddEffect: function() {
+      uni.navigateTo({ url: '/pages/action-detail/octopus-add-effect' })
+    },
+    /** 点击成效节点 */
+    onEffectClick: function(effect) {
+      var self = this
+      if (!self.octopusKeyPersons[effect.id]) {
+        getOctopusEffectKeyPersons({ effectId: effect.id }).then(function(res) {
+          if (res.code === 200) self.$set(self.octopusKeyPersons, effect.id, res.data || [])
+        }).catch(function(e) { console.log('获取关键人失败', e) })
+      }
+    },
+    /** 点击拓展方向节点 */
+    onExpansionClick: function(expansion) {
+      var prefill = encodeURIComponent(JSON.stringify(expansion.prefill || {}))
+      uni.navigateTo({ url: '/pages/action-detail/octopus-add-key-person?prefill=' + prefill })
+    },
+    /** 重新分析 */
+    onReanalyze: function() {
+      this.loadOctopusData()
+    },
     onTaskClick: function(task) {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },

+ 202 - 0
cfc-frontend/pages/action-detail/octopus-add-effect.vue

@@ -0,0 +1,202 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">记录成效</text>
+    </view>
+
+    <!-- 关联会员订单(可选) -->
+    <view class="form-section">
+      <text class="section-label">关联会员订单(选填)</text>
+      <view class="order-select" v-if="orders.length > 0">
+        <view class="order-item" v-for="o in orders" :key="o.id"
+              :class="{selected: selectedOrderId === o.id}"
+              @click="selectedOrderId = o.id">
+          <text class="order-no">{{ o.orderNo }}</text>
+          <text class="order-amount">¥{{ (o.actualPrice/100).toFixed(2) }}</text>
+        </view>
+      </view>
+      <text class="empty-hint" v-else>暂无会员订单,可不关联</text>
+    </view>
+
+    <!-- 成效类型(多选) -->
+    <view class="form-section">
+      <text class="section-label">成效类型 <text class="required-mark">*</text></text>
+      <view class="type-grid">
+        <view class="type-item" v-for="(name, key) in effectTypes" :key="key"
+              :class="{selected: effectTypeList.includes(Number(key))}"
+              @click="toggleType(Number(key))">
+          <text class="type-icon">{{ typeIcons[key] }}</text>
+          <text class="type-name">{{ name }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 成效金额 -->
+    <view class="form-section">
+      <text class="section-label">成效金额(元)</text>
+      <input class="amount-input" v-model="effectAmount" type="digit" placeholder="如 2500.00" />
+    </view>
+
+    <!-- 成效描述 -->
+    <view class="form-section">
+      <text class="section-label">成效描述(选填)</text>
+      <textarea class="desc-input" v-model="effectDesc" placeholder="简述成效内容" maxlength="200"></textarea>
+    </view>
+
+    <!-- 提交按钮 -->
+    <view class="submit-bar">
+      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存成效</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMemberOrders, addOctopusEffect } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      orders: [],
+      selectedOrderId: null,
+      effectTypeList: [],
+      effectTypes: {1: '金额大', 2: '频次高', 4: '新成交'},
+      typeIcons: {1: '💰', 2: '🔁', 4: '✨'},
+      effectAmount: '',
+      effectDesc: ''
+    }
+  },
+  computed: {
+    canSubmit() {
+      return this.effectTypeList.length > 0 && this.effectAmount.trim()
+    }
+  },
+  onLoad() {
+    this.loadOrders()
+  },
+  methods: {
+    async loadOrders() {
+      try {
+        var res = await getMemberOrders({ page: 1, size: 100 })
+        if (res.code === 200 && res.data) {
+          this.orders = res.data.list || (Array.isArray(res.data) ? res.data : [])
+        }
+      } catch (e) { /* 静默 */ }
+    },
+    toggleType(val) {
+      var idx = this.effectTypeList.indexOf(val)
+      if (idx > -1) this.effectTypeList.splice(idx, 1)
+      else this.effectTypeList.push(val)
+    },
+    async onSubmit() {
+      if (!this.canSubmit) {
+        uni.showToast({ title: '请选择类型并填写金额', icon: 'none' })
+        return
+      }
+      var type = 0
+      this.effectTypeList.forEach(v => { type |= v })
+      var data = { effectType: type, effectAmount: this.effectAmount, effectDesc: this.effectDesc.trim() }
+      if (this.selectedOrderId) data.memberOrderId = this.selectedOrderId
+      try {
+        var res = await addOctopusEffect(data)
+        if (res.code === 200) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+          setTimeout(() => uni.navigateBack(), 1500)
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) { uni.showToast({ title: '保存失败', icon: 'none' }) }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 24rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+.form-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.section-label {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.required-mark {
+  color: #F97316;
+}
+.type-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.type-item {
+  width: calc(33.33% - 11rpx);
+  padding: 20rpx 0;
+  text-align: center;
+  border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx;
+  background: #fafafa;
+}
+.type-item.selected {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.type-icon {
+  font-size: 40rpx;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.type-name {
+  font-size: 22rpx;
+  color: #666;
+}
+.amount-input {
+  width: 100%;
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.desc-input {
+  width: 100%;
+  height: 160rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.submit-bar {
+  margin-top: 40rpx;
+}
+.btn-submit {
+  width: 100%;
+  background: #F97316;
+  color: #fff;
+  border: none;
+  border-radius: 12rpx;
+  padding: 24rpx 0;
+  font-size: 32rpx;
+}
+.btn-submit[disabled] {
+  background: #ccc;
+}
+</style>

+ 0 - 249
cfc-frontend/pages/action-detail/octopus-add-help.vue

@@ -1,249 +0,0 @@
-<template>
-  <view class="container">
-    <view class="page-header">
-      <text class="page-title">记录帮助</text>
-    </view>
-
-    <!-- 选择帮助对象 -->
-    <view class="form-section">
-      <text class="section-label">帮助对象</text>
-      <view class="member-select">
-        <view class="member-item" v-for="c in contacts" :key="c.id"
-              :class="{selected: selectedContactId === c.id}"
-              @click="selectContact(c)">
-          <text class="member-name">{{ c.name }}</text>
-          <text class="member-rel">{{ c.relationshipType }}</text>
-        </view>
-      </view>
-    </view>
-
-    <!-- 选择帮助类型 -->
-    <view class="form-section">
-      <text class="section-label">帮助类型</text>
-      <view class="type-grid">
-        <view class="type-item" v-for="(name, key) in helpTypes" :key="key"
-              :class="{selected: selectedType === key}"
-              @click="selectedType = key">
-          <text class="type-icon">{{ helpTypeIcons[key] }}</text>
-          <text class="type-name">{{ name }}</text>
-        </view>
-      </view>
-    </view>
-
-    <!-- 金额(仅经济支持) -->
-    <view class="form-section" v-if="selectedType === 'MONEY'">
-      <text class="section-label">金额(选填)</text>
-      <input class="amount-input" type="digit" v-model="amount"
-             placeholder="请输入金额,单位:元" />
-    </view>
-
-    <!-- 描述 -->
-    <view class="form-section">
-      <text class="section-label">描述(选填)</text>
-      <textarea class="desc-input" v-model="description"
-                placeholder="简单描述一下这次帮助..." maxlength="256"></textarea>
-    </view>
-
-    <!-- 提交按钮 -->
-    <view class="submit-bar">
-      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存记录</button>
-    </view>
-  </view>
-</template>
-
-<script>
-import { getContactList, addOctopusHelpLog } from '../../utils/api.js'
-
-export default {
-  data() {
-    return {
-      contacts: [],
-      selectedContactId: null,
-      selectedType: '',
-      amount: '',
-      description: '',
-      helpTypes: {
-        MONEY: '经济支持',
-        ITEM: '物质帮助',
-        EMOTION: '情感支持',
-        RESOURCE: '资源帮助',
-        SKILL: '技能帮助',
-        TIME: '时间帮助',
-        OTHER: '其他'
-      },
-      helpTypeIcons: {
-        MONEY: '💰',
-        ITEM: '📦',
-        EMOTION: '💕',
-        RESOURCE: '📚',
-        SKILL: '🛠️',
-        TIME: '⏰',
-        OTHER: '🤝'
-      }
-    }
-  },
-  computed: {
-    canSubmit() {
-      return this.selectedContactId && this.selectedType
-    }
-  },
-  onLoad(options) {
-    if (options.contactId) {
-      this.selectedContactId = Number(options.contactId)
-    }
-    this.loadContacts()
-  },
-  methods: {
-    async loadContacts() {
-      try {
-        var res = await getContactList({ page: 1, size: 100 })
-        if (res.code === 200 && res.data) {
-          this.contacts = res.data.list || (Array.isArray(res.data) ? res.data : [])
-        }
-      } catch (e) {
-        uni.showToast({ title: '加载联系人失败', icon: 'none' })
-      }
-    },
-    selectContact(contact) {
-      this.selectedContactId = contact.id
-    },
-    async onSubmit() {
-      if (!this.canSubmit) {
-        uni.showToast({ title: '请选择对象和类型', icon: 'none' })
-        return
-      }
-      try {
-        var logData = {
-          contactId: this.selectedContactId,
-          helpType: this.selectedType,
-          amount: this.amount,
-          description: this.description
-        }
-        var res = await addOctopusHelpLog(logData)
-        if (res.code === 200) {
-          uni.showToast({ title: '记录成功', icon: 'success' })
-          setTimeout(function() {
-            uni.navigateBack()
-          }, 1500)
-        } else {
-          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
-        }
-      } catch (e) {
-        uni.showToast({ title: '保存失败', icon: 'none' })
-      }
-    }
-  }
-}
-</script>
-
-<style scoped>
-.container {
-  min-height: 100vh;
-  background: #f5f5f5;
-  padding: 24rpx;
-}
-.page-header {
-  padding: 20rpx 0 30rpx;
-}
-.page-title {
-  font-size: 36rpx;
-  font-weight: bold;
-  color: #333;
-}
-.form-section {
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 24rpx;
-  margin-bottom: 20rpx;
-}
-.section-label {
-  font-size: 28rpx;
-  color: #333;
-  font-weight: 500;
-  display: block;
-  margin-bottom: 16rpx;
-}
-.member-select {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 16rpx;
-}
-.member-item {
-  padding: 16rpx 24rpx;
-  border: 2rpx solid #e0e0e0;
-  border-radius: 12rpx;
-  background: #fafafa;
-}
-.member-item.selected {
-  border-color: #F97316;
-  background: #FFF7ED;
-}
-.member-name {
-  font-size: 28rpx;
-  color: #333;
-  display: block;
-}
-.member-rel {
-  font-size: 22rpx;
-  color: #999;
-}
-.type-grid {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 16rpx;
-}
-.type-item {
-  width: calc(33.33% - 11rpx);
-  padding: 20rpx 0;
-  text-align: center;
-  border: 2rpx solid #e0e0e0;
-  border-radius: 12rpx;
-  background: #fafafa;
-}
-.type-item.selected {
-  border-color: #F97316;
-  background: #FFF7ED;
-}
-.type-icon {
-  font-size: 40rpx;
-  display: block;
-  margin-bottom: 8rpx;
-}
-.type-name {
-  font-size: 22rpx;
-  color: #666;
-}
-.amount-input {
-  width: 100%;
-  height: 88rpx;
-  background: #fafafa;
-  border-radius: 12rpx;
-  padding: 0 20rpx;
-  font-size: 28rpx;
-  box-sizing: border-box;
-}
-.desc-input {
-  width: 100%;
-  height: 160rpx;
-  background: #fafafa;
-  border-radius: 12rpx;
-  padding: 20rpx;
-  font-size: 28rpx;
-  box-sizing: border-box;
-}
-.submit-bar {
-  margin-top: 40rpx;
-}
-.btn-submit {
-  width: 100%;
-  background: #F97316;
-  color: #fff;
-  border: none;
-  border-radius: 12rpx;
-  padding: 24rpx 0;
-  font-size: 32rpx;
-}
-.btn-submit[disabled] {
-  background: #ccc;
-}
-</style>

+ 165 - 0
cfc-frontend/pages/action-detail/octopus-add-key-person.vue

@@ -0,0 +1,165 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">添加关键人</text>
+    </view>
+
+    <view class="form-section">
+      <text class="section-label">姓名 <text class="required-mark">*</text></text>
+      <input class="name-input" v-model="name" placeholder="关键人姓名" maxlength="30" />
+    </view>
+
+    <view class="form-section">
+      <text class="section-label">单位/公司</text>
+      <input class="name-input" v-model="organization" placeholder="如:某小学家委会、某科技公司" maxlength="60" />
+    </view>
+
+    <view class="form-section">
+      <text class="section-label">部门</text>
+      <input class="name-input" v-model="department" placeholder="如:市场部、教务处" maxlength="30" />
+    </view>
+
+    <view class="form-section">
+      <text class="section-label">职务/职位</text>
+      <input class="name-input" v-model="title" placeholder="如:家委会主任、市场总监" maxlength="30" />
+    </view>
+
+    <view class="form-section">
+      <text class="section-label">初识场合</text>
+      <input class="name-input" v-model="firstMeetScene" placeholder="如:家长会、行业峰会、朋友介绍" maxlength="50" />
+    </view>
+
+    <view class="form-section">
+      <text class="section-label">如何认识</text>
+      <textarea class="desc-input" v-model="knowWay" placeholder="如:孩子班主任介绍认识、同事推荐" maxlength="200"></textarea>
+    </view>
+
+    <view class="form-section">
+      <text class="section-label">联系方式(选填)</text>
+      <input class="name-input" v-model="contactInfo" placeholder="电话/微信/邮箱" maxlength="50" />
+    </view>
+
+    <view class="submit-bar">
+      <button class="btn-submit" @click="onSubmit" :disabled="!name.trim()">保存关键人</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { addOctopusKeyPerson } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      name: '',
+      organization: '',
+      department: '',
+      title: '',
+      firstMeetScene: '',
+      knowWay: '',
+      contactInfo: ''
+    }
+  },
+  onLoad(options) {
+    if (options.prefill) {
+      try {
+        var prefill = JSON.parse(decodeURIComponent(options.prefill))
+        Object.assign(this.$data, prefill)
+      } catch (e) { /* 忽略 */ }
+    }
+  },
+  methods: {
+    async onSubmit() {
+      if (!this.name.trim()) {
+        uni.showToast({ title: '请填写姓名', icon: 'none' })
+        return
+      }
+      var data = {
+        name: this.name.trim(),
+        organization: this.organization.trim(),
+        department: this.department.trim(),
+        title: this.title.trim(),
+        firstMeetScene: this.firstMeetScene.trim(),
+        knowWay: this.knowWay.trim(),
+        contactInfo: this.contactInfo.trim()
+      }
+      try {
+        var res = await addOctopusKeyPerson(data)
+        if (res.code === 200) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+          setTimeout(() => uni.navigateBack(), 1500)
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 24rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+.form-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.section-label {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.required-mark {
+  color: #F97316;
+}
+.name-input {
+  width: 100%;
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.desc-input {
+  width: 100%;
+  height: 160rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.submit-bar {
+  margin-top: 40rpx;
+}
+.btn-submit {
+  width: 100%;
+  background: #F97316;
+  color: #fff;
+  border: none;
+  border-radius: 12rpx;
+  padding: 24rpx 0;
+  font-size: 32rpx;
+}
+.btn-submit[disabled] {
+  background: #ccc;
+}
+</style>

+ 0 - 222
cfc-frontend/pages/action-detail/octopus-records.vue

@@ -1,222 +0,0 @@
-<template>
-  <view class="container">
-    <view class="page-header">
-      <text class="page-title">{{ pageTitle }}</text>
-    </view>
-
-    <!-- 记录列表 -->
-    <view v-if="records.length > 0" class="records-list">
-      <view class="record-card" v-for="item in records" :key="item.id">
-        <view class="record-main">
-          <view class="record-type">
-            <text class="type-icon">{{ helpTypeMap[item.helpType] && helpTypeMap[item.helpType].icon || '🤝' }}</text>
-            <text class="type-label">{{ helpTypeMap[item.helpType] && helpTypeMap[item.helpType].label || '其他' }}</text>
-          </view>
-          <view class="record-info">
-            <text v-if="item.amount" class="record-amount">¥{{ item.amount }}</text>
-            <text v-if="item.description" class="record-desc">{{ item.description }}</text>
-            <text class="record-time">{{ formatTime(item.happenedAt) }}</text>
-          </view>
-        </view>
-        <view class="record-actions">
-          <view class="btn-delete" @click="onDelete(item)">
-            <text class="delete-text">删除</text>
-          </view>
-        </view>
-      </view>
-    </view>
-
-    <!-- 空状态 -->
-    <view v-else class="empty-state">
-      <text class="empty-icon">📋</text>
-      <text class="empty-text">暂无帮助记录</text>
-    </view>
-  </view>
-</template>
-
-<script>
-import { getOctopusRecords, deleteOctopusHelpLog } from '../../utils/api.js'
-
-export default {
-  data() {
-    return {
-      contactId: '',
-      contactName: '',
-      records: [],
-      loading: false,
-      helpTypeMap: {
-        MONEY: { icon: '💰', label: '经济支持' },
-        ITEM: { icon: '📦', label: '物质帮助' },
-        EMOTION: { icon: '💕', label: '情感支持' },
-        RESOURCE: { icon: '📚', label: '资源帮助' },
-        SKILL: { icon: '🛠️', label: '技能帮助' },
-        TIME: { icon: '⏰', label: '时间帮助' },
-        OTHER: { icon: '🤝', label: '其他' }
-      }
-    }
-  },
-  computed: {
-    pageTitle() {
-      return this.contactName ? (this.contactName + ' 的帮助记录') : '帮助记录'
-    }
-  },
-  onLoad(options) {
-    if (options && options.contactId) {
-      this.contactId = options.contactId
-    }
-    if (options && options.contactName) {
-      this.contactName = options.contactName
-    }
-    this.loadRecords()
-  },
-  methods: {
-    formatTime(str) {
-      if (!str) return ''
-      if (str.indexOf('T') !== -1) {
-        return str.replace('T', ' ').slice(0, 19)
-      }
-      return str.slice(0, 10)
-    },
-    async loadRecords() {
-      if (this.loading) return
-      this.loading = true
-      try {
-        var res = await getOctopusRecords({ contactId: this.contactId })
-        // 后端返回 { contact, summary, records: [...] }
-        var list = (res.code === 200 && res.data && res.data.records) ? res.data.records : []
-        if (!this.contactName && res.data && res.data.contact) {
-          this.contactName = res.data.contact.name || ''
-        }
-        this.records = list.slice().sort(function(a, b) {
-          return (b.happenedAt || '').localeCompare(a.happenedAt || '')
-        })
-      } catch (e) {
-        uni.showToast({ title: '加载记录失败', icon: 'none' })
-      } finally {
-        this.loading = false
-      }
-    },
-    onDelete(record) {
-      var that = this
-      uni.showModal({
-        title: '确认删除',
-        content: '确定要删除这条帮助记录吗?',
-        success: function(res) {
-          if (res.confirm) {
-            that.doDelete(record)
-          }
-        }
-      })
-    },
-    async doDelete(record) {
-      try {
-        var res = await deleteOctopusHelpLog({ logId: record.id })
-        if (res.code === 200) {
-          uni.showToast({ title: '删除成功', icon: 'success' })
-          this.loadRecords()
-        } else {
-          uni.showToast({ title: res.message || '删除失败', icon: 'none' })
-        }
-      } catch (e) {
-        uni.showToast({ title: '删除失败', icon: 'none' })
-      }
-    }
-  }
-}
-</script>
-
-<style scoped>
-.container {
-  min-height: 100vh;
-  background: #f5f5f5;
-  padding: 24rpx;
-}
-.page-header {
-  padding: 20rpx 0 30rpx;
-}
-.page-title {
-  font-size: 36rpx;
-  font-weight: bold;
-  color: #333;
-}
-.records-list {
-  padding-bottom: 40rpx;
-}
-.record-card {
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 24rpx;
-  margin-bottom: 16rpx;
-}
-.record-main {
-  display: flex;
-  align-items: flex-start;
-}
-.record-type {
-  display: flex;
-  align-items: center;
-  margin-right: 20rpx;
-  flex-shrink: 0;
-}
-.type-icon {
-  font-size: 36rpx;
-  margin-right: 8rpx;
-}
-.type-label {
-  font-size: 28rpx;
-  color: #F97316;
-  font-weight: 500;
-}
-.record-info {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-}
-.record-amount {
-  font-size: 32rpx;
-  color: #F97316;
-  font-weight: bold;
-  margin-bottom: 8rpx;
-}
-.record-desc {
-  font-size: 26rpx;
-  color: #666;
-  margin-bottom: 8rpx;
-  line-height: 1.5;
-}
-.record-time {
-  font-size: 22rpx;
-  color: #999;
-}
-.record-actions {
-  display: flex;
-  justify-content: flex-end;
-  margin-top: 16rpx;
-  padding-top: 16rpx;
-  border-top: 1rpx solid #f0f0f0;
-}
-.btn-delete {
-  padding: 8rpx 24rpx;
-  border: 1rpx solid #ff4d4f;
-  border-radius: 8rpx;
-}
-.delete-text {
-  font-size: 22rpx;
-  color: #ff4d4f;
-}
-.empty-state {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  padding-top: 200rpx;
-}
-.empty-icon {
-  font-size: 80rpx;
-  margin-bottom: 24rpx;
-}
-.empty-text {
-  font-size: 28rpx;
-  color: #999;
-}
-</style>

+ 14 - 5
cfc-frontend/utils/api.js

@@ -2268,11 +2268,6 @@ export const deleteContact = (id) => request('/api/contact/delete', 'POST', { id
 export const importPhoneContact = (data) => request('/api/contact/import-phone', 'POST', data)
 export const recordContactInteraction = (data) => request('/api/contact/interaction', 'POST', data)
 
-// Octopus 章鱼图
-export const getOctopusTentacles = () => request('/api/octopus/tentacles', 'POST')
-export const addOctopusHelpLog = (data) => request('/api/octopus/add', 'POST', data)
-export const deleteOctopusHelpLog = (data) => request('/api/octopus/delete', 'POST', data)
-export const getOctopusRecords = (data) => request('/api/octopus/records', 'POST', data)
 
 export const getPearlResources = () => request('/api/pearl/resources', 'POST')
 export const addPearlResource = (data) => request('/api/pearl/item/add', 'POST', data)
@@ -2284,6 +2279,20 @@ export const getPearlReminders = () => request('/api/pearl/reminder/list', 'POST
 export const updatePearlPriority = (data) => request('/api/pearl/item/update-priority', 'POST', data)
 export const getAbilityMap = () => request('/api/ability/map', 'POST')
 
+// ===== 关键人拓展章鱼图 =====
+export const getOctopusEffects = (data) => request('/api/octopus/effect/list', 'POST', data)
+export const addOctopusEffect = (data) => request('/api/octopus/effect/add', 'POST', data)
+export const deleteOctopusEffect = (data) => request('/api/octopus/effect/delete', 'POST', data)
+
+export const addOctopusKeyPerson = (data) => request('/api/octopus/key-person/add', 'POST', data)
+export const updateOctopusKeyPerson = (data) => request('/api/octopus/key-person/update', 'POST', data)
+export const deleteOctopusKeyPerson = (data) => request('/api/octopus/key-person/delete', 'POST', data)
+export const getOctopusEffectKeyPersons = (data) => request('/api/octopus/effect/key-persons', 'POST', data)
+
+export const getOctopusAnalysisKeywords = (data) => request('/api/octopus/analysis/keywords', 'POST', data)
+export const getOctopusAnalysisSummary = (data) => request('/api/octopus/analysis/summary', 'POST', data)
+export const getOctopusExpansionSuggest = (data) => request('/api/octopus/expansion/suggest', 'POST', data)
+
 // ===== 椋熻氨鎺ㄨ崘 =====
 export const getMealRecommend = (params) => request('/api/meal/recommend', 'POST', params)
 export const replaceFood = (data) => request('/api/meal/replace', 'POST', data)

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott