Преглед изворни кода

feat(activity): 审核仅填五维权重并自动推导维度,活动标签自动生成

Xiaogang Liao пре 6 дана
родитељ
комит
ae2e60f108

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

@@ -3104,6 +3104,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移388: activities表添加intro列(活动简介,纯文本短摘要,用于活动列表卡片展示)
         ensureColumn("activities", "intro", "VARCHAR(200) DEFAULT NULL COMMENT '活动简介(列表卡片展示)'");
 
+        // 迁移389: activities表添加tags列(活动标签,逗号分隔,根据活动内容自动生成)
+        ensureColumn("activities", "tags", "VARCHAR(255) DEFAULT NULL COMMENT '活动标签,逗号分隔'");
+
         // 迁移382: health_status 添加 signature 列(用户手签名,base64 PNG data URL,用于问卷回答确认)
         ensureColumn("health_status", "signature", "MEDIUMTEXT DEFAULT NULL COMMENT '用户手签名(base64 PNG data URL),确认问卷回答准确性'");
 

+ 6 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityController.java

@@ -75,15 +75,19 @@ public class AdminActivityController {
         Long id = ParamUtils.getLong(params.get("id"));
         String action = (String) params.get("action");
         String rejectReason = (String) params.get("rejectReason");
-        String dimensionCode = (String) params.get("dimensionCode");
         @SuppressWarnings("unchecked")
         List<Map<String, Object>> dimensionWeights =
                 (List<Map<String, Object>>) params.get("dimensionWeights");
         if (id == null || action == null) return Result.error("id和action不能为空");
-        activityAdminService.auditActivity(id, action, rejectReason, adminId, dimensionCode, dimensionWeights);
+        activityAdminService.auditActivity(id, action, rejectReason, adminId, dimensionWeights);
         return Result.success("已审核");
     }
 
+    @PostMapping("/backfill-tags")
+    public Result<Integer> backfillTags() {
+        return Result.success(activityAdminService.backfillTags());
+    }
+
     @PostMapping("/withdraw")
     public Result<String> withdraw(@RequestBody Map<String, Object> params) {
         Long id = ParamUtils.getLong(params.get("id"));

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

@@ -11,6 +11,7 @@ public class ActivityDTO {
     private String title;
     private String description;
     private String intro;
+    private String tags;
     private String coverImage;
     private String dimensionCode;
     /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
@@ -82,6 +83,7 @@ public class ActivityDTO {
         dto.setTitle(activity.getTitle());
         dto.setDescription(activity.getDescription());
         dto.setIntro(activity.getIntro());
+        dto.setTags(activity.getTags());
         dto.setCoverImage(activity.getCoverImage());
         dto.setDimensionCode(activity.getDimensionCode());
         dto.setDimensionWeights(activity.getDimensionWeights());

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java

@@ -22,6 +22,9 @@ public class Activity implements Serializable {
     /** 活动简介(纯文本短摘要,用于活动列表卡片展示) */
     private String intro;
 
+    /** 活动标签,逗号分隔(根据活动内容自动生成,可人工修正) */
+    private String tags;
+
     private String coverImage;
 
     /** body/mind/wisdom/action/wealth */

+ 32 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityAdminService.java

@@ -33,6 +33,9 @@ public class ActivityAdminService {
     @Resource
     private DimensionWeightService dimensionWeightService;
 
+    @Resource
+    private ActivityTagService activityTagService;
+
     /**
      * 分页查询活动列表(包含草稿)
      * 支持按 status / dimensionCode / auditStatus 过滤
@@ -136,14 +139,16 @@ public class ActivityAdminService {
         activity.setStatus("pending");
         activity.setAuditStatus("pending");
         activityMapper.updateById(activity);
+        // 提交审核时异步生成标签,便于审核页直接查看
+        activityTagService.generateIfEmptyAsync(id);
     }
 
     /**
      * 审核活动(审核中心:ActivityAudit.vue 使用)
-     * @param params action: "approved" | "rejected", id: Long, dimensionCode: String(approved 时必填), rejectReason: String
+     * 审核通过时只需提交五维权重,所属维度自动取权重最高的维度。
      */
     @Transactional
-    public void auditActivity(Long id, String action, String auditReason, Long adminId, String dimensionCode,
+    public void auditActivity(Long id, String action, String auditReason, Long adminId,
                               List<Map<String, Object>> dimensionWeights) {
         Activity activity = activityMapper.selectById(id);
         if (activity == null) {
@@ -158,13 +163,10 @@ public class ActivityAdminService {
         }
         List<DimensionWeight> weights = null;
         if ("approved".equals(normalizedAction)) {
-            if (dimensionCode == null || dimensionCode.trim().isEmpty()) {
-                throw new IllegalStateException("审核通过时必须选择所属维度");
-            }
             weights = parseAndValidateDimensionWeights(dimensionWeights);
             activity.setStatus("published");
             activity.setAuditStatus("approved");
-            activity.setDimensionCode(dimensionCode.trim());
+            activity.setDimensionCode(resolveTopDimension(weights));
             activity.setAuditorId(adminId);
             activity.setAuditedAt(new Date());
         } else {
@@ -178,9 +180,33 @@ public class ActivityAdminService {
         if (weights != null) {
             // 同时写入 dimension_weights 表与 activities.dimension_weights JSON 列
             dimensionWeightService.saveWeights("activity", id, weights);
+            // 审核通过时若标签仍为空则异步补标
+            activityTagService.generateIfEmptyAsync(id);
         }
     }
 
+    /**
+     * 所属维度取五维权重中占比最高的维度;并列时按 身/心/智/行/富 顺序取第一个。
+     */
+    private String resolveTopDimension(List<DimensionWeight> weights) {
+        String top = null;
+        int max = -1;
+        for (DimensionWeight weight : weights) {
+            if (weight.getWeight() != null && weight.getWeight() > max) {
+                max = weight.getWeight();
+                top = weight.getDimension();
+            }
+        }
+        return top;
+    }
+
+    /**
+     * 为已发布且标签为空的活动批量补标(异步)。
+     */
+    public int backfillTags() {
+        return activityTagService.backfillEmptyPublished();
+    }
+
     private static final List<String> REQUIRED_DIMENSIONS =
             Arrays.asList("body", "mind", "wisdom", "action", "wealth");
 

+ 155 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityTagService.java

@@ -0,0 +1,155 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.mapper.ActivityMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 活动标签:审核提交/通过时根据活动内容自动生成 3-5 个中文关键词。
+ * 与文章自动打标一致,走 AIService;失败仅记录日志,不影响活动流程。
+ */
+@Service
+public class ActivityTagService {
+
+    private static final Logger log = LoggerFactory.getLogger(ActivityTagService.class);
+    private static final String UPDATE_TAGS_SQL = "UPDATE activities SET tags = ? WHERE id = ?";
+
+    @Resource
+    private ActivityMapper activityMapper;
+
+    @Resource
+    private AIService aiService;
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    @Resource(name = "taskExecutor")
+    private ThreadPoolTaskExecutor taskExecutor;
+
+    /** 异步生成,不阻塞审核/提交流程 */
+    public void generateIfEmptyAsync(Long activityId) {
+        if (activityId == null) {
+            return;
+        }
+        try {
+            taskExecutor.execute(() -> {
+                try {
+                    generateIfEmpty(activityId);
+                } catch (Exception e) {
+                    log.warn("活动自动标签生成失败: activityId={}, error={}", activityId, e.getMessage());
+                }
+            });
+        } catch (Exception e) {
+            log.warn("活动自动标签任务提交失败: activityId={}, error={}", activityId, e.getMessage());
+        }
+    }
+
+    /** 仅当活动存在且标签为空时生成并写回 */
+    public void generateIfEmpty(Long activityId) {
+        if (activityId == null) {
+            return;
+        }
+        Activity activity = activityMapper.selectById(activityId);
+        if (activity == null) {
+            return;
+        }
+        if (activity.getTags() != null && !activity.getTags().trim().isEmpty()) {
+            return;
+        }
+        List<String> tags = generateTags(buildContent(activity));
+        if (tags.isEmpty()) {
+            return;
+        }
+        jdbcTemplate.update(UPDATE_TAGS_SQL, String.join(",", tags), activityId);
+    }
+
+    /**
+     * 调用 AI 从活动内容中提取 3-5 个中文关键词作为标签。
+     */
+    public List<String> generateTags(String content) {
+        if (content == null || content.trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        String plainText = content.replaceAll("<[^>]*>", " ")
+                .replaceAll("\\s+", " ")
+                .trim();
+        if (plainText.isEmpty()) {
+            return Collections.emptyList();
+        }
+        if (plainText.length() > 1500) {
+            plainText = plainText.substring(0, 1500);
+        }
+
+        String prompt = "你是一个家庭活动内容分析助手。请根据以下活动信息,提取 3 到 5 个最能代表活动主题的中文关键词作为标签。"
+                + "要求:1) 关键词为中文短语或名词,2-6 个字;2) 用英文逗号分隔;3) 不要包含编号、引号或解释文字;4) 只返回标签列表本身。"
+                + "\n\n活动信息:" + plainText;
+
+        try {
+            Map<String, Object> aiResult = aiService.sendMessage(prompt, "0", "", null);
+            Object answerObj = aiResult == null ? null : aiResult.get("answer");
+            if (answerObj == null || answerObj.toString().trim().isEmpty()) {
+                return Collections.emptyList();
+            }
+            List<String> tags = new ArrayList<>();
+            for (String raw : answerObj.toString().split("[,,、]")) {
+                String tag = raw.trim()
+                        .replaceAll("^[\\d\\.\\-\\s、。,]+", "")
+                        .replaceAll("[\\s、。,]+$", "")
+                        .trim();
+                if (!tag.isEmpty() && tag.length() <= 20 && !tags.contains(tag) && tags.size() < 5) {
+                    tags.add(tag);
+                }
+            }
+            return tags;
+        } catch (Exception e) {
+            log.warn("活动 AI 自动生成标签失败:{}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * 为已发布且标签为空的活动批量补标(异步,幂等)。
+     *
+     * @return 本次触发的活动数量
+     */
+    public int backfillEmptyPublished() {
+        List<Activity> activities = activityMapper.selectList(
+                new LambdaQueryWrapper<Activity>()
+                        .eq(Activity::getStatus, "published")
+                        .and(w -> w.isNull(Activity::getTags).or().apply("TRIM(tags) = ''")));
+        for (Activity activity : activities) {
+            generateIfEmptyAsync(activity.getId());
+        }
+        return activities.size();
+    }
+
+    private String buildContent(Activity activity) {
+        StringBuilder sb = new StringBuilder();
+        append(sb, activity.getTitle());
+        append(sb, activity.getIntro());
+        append(sb, activity.getDescription());
+        append(sb, activity.getLocation());
+        append(sb, activity.getActivityType());
+        return sb.toString();
+    }
+
+    private void append(StringBuilder sb, String value) {
+        if (value != null && !value.trim().isEmpty()) {
+            if (sb.length() > 0) {
+                sb.append(" ");
+            }
+            sb.append(value.trim());
+        }
+    }
+}

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

@@ -2109,6 +2109,7 @@ CREATE TABLE IF NOT EXISTS activities (
     title            VARCHAR(100) NOT NULL COMMENT '活动标题',
     description      TEXT COMMENT '活动描述',
     intro            VARCHAR(200) DEFAULT NULL COMMENT '活动简介(列表卡片展示)',
+    tags             VARCHAR(255) DEFAULT NULL COMMENT '活动标签,逗号分隔',
     cover_image      VARCHAR(500) COMMENT '封面图片URL',
     dimension_code   VARCHAR(20) COMMENT '维度代码: body/mind/wisdom/action/wealth',
     dimension_weights JSON DEFAULT NULL COMMENT '五维权重百分比: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0}',

+ 49 - 8
cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityAuditTest.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.entity.DimensionWeight;
 import com.etotem.cfc.mapper.ActivityMapper;
 import com.etotem.cfc.service.ActivityAdminService;
+import com.etotem.cfc.service.ActivityTagService;
 import com.etotem.cfc.service.DimensionWeightService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -21,21 +22,25 @@ import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 
 /**
- * 审核中心活动审核:动作兼容 publish/reject,审核通过时必须补充五维权重。
+ * 审核中心活动审核:动作兼容 publish/reject;通过时只填五维权重,
+ * 所属维度由权重最高的维度自动推导。
  */
 class ActivityAuditTest {
 
     private ActivityAdminService service;
     private ActivityMapper activityMapper;
     private DimensionWeightService dimensionWeightService;
+    private ActivityTagService activityTagService;
 
     @BeforeEach
     void setUp() throws Exception {
         service = new ActivityAdminService();
         activityMapper = mock(ActivityMapper.class);
         dimensionWeightService = mock(DimensionWeightService.class);
+        activityTagService = mock(ActivityTagService.class);
         set("activityMapper", activityMapper);
         set("dimensionWeightService", dimensionWeightService);
+        set("activityTagService", activityTagService);
     }
 
     private void set(String name, Object value) throws Exception {
@@ -73,7 +78,7 @@ class ActivityAuditTest {
         when(activityMapper.selectById(9L)).thenReturn(activity);
         when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
 
-        service.auditActivity(9L, "publish", null, 1L, "body", weights(20, 20, 20, 20, 20));
+        service.auditActivity(9L, "publish", null, 1L, weights(20, 20, 20, 20, 20));
 
         assertEquals("published", activity.getStatus());
         assertEquals("approved", activity.getAuditStatus());
@@ -82,13 +87,35 @@ class ActivityAuditTest {
         assertNotNull(activity.getAuditedAt());
     }
 
+    @Test
+    void audit_通过时按最高权重自动确定所属维度() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+        when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
+
+        service.auditActivity(9L, "approved", null, 1L, weights(10, 50, 10, 20, 10));
+
+        assertEquals("mind", activity.getDimensionCode());
+    }
+
+    @Test
+    void audit_最高权重并列时按身心灵知行顺序取第一个() {
+        Activity activity = pending();
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+        when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
+
+        service.auditActivity(9L, "approved", null, 1L, weights(50, 50, 0, 0, 0));
+
+        assertEquals("body", activity.getDimensionCode());
+    }
+
     @Test
     void audit_通过时保存五维权重() {
         Activity activity = pending();
         when(activityMapper.selectById(9L)).thenReturn(activity);
         when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
 
-        service.auditActivity(9L, "approved", null, 1L, "middle", weights(40, 15, 15, 15, 15));
+        service.auditActivity(9L, "approved", null, 1L, weights(40, 15, 15, 15, 15));
 
         @SuppressWarnings("unchecked")
         ArgumentCaptor<List<DimensionWeight>> captor = ArgumentCaptor.forClass(List.class);
@@ -101,6 +128,20 @@ class ActivityAuditTest {
         assertNotNull(body);
         assertEquals(40, body.getWeight());
         assertTrue(body.getEnabled());
+        verify(activityTagService).generateIfEmptyAsync(9L);
+    }
+
+    @Test
+    void submitForReview_提交后异步生成标签() {
+        Activity activity = pending();
+        activity.setStatus("draft");
+        when(activityMapper.selectById(9L)).thenReturn(activity);
+        when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
+
+        service.submitForReview(9L);
+
+        assertEquals("pending", activity.getStatus());
+        verify(activityTagService).generateIfEmptyAsync(9L);
     }
 
     @Test
@@ -109,7 +150,7 @@ class ActivityAuditTest {
         when(activityMapper.selectById(9L)).thenReturn(activity);
 
         IllegalStateException e = assertThrows(IllegalStateException.class,
-                () -> service.auditActivity(9L, "approved", null, 1L, "body", null));
+                () -> service.auditActivity(9L, "approved", null, 1L, null));
 
         assertTrue(e.getMessage().contains("五维权重"), e.getMessage());
         verify(activityMapper, never()).updateById(any(Activity.class));
@@ -122,7 +163,7 @@ class ActivityAuditTest {
         when(activityMapper.selectById(9L)).thenReturn(activity);
 
         IllegalStateException e = assertThrows(IllegalStateException.class,
-                () -> service.auditActivity(9L, "approved", null, 1L, "body", weights(20, 20, 20, 20, 19)));
+                () -> service.auditActivity(9L, "approved", null, 1L, weights(20, 20, 20, 20, 19)));
 
         assertTrue(e.getMessage().contains("100"), e.getMessage());
         verify(activityMapper, never()).updateById(any(Activity.class));
@@ -137,7 +178,7 @@ class ActivityAuditTest {
         onlyFour.removeIf(m -> "wealth".equals(m.get("dimension")));
 
         IllegalStateException e = assertThrows(IllegalStateException.class,
-                () -> service.auditActivity(9L, "approved", null, 1L, "body", onlyFour));
+                () -> service.auditActivity(9L, "approved", null, 1L, onlyFour));
 
         assertTrue(e.getMessage().contains("五个维度"), e.getMessage());
     }
@@ -148,7 +189,7 @@ class ActivityAuditTest {
         when(activityMapper.selectById(9L)).thenReturn(activity);
         when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
 
-        service.auditActivity(9L, "reject", "材料不全", 1L, null, null);
+        service.auditActivity(9L, "reject", "材料不全", 1L, null);
 
         assertEquals("rejected", activity.getStatus());
         assertEquals("rejected", activity.getAuditStatus());
@@ -162,7 +203,7 @@ class ActivityAuditTest {
         when(activityMapper.selectById(9L)).thenReturn(activity);
 
         IllegalStateException e = assertThrows(IllegalStateException.class,
-                () -> service.auditActivity(9L, "unknown", null, 1L, "body", weights(20, 20, 20, 20, 20)));
+                () -> service.auditActivity(9L, "unknown", null, 1L, weights(20, 20, 20, 20, 20)));
 
         assertTrue(e.getMessage().contains("审核操作无效"), e.getMessage());
     }

+ 111 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ActivityTagServiceTest.java

@@ -0,0 +1,111 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.service.AIService;
+import com.etotem.cfc.service.ActivityTagService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 活动自动标签:与文章一致,根据标题/简介/描述等内容由 AI 提取 3-5 个关键词。
+ */
+class ActivityTagServiceTest {
+
+    private ActivityTagService service;
+    private ActivityMapper activityMapper;
+    private AIService aiService;
+    private JdbcTemplate jdbcTemplate;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ActivityTagService();
+        activityMapper = mock(ActivityMapper.class);
+        aiService = mock(AIService.class);
+        jdbcTemplate = mock(JdbcTemplate.class);
+        set("activityMapper", activityMapper);
+        set("aiService", aiService);
+        set("jdbcTemplate", jdbcTemplate);
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field f = ActivityTagService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    private Activity activity(Long id, String tags) {
+        Activity a = new Activity();
+        a.setId(id);
+        a.setTitle("周末亲子户外徒步");
+        a.setIntro("带孩子亲近自然");
+        a.setDescription("<p>亲子徒步、自然观察与野餐</p>");
+        a.setTags(tags);
+        return a;
+    }
+
+    @Test
+    void generateTags_解析AI返回的中文逗号与顿号() {
+        Map<String, Object> ai = new HashMap<>();
+        ai.put("answer", "1. 亲子活动,户外徒步、自然教育");
+        when(aiService.sendMessage(anyString(), anyString(), anyString(), any())).thenReturn(ai);
+
+        List<String> tags = service.generateTags("周末亲子户外徒步,带孩子亲近自然");
+
+        assertEquals(3, tags.size());
+        assertEquals("亲子活动", tags.get(0));
+        assertEquals("户外徒步", tags.get(1));
+        assertEquals("自然教育", tags.get(2));
+    }
+
+    @Test
+    void generateTags_空内容不调用AI() {
+        assertTrue(service.generateTags("   ").isEmpty());
+        verifyNoInteractions(aiService);
+    }
+
+    @Test
+    void generateIfEmpty_已有标签时不重复生成() {
+        when(activityMapper.selectById(9L)).thenReturn(activity(9L, "亲子,户外"));
+
+        service.generateIfEmpty(9L);
+
+        verifyNoInteractions(aiService);
+        verify(jdbcTemplate, never()).update(anyString(), any(Object[].class));
+    }
+
+    @Test
+    void generateIfEmpty_标签为空时生成并写回() {
+        when(activityMapper.selectById(9L)).thenReturn(activity(9L, null));
+        Map<String, Object> ai = new HashMap<>();
+        ai.put("answer", "亲子活动,户外徒步,自然教育");
+        when(aiService.sendMessage(anyString(), anyString(), anyString(), any())).thenReturn(ai);
+
+        service.generateIfEmpty(9L);
+
+        verify(jdbcTemplate).update("UPDATE activities SET tags = ? WHERE id = ?",
+                "亲子活动,户外徒步,自然教育", 9L);
+    }
+
+    @Test
+    void generateIfEmpty_AI无返回时不写库() {
+        when(activityMapper.selectById(9L)).thenReturn(activity(9L, ""));
+        when(aiService.sendMessage(anyString(), anyString(), anyString(), any()))
+                .thenReturn(Collections.emptyMap());
+
+        service.generateIfEmpty(9L);
+
+        verify(jdbcTemplate, never()).update(anyString(), any(Object[].class));
+    }
+}

+ 20 - 0
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -28,6 +28,9 @@
 
       <view class="info-section">
         <text class="activity-title">{{ activity.title }}</text>
+        <view class="activity-tags" v-if="tagList(activity.tags).length">
+          <text class="tag-chip" v-for="(t, ti) in tagList(activity.tags)" :key="ti">{{ t }}</text>
+        </view>
 
         <!-- 活动状态标签 -->
         <view class="status-row">
@@ -369,6 +372,9 @@ export default {
     this.fontScale = getFontScale()
   },
   methods: {
+    tagList: function(tags) {
+      return String(tags || '').split(/[,,、]/).map(function(t) { return t.trim() }).filter(function(t) { return !!t })
+    },
     // ===== 工具方法 =====
     getImageUrl: function(path) {
       if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
@@ -985,6 +991,20 @@ export default {
 .info-section {
   padding: 30rpx;
 }
+.activity-tags {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 16rpx;
+}
+.tag-chip {
+  font-size: 22rpx;
+  color: $brand-primary;
+  background: $uni-primary-pale;
+  padding: 3rpx 14rpx;
+  border-radius: 6rpx;
+  margin-right: 10rpx;
+  margin-bottom: 6rpx;
+}
 .activity-title {
   font-size: calc(36rpx * var(--font-scale, 1));
   font-weight: bold;

+ 29 - 0
cfc-frontend/pages/activity/index.vue

@@ -56,6 +56,9 @@
              lazy-load />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
+              <view class="card-tags" v-if="tagList(act.tags).length">
+                <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
+              </view>
               <text class="card-meta" v-if="act.registrantName">报名人:{{ act.registrantName }}</text>
               <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(act.endTime) : '' }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
@@ -130,6 +133,9 @@
              lazy-load />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
+              <view class="card-tags" v-if="tagList(act.tags).length">
+                <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
+              </view>
               <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(act.endTime) : '' }}</text>
               <text class="card-desc" v-if="act.intro || act.description">{{ act.intro || act.description }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
@@ -195,6 +201,9 @@
              lazy-load />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
+              <view class="card-tags" v-if="tagList(act.tags).length">
+                <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
+              </view>
               <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(act.endTime) : '' }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
@@ -273,6 +282,9 @@
              lazy-load />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
+              <view class="card-tags" v-if="tagList(act.tags).length">
+                <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
+              </view>
               <text class="card-meta">{{ act.startTime }}</text>
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act.status || 'draft')">
@@ -386,6 +398,9 @@ export default {
     }
   },
   methods: {
+    tagList: function(tags) {
+      return String(tags || '').split(/[,,、]/).map(function(t) { return t.trim() }).filter(function(t) { return !!t })
+    },
     // 活动管理权限判定:与后端 ActivityController.hasActivityManageRole 对齐。
     // 后台角色(admin / activity_admin / activity_manager)→ 可直接发布;
     // 其余账号查服务商入驻状态,vendorType=activity_provider 且审核通过 → 可管理(只能提交审核)。
@@ -994,6 +1009,20 @@ uni.showToast({ title: '请先选择家庭成员身份', icon: 'none' })
   flex-direction: column;
 }
 
+.card-tags {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 10rpx;
+}
+.tag-chip {
+  font-size: 22rpx;
+  color: $brand-primary;
+  background: $uni-primary-pale;
+  padding: 2rpx 12rpx;
+  border-radius: 6rpx;
+  margin-right: 8rpx;
+  margin-bottom: 4rpx;
+}
 .card-title {
   font-size: calc(28rpx * var(--font-scale, 1));
   font-weight: 600;

+ 4 - 5
cfc-web/src/router/index.js

@@ -696,11 +696,10 @@ const routes = [
           meta: { title: '通知模板管理', perm: 'admin:all' }
         },
         {
-        path: 'activity-review',
-        name: 'ActivityReview',
-        component: () => import('@/views/admin/ActivityReview.vue'),
-        meta: { title: '活动审核', perm: 'activity:review' }
-      },
+          // 旧版活动审核页已由审核中心统一承接
+          path: 'activity-review',
+          redirect: '/review-center?tab=activity'
+        },
       {
         path: 'activity-registration-review',
         name: 'ActivityRegistrationReview',

+ 11 - 2
cfc-web/src/views/admin/ActivityEdit.vue

@@ -79,6 +79,10 @@
           <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" :readonly="readonly" />
         </el-form-item>
 
+        <el-form-item label="活动标签">
+          <el-input v-model="form.tags" :disabled="readonly" placeholder="多个标签用逗号分隔;留空则在提交审核时自动生成" />
+        </el-form-item>
+
         <el-form-item label="活动地点">
           <el-input v-model="form.location" placeholder="请输入活动地点" :disabled="readonly" />
         </el-form-item>
@@ -198,6 +202,7 @@ export default {
         description: '',
         dimensionCode: '',
         dimensionWeights: [],
+        tags: '',
         location: '',
         maxParticipants: null,
         priceYuan: null,
@@ -318,6 +323,7 @@ export default {
             description: d.description || '',
             dimensionCode: d.dimensionCode || '',
             dimensionWeights: [],
+            tags: d.tags || '',
             location: d.location || '',
             maxParticipants: d.maxParticipants || null,
             priceYuan: d.price ? d.price / 100 : null,
@@ -397,7 +403,8 @@ export default {
           visibility: this.form.visibility,
           visibleScope: this.form.visibleScope,
           visibleTo: this.form.visibleTo,
-          checkinPoints: this.form.checkinPoints || 0
+          checkinPoints: this.form.checkinPoints || 0,
+          tags: this.form.tags || ''
         }
         if (this.isEdit) {
           // 编辑模式:保存后直接返回列表
@@ -439,6 +446,7 @@ export default {
         description: '',
         dimensionCode: '',
         dimensionWeights: [],
+        tags: '',
         location: '',
         maxParticipants: null,
         priceYuan: null,
@@ -504,7 +512,8 @@ export default {
           visibility: this.form.visibility,
           visibleScope: this.form.visibleScope,
           visibleTo: this.form.visibleTo,
-          checkinPoints: this.form.checkinPoints || 0
+          checkinPoints: this.form.checkinPoints || 0,
+          tags: this.form.tags || ''
         }
         const activityId = this.form._activityId || this.$route.query.id
         if (

+ 6 - 15
cfc-web/src/views/admin/review/ActivityAudit.vue

@@ -60,14 +60,8 @@
         <el-form-item label="活动内容">
           <div style="white-space:pre-wrap;">{{ currentRow.description || currentRow.content || '-' }}</div>
         </el-form-item>
-        <el-form-item label="所属维度" v-if="currentRow && currentRow.auditStatus === 'pending'">
-          <el-select v-model="auditDimensionCode" placeholder="请选择维度" style="width:100%">
-            <el-option label="身 · 主动健康" value="body" />
-            <el-option label="心 · 情感丰盈" value="mind" />
-            <el-option label="智 · 赋能未来" value="wisdom" />
-            <el-option label="行 · 知行合一" value="action" />
-            <el-option label="富 · 物质精神" value="wealth" />
-          </el-select>
+        <el-form-item label="活动标签" v-if="currentRow && currentRow.tags">
+          <el-tag v-for="tag in tagList(currentRow.tags)" :key="tag" size="small" style="margin-right:6px;">{{ tag }}</el-tag>
         </el-form-item>
         <el-form-item label="五维权重" v-if="currentRow && currentRow.auditStatus === 'pending'">
           <dimension-weight-picker v-model="auditWeights" :show-presets="true" />
@@ -102,7 +96,7 @@ export default {
       list: [], loading: false, keyword: '', statusFilter: 'pending',
       sortableColumns: { id: 'ID', title: '活动名称', auditReason: '原因' },
       detailVisible: false, currentRow: null, auditLoading: false,
-      auditDimensionCode: '', auditWeights: []
+      auditWeights: []
     }
   },
   mounted() { this.loadData() },
@@ -126,9 +120,11 @@ export default {
         dimension, weight: 20, enabled: true
       }))
     },
+    tagList(tags) {
+      return (tags || '').split(/[,,、]/).map(t => t.trim()).filter(Boolean)
+    },
     async showDetail(row) {
       this.currentRow = row
-      this.auditDimensionCode = row.dimensionCode || ''
       this.auditWeights = this.defaultWeights()
       this.detailVisible = true
       if (row.auditStatus !== 'pending') return
@@ -145,10 +141,6 @@ export default {
       }
     },
     handleApprove() {
-      if (!this.auditDimensionCode) {
-        this.$message.warning('审核通过时必须选择所属维度')
-        return
-      }
       const weights = this.auditWeights || []
       const missing = ['body', 'mind', 'wisdom', 'action', 'wealth']
         .some(d => !weights.find(w => w.dimension === d))
@@ -166,7 +158,6 @@ export default {
       try {
         const params = { id: this.currentRow.id, action, rejectReason: action === 'rejected' ? reason : '' }
         if (action === 'approved') {
-          params.dimensionCode = this.auditDimensionCode
           params.dimensionWeights = this.auditWeights
         }
         await auditActivity(params)

+ 4 - 16
cfc-web/src/views/admin/review/__tests__/ActivityAudit.spec.js

@@ -1,6 +1,6 @@
 import { shallowMount, createLocalVue } from '@vue/test-utils'
 import ActivityAudit from '../ActivityAudit.vue'
-import { auditActivity, getActivityList } from '@/api/activity'
+import { auditActivity } from '@/api/activity'
 import { getDimensionWeights } from '@/api/dimension-weight'
 
 jest.mock('@/api/activity', () => ({
@@ -42,20 +42,9 @@ describe('ActivityAudit 审核提交', () => {
     jest.clearAllMocks()
   })
 
-  it('未选择所属维度时不提交', () => {
-    const wrapper = mount()
-    wrapper.vm.currentRow = { id: 9, auditStatus: 'pending' }
-    wrapper.vm.auditDimensionCode = ''
-    wrapper.vm.handleApprove()
-
-    expect(wrapper.vm.$message.warning).toHaveBeenCalled()
-    expect(auditActivity).not.toHaveBeenCalled()
-  })
-
   it('五维权重合计不为 100% 时不提交', () => {
     const wrapper = mount()
     wrapper.vm.currentRow = { id: 9, auditStatus: 'pending' }
-    wrapper.vm.auditDimensionCode = 'body'
     wrapper.vm.auditWeights = validWeights().map((w, i) => ({ ...w, weight: i === 0 ? 19 : 20 }))
     wrapper.vm.handleApprove()
 
@@ -63,10 +52,9 @@ describe('ActivityAudit 审核提交', () => {
     expect(auditActivity).not.toHaveBeenCalled()
   })
 
-  it('校验通过后以 approved 提交并携带维度与五维权重', async () => {
+  it('只需五维权重,不再提交所属维度', async () => {
     const wrapper = mount()
     wrapper.vm.currentRow = { id: 9, auditStatus: 'pending' }
-    wrapper.vm.auditDimensionCode = 'body'
     wrapper.vm.auditWeights = validWeights()
     wrapper.vm.handleApprove()
     await flush()
@@ -75,9 +63,9 @@ describe('ActivityAudit 审核提交', () => {
       id: 9,
       action: 'approved',
       rejectReason: '',
-      dimensionCode: 'body',
       dimensionWeights: validWeights()
     })
+    expect(auditActivity.mock.calls[0][0].dimensionCode).toBeUndefined()
   })
 
   it('驳回时以 rejected 提交并携带原因', async () => {
@@ -104,7 +92,7 @@ describe('ActivityAudit 审核提交', () => {
       ]
     })
     const wrapper = mount()
-    wrapper.vm.showDetail({ id: 9, auditStatus: 'pending', dimensionCode: 'body' })
+    wrapper.vm.showDetail({ id: 9, auditStatus: 'pending' })
     await flush()
 
     expect(getDimensionWeights).toHaveBeenCalledWith('activity', 9)