Просмотр исходного кода

feat(activity): 规划师端活动管理 — 活动创建/编辑/发布

后端:
- 新增 GuideActivity 实体 + guide_activities 表
- 新增 GuideActivityMapper / GuideActivityService
- 新增 GuideActivityController (7个端点: list/detail/create/update/publish/cancel/delete)

前端:
- api.js 新增 7 个活动管理 API (getActivityList/detail/create/update/publish/cancel/delete)
- pages/guide/activities/index.vue — 活动列表 (筛选/新建/发布/取消/删除)
- pages/guide/activities/detail.vue — 活动详情页 (create模式+edit模式, 日期选择器, 分类选择)
- pages.json 注册 2 个新页面

关键设计: 使用页面跳转 (navigateTo), 无 modal 弹窗, 符合 training-plans 模式
Xiaogang Liao 4 месяцев назад
Родитель
Сommit
7c5540753b

+ 109 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideActivityController.java

@@ -0,0 +1,109 @@
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.GuideActivity;
+import com.etotem.cfc.service.GuideActivityService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "活动管理", description = "规划师端活动创建/编辑/发布")
+@RestController
+@RequestMapping("/api/guide/activities")
+public class GuideActivityController {
+
+    @Resource
+    private GuideActivityService activityService;
+
+    @Operation(summary = "获取活动列表")
+    @PostMapping("/list")
+    public Result<List<GuideActivity>> getActivityList(@RequestAttribute("userId") Long guideId,
+            @RequestBody Map<String, Object> body) {
+        String status = body.get("status") != null ? body.get("status").toString() : null;
+        List<GuideActivity> list;
+        if (status != null && !status.isEmpty()) {
+            list = activityService.getActivitiesByGuideAndStatus(guideId, status);
+        } else {
+            list = activityService.getActivitiesByGuide(guideId);
+        }
+        return Result.success(list);
+    }
+
+    @Operation(summary = "获取活动详情")
+    @PostMapping("/detail")
+    public Result<GuideActivity> getActivityDetail(@RequestBody Map<String, Object> body) {
+        Long activityId = body.get("activityId") != null
+                ? Long.valueOf(body.get("activityId").toString())
+                : null;
+        if (activityId == null) {
+            return Result.error("activityId不能为空");
+        }
+        GuideActivity activity = activityService.getActivityById(activityId);
+        if (activity == null) {
+            return Result.error("活动不存在");
+        }
+        return Result.success(activity);
+    }
+
+    @Operation(summary = "创建活动")
+    @PostMapping("/create")
+    public Result<Long> createActivity(@RequestAttribute("userId") Long guideId,
+            @RequestBody GuideActivity activity) {
+        Long id = activityService.createActivity(guideId, activity);
+        return Result.success(id);
+    }
+
+    @Operation(summary = "更新活动")
+    @PostMapping("/update")
+    public Result<Boolean> updateActivity(@RequestAttribute("userId") Long guideId,
+            @RequestBody GuideActivity activity) {
+        if (activity.getId() == null) {
+            return Result.error("activityId不能为空");
+        }
+        activityService.updateActivity(guideId, activity);
+        return Result.success(true);
+    }
+
+    @Operation(summary = "发布活动")
+    @PostMapping("/publish")
+    public Result<Boolean> publishActivity(@RequestBody Map<String, Object> body) {
+        Long activityId = body.get("activityId") != null
+                ? Long.valueOf(body.get("activityId").toString())
+                : null;
+        if (activityId == null) {
+            return Result.error("activityId不能为空");
+        }
+        activityService.updateStatus(activityId, "published");
+        return Result.success(true);
+    }
+
+    @Operation(summary = "取消活动")
+    @PostMapping("/cancel")
+    public Result<Boolean> cancelActivity(@RequestBody Map<String, Object> body) {
+        Long activityId = body.get("activityId") != null
+                ? Long.valueOf(body.get("activityId").toString())
+                : null;
+        if (activityId == null) {
+            return Result.error("activityId不能为空");
+        }
+        activityService.updateStatus(activityId, "cancelled");
+        return Result.success(true);
+    }
+
+    @Operation(summary = "删除活动")
+    @PostMapping("/delete")
+    public Result<Boolean> deleteActivity(@RequestBody Map<String, Object> body) {
+        Long activityId = body.get("activityId") != null
+                ? Long.valueOf(body.get("activityId").toString())
+                : null;
+        if (activityId == null) {
+            return Result.error("activityId不能为空");
+        }
+        activityService.deleteActivity(activityId);
+        return Result.success(true);
+    }
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GuideActivity.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("guide_activities")
+public class GuideActivity implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long guideId;
+    private String title;
+    private String description;
+    private String coverImage;
+    private String category;      // outdoor/nature/culture/sports/other
+    private String location;
+    private Date activityDate;
+    private Integer maxParticipants;
+    private Integer currentParticipants;
+    private Long price;
+    private String status;         // draft/published/cancelled/completed
+    private String source;         // manual
+    private Date publishedAt;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/GuideActivityMapper.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.GuideActivity;
+import org.apache.ibatis.annotations.*;
+
+import java.util.List;
+
+@Mapper
+public interface GuideActivityMapper extends BaseMapper<GuideActivity> {
+
+    @Select("SELECT * FROM guide_activities WHERE guide_id = #{guideId} ORDER BY created_at DESC")
+    List<GuideActivity> selectByGuideId(@Param("guideId") Long guideId);
+
+    @Select("SELECT * FROM guide_activities WHERE guide_id = #{guideId} AND status = #{status} ORDER BY created_at DESC")
+    List<GuideActivity> selectByGuideIdAndStatus(@Param("guideId") Long guideId, @Param("status") String status);
+
+    @Update("UPDATE guide_activities SET status = #{status}, updated_at = NOW() WHERE id = #{id}")
+    void updateStatus(@Param("id") Long id, @Param("status") String status);
+}

+ 68 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuideActivityService.java

@@ -0,0 +1,68 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.GuideActivity;
+import com.etotem.cfc.mapper.GuideActivityMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class GuideActivityService {
+
+    @Resource
+    private GuideActivityMapper activityMapper;
+
+    public List<GuideActivity> getActivitiesByGuide(Long guideId) {
+        return activityMapper.selectByGuideId(guideId);
+    }
+
+    public List<GuideActivity> getActivitiesByGuideAndStatus(Long guideId, String status) {
+        return activityMapper.selectByGuideIdAndStatus(guideId, status);
+    }
+
+    public GuideActivity getActivityById(Long id) {
+        return activityMapper.selectById(id);
+    }
+
+    @Transactional
+    public Long createActivity(Long guideId, GuideActivity activity) {
+        activity.setGuideId(guideId);
+        activity.setStatus("draft");
+        activity.setCurrentParticipants(0);
+        activity.setCreatedAt(new Date());
+        activity.setUpdatedAt(new Date());
+        activityMapper.insert(activity);
+        return activity.getId();
+    }
+
+    @Transactional
+    public void updateActivity(Long guideId, GuideActivity activity) {
+        GuideActivity existing = activityMapper.selectById(activity.getId());
+        if (existing == null || !existing.getGuideId().equals(guideId)) {
+            return;
+        }
+        if (activity.getTitle() != null) existing.setTitle(activity.getTitle());
+        if (activity.getDescription() != null) existing.setDescription(activity.getDescription());
+        if (activity.getCoverImage() != null) existing.setCoverImage(activity.getCoverImage());
+        if (activity.getCategory() != null) existing.setCategory(activity.getCategory());
+        if (activity.getLocation() != null) existing.setLocation(activity.getLocation());
+        if (activity.getActivityDate() != null) existing.setActivityDate(activity.getActivityDate());
+        if (activity.getMaxParticipants() != null) existing.setMaxParticipants(activity.getMaxParticipants());
+        if (activity.getPrice() != null) existing.setPrice(activity.getPrice());
+        existing.setUpdatedAt(new Date());
+        activityMapper.updateById(existing);
+    }
+
+    @Transactional
+    public void updateStatus(Long activityId, String status) {
+        activityMapper.updateStatus(activityId, status);
+    }
+
+    @Transactional
+    public void deleteActivity(Long activityId) {
+        activityMapper.deleteById(activityId);
+    }
+}

+ 26 - 1
cfc-backend/src/main/resources/schema_assessment.sql

@@ -162,4 +162,29 @@ CREATE TABLE IF NOT EXISTS training_plan_tasks (
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     INDEX idx_plan (plan_id),
     INDEX idx_plan (plan_id),
     INDEX idx_task (task_id)
     INDEX idx_task (task_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='训练方案任务关联表';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='训练方案任务关联表';
+
+-- ============================================
+-- 活动管理(guide端活动创建/编辑/发布)
+-- ============================================
+CREATE TABLE IF NOT EXISTS guide_activities (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+    guide_id BIGINT NOT NULL COMMENT '规划师ID',
+    title VARCHAR(200) NOT NULL COMMENT '活动标题',
+    description TEXT COMMENT '活动描述',
+    cover_image VARCHAR(500) COMMENT '封面图片URL',
+    category VARCHAR(50) DEFAULT 'other' COMMENT '分类:outdoor/nature/culture/sports/other',
+    location VARCHAR(300) COMMENT '活动地点',
+    activity_date DATETIME COMMENT '活动日期时间',
+    max_participants INT DEFAULT 20 COMMENT '最大参与人数',
+    current_participants INT DEFAULT 0 COMMENT '当前报名人数',
+    price BIGINT DEFAULT 0 COMMENT '价格(分),0表示免费',
+    status VARCHAR(20) DEFAULT 'draft' COMMENT '状态:draft草稿/published已发布/cancelled已取消/completed已完成',
+    source VARCHAR(20) DEFAULT 'manual' COMMENT '来源',
+    published_at DATETIME COMMENT '发布时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_guide (guide_id),
+    INDEX idx_status (status),
+    INDEX idx_activity_date (activity_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规划师活动表';

+ 8 - 0
cfc-frontend/pages.json

@@ -206,6 +206,14 @@
         {
         {
           "path": "training-plans/detail",
           "path": "training-plans/detail",
           "style": { "navigationBarTitleText": "方案详情" }
           "style": { "navigationBarTitleText": "方案详情" }
+        },
+        {
+          "path": "activities/index",
+          "style": { "navigationBarTitleText": "活动管理" }
+        },
+        {
+          "path": "activities/detail",
+          "style": { "navigationBarTitleText": "活动详情" }
         }
         }
       ]
       ]
     },
     },

+ 630 - 0
cfc-frontend/pages/guide/activities/detail.vue

@@ -0,0 +1,630 @@
+<template>
+  <view class="container">
+    <!-- 编辑模式 -->
+    <view class="edit-section" v-if="!readonly">
+      <view class="form-card">
+        <view class="form-title">活动信息</view>
+
+        <view class="form-item">
+          <text class="form-label">活动标题 *</text>
+          <input class="form-input" v-model="form.title" placeholder="请输入活动标题" :disabled="readonly" />
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">活动分类</text>
+          <picker class="form-picker" :value="categoryIdx" :range="categoryOptions" range-key="label"
+            @change="onCategoryChange">
+            <text class="picker-text">{{ form.category ? categoryLabel(form.category) : '请选择分类' }}</text>
+          </picker>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">活动日期时间 *</text>
+          <view class="form-date-picker" @click="showDatePicker">
+            <text class="picker-text">{{ form.activityDate ? formatDate(form.activityDate) : '请选择日期和时间' }}</text>
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">活动地点</text>
+          <input class="form-input" v-model="form.location" placeholder="请输入活动地点" />
+        </view>
+
+        <view class="form-item-row">
+          <view class="form-item half">
+            <text class="form-label">最大人数</text>
+            <input class="form-input" type="number" v-model="form.maxParticipants" placeholder="20" />
+          </view>
+          <view class="form-item half">
+            <text class="form-label">价格(元)</text>
+            <input class="form-input" type="digit" v-model="priceYuan" placeholder="0表示免费" />
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">封面图片URL</text>
+          <input class="form-input" v-model="form.coverImage" placeholder="输入图片URL" />
+          <view class="cover-preview" v-if="form.coverImage">
+            <image class="preview-img" :src="form.coverImage" mode="aspectFill" @error="onImgError" />
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">活动描述</text>
+          <textarea class="form-textarea" v-model="form.description" placeholder="请输入活动详细介绍" />
+        </view>
+
+        <view class="form-actions">
+          <view class="btn-save" @click="handleSave">
+            <text>保存</text>
+          </view>
+          <view class="btn-publish" v-if="form.status === 'draft'" @click="handlePublish">
+            <text>保存并发布</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 只读模式(已发布/已取消/已完成) -->
+    <view class="detail-section" v-else>
+      <view class="detail-cover" v-if="form.coverImage">
+        <image class="cover-img" :src="form.coverImage" mode="aspectFill" />
+      </view>
+      <view class="detail-cover placeholder" v-else>
+        <text class="placeholder-icon">{{ categoryIcon(form.category) }}</text>
+      </view>
+
+      <view class="detail-card">
+        <view class="detail-header">
+          <text class="detail-title">{{ form.title }}</text>
+          <text class="detail-status" :class="'status-' + form.status">{{ statusText(form.status) }}</text>
+        </view>
+
+        <view class="detail-meta-row">
+          <view class="meta-item">
+            <text class="meta-icon">&#x1F4C5;</text>
+            <text class="meta-text">{{ form.activityDate ? formatDate(form.activityDate) : '待定' }}</text>
+          </view>
+          <view class="meta-item" v-if="form.location">
+            <text class="meta-icon">&#x1F4CD;</text>
+            <text class="meta-text">{{ form.location }}</text>
+          </view>
+        </view>
+
+        <view class="detail-stats">
+          <view class="stat-box">
+            <text class="stat-val">{{ form.currentParticipants || 0 }}</text>
+            <text class="stat-label">已报名</text>
+          </view>
+          <view class="stat-box">
+            <text class="stat-val">{{ form.maxParticipants || 20 }}</text>
+            <text class="stat-label">最大人数</text>
+          </view>
+          <view class="stat-box">
+            <text class="stat-val">{{ form.price > 0 ? (form.price / 100 + '元') : '免费' }}</text>
+            <text class="stat-label">价格</text>
+          </view>
+        </view>
+
+        <view class="detail-desc" v-if="form.description">
+          <text class="section-title">活动介绍</text>
+          <text class="desc-text">{{ form.description }}</text>
+        </view>
+
+        <view class="detail-actions" v-if="form.status === 'draft'">
+          <view class="btn-edit" @click="readonly = false">
+            <text>编辑</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="loading" v-if="loading"><text class="loading-text">加载中...</text></view>
+  </view>
+</template>
+
+<script>
+import { getActivityDetail, createActivity, updateActivity, publishActivity } from '../../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      activityId: null,
+      isNew: false,
+      readonly: false,
+      loading: false,
+      form: {
+        title: '',
+        description: '',
+        coverImage: '',
+        category: '',
+        location: '',
+        activityDate: null,
+        maxParticipants: 20,
+        price: 0,
+        status: 'draft'
+      },
+      priceYuan: '0',
+      categoryIdx: 0,
+      categoryOptions: [
+        { key: 'outdoor', label: '&#x26F5; 户外运动' },
+        { key: 'nature', label: '&#x1F33F; 自然探索' },
+        { key: 'culture', label: '&#x1F3A8; 文化体验' },
+        { key: 'sports', label: '&#x26BD; 体育竞技' },
+        { key: 'other', label: '&#x1F3E0; 其他' }
+      ]
+    }
+  },
+  onLoad(options) {
+    this.activityId = options.activityId ? parseInt(options.activityId) : null
+    if (!this.activityId || this.activityId === 0) {
+      this.isNew = true
+      this.readonly = false
+      uni.setNavigationBarTitle({ title: '创建活动' })
+    } else {
+      this.loadDetail()
+    }
+  },
+  methods: {
+    loadDetail() {
+      var self = this
+      self.loading = true
+      getActivityDetail(self.activityId).then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          self.form = res.data
+          self.priceYuan = self.form.price ? (self.form.price / 100).toString() : '0'
+          self.readonly = self.form.status !== 'draft'
+          uni.setNavigationBarTitle({ title: self.readonly ? '活动详情' : '编辑活动' })
+        }
+      }).catch(function() { self.loading = false })
+    },
+    onCategoryChange(e) {
+      this.categoryIdx = e.detail.value
+      this.form.category = this.categoryOptions[this.categoryIdx].key
+    },
+    showDatePicker() {
+      var self = this
+      uni.showDatePicker({
+        mode: 'date',
+        success: function(res) {
+          var date = res.value
+          uni.showTimePicker({
+            mode: 'time',
+            success: function(res2) {
+              var time = res2.value
+              var d = new Date(date)
+              var parts = time.split(':')
+              d.setHours(parseInt(parts[0]), parseInt(parts[1]))
+              self.form.activityDate = d
+            }
+          })
+        }
+      })
+    },
+    handleSave() {
+      if (!this.form.title) {
+        uni.showToast({ title: '请输入活动标题', icon: 'none' })
+        return
+      }
+      this.save(false)
+    },
+    handlePublish() {
+      if (!this.form.title) {
+        uni.showToast({ title: '请输入活动标题', icon: 'none' })
+        return
+      }
+      this.save(true)
+    },
+    save(publishAfterSave) {
+      var self = this
+      var payload = Object.assign({}, self.form)
+      if (self.priceYuan) {
+        payload.price = Math.round(parseFloat(self.priceYuan) * 100)
+      }
+
+      var done = function() {
+        if (publishAfterSave && self.form.status === 'draft') {
+          publishActivity(self.form.id || self.activityId).then(function(r) {
+            if (r.code === 200) {
+              uni.showToast({ title: '已发布', icon: 'success' })
+              setTimeout(function() {
+                uni.redirectTo({ url: '/pages/guide/activities/index' })
+              }, 1000)
+            } else {
+              uni.showToast({ title: '发布失败', icon: 'none' })
+            }
+          })
+        } else {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+          setTimeout(function() {
+            uni.navigateBack()
+          }, 1000)
+        }
+      }
+
+      if (self.isNew || !self.form.id) {
+        createActivity(payload).then(function(res) {
+          if (res.code === 200) {
+            self.form.id = res.data
+            self.isNew = false
+            if (publishAfterSave) {
+              publishActivity(res.data).then(function(r) {
+                if (r.code === 200) {
+                  uni.showToast({ title: '已发布', icon: 'success' })
+                  setTimeout(function() {
+                    uni.redirectTo({ url: '/pages/guide/activities/index' })
+                  }, 1000)
+                }
+              })
+            } else {
+              done()
+            }
+          } else {
+            uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+          }
+        }).catch(function() {
+          uni.showToast({ title: '网络异常', icon: 'none' })
+        })
+      } else {
+        payload.id = self.form.id || self.activityId
+        updateActivity(payload).then(function(res) {
+          if (res.code === 200) {
+            if (publishAfterSave) {
+              publishActivity(payload.id).then(function(r) {
+                if (r.code === 200) {
+                  uni.showToast({ title: '已发布', icon: 'success' })
+                  setTimeout(function() {
+                    uni.redirectTo({ url: '/pages/guide/activities/index' })
+                  }, 1000)
+                }
+              })
+            } else {
+              done()
+            }
+          } else {
+            uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+          }
+        }).catch(function() {
+          uni.showToast({ title: '网络异常', icon: 'none' })
+        })
+      }
+    },
+    onImgError() {
+      this.form.coverImage = ''
+    },
+    categoryLabel(key) {
+      var map = { outdoor: '户外运动', nature: '自然探索', culture: '文化体验', sports: '体育竞技', other: '其他' }
+      return map[key] || '其他'
+    },
+    categoryIcon(category) {
+      var map = { outdoor: '&#x26F5;', nature: '&#x1F33F;', culture: '&#x1F3A8;', sports: '&#x26BD;', other: '&#x1F3E0;' }
+      return map[category] || '&#x1F3E0;'
+    },
+    statusText(status) {
+      var map = { draft: '草稿', published: '已发布', cancelled: '已取消', completed: '已完成' }
+      return map[status] || status || '草稿'
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      var d = new Date(dateStr)
+      var y = d.getFullYear()
+      var m = ('0' + (d.getMonth() + 1)).slice(-2)
+      var day = ('0' + d.getDate()).slice(-2)
+      var h = ('0' + d.getHours()).slice(-2)
+      var mi = ('0' + d.getMinutes()).slice(-2)
+      return y + '-' + m + '-' + day + ' ' + h + ':' + mi
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 60rpx;
+}
+
+/* ===== 编辑表单 ===== */
+.edit-section {
+  padding: 20rpx 30rpx;
+}
+
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
+}
+
+.form-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 24rpx;
+  padding-bottom: 16rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.form-item {
+  margin-bottom: 24rpx;
+}
+
+.form-item:last-child {
+  margin-bottom: 0;
+}
+
+.form-item-row {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+
+.form-item.half {
+  flex: 1;
+}
+
+.form-label {
+  font-size: 26rpx;
+  color: #888;
+  display: block;
+  margin-bottom: 8rpx;
+}
+
+.form-input {
+  width: 100%;
+  height: 80rpx;
+  background: #f9fafb;
+  border: 1rpx solid #e5e7eb;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  box-sizing: border-box;
+}
+
+.form-picker, .form-date-picker {
+  width: 100%;
+  min-height: 80rpx;
+  background: #f9fafb;
+  border: 1rpx solid #e5e7eb;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  box-sizing: border-box;
+}
+
+.picker-text {
+  font-size: 28rpx;
+  color: #333;
+}
+
+.form-textarea {
+  width: 100%;
+  background: #f9fafb;
+  border: 1rpx solid #e5e7eb;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  box-sizing: border-box;
+  min-height: 160rpx;
+  display: block;
+}
+
+.cover-preview {
+  margin-top: 12rpx;
+  border-radius: 12rpx;
+  overflow: hidden;
+  height: 200rpx;
+}
+
+.preview-img {
+  width: 100%;
+  height: 200rpx;
+}
+
+.form-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  justify-content: center;
+  margin-top: 32rpx;
+  padding-top: 24rpx;
+  border-top: 1rpx solid #f0f0f0;
+}
+
+.btn-save, .btn-publish {
+  padding: 18rpx 48rpx;
+  border-radius: 40rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+}
+
+.btn-save {
+  background: linear-gradient(135deg, #10B981, #059669);
+  box-shadow: 0 4rpx 16rpx rgba(16, 185, 129, 0.3);
+}
+
+.btn-publish {
+  background: #fff;
+  border: 2rpx solid #10B981;
+  color: #10B981;
+}
+
+.btn-save:active, .btn-publish:active { opacity: 0.8; }
+
+.btn-save text, .btn-publish text {
+  font-weight: 500;
+}
+
+/* ===== 详情模式 ===== */
+.detail-section {
+  padding: 0;
+}
+
+.detail-cover {
+  width: 100%;
+  height: 400rpx;
+  background: linear-gradient(135deg, #10B981 0%, #059669 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.cover-img {
+  width: 100%;
+  height: 100%;
+}
+
+.placeholder-icon {
+  font-size: 120rpx;
+  color: rgba(255,255,255,0.8);
+}
+
+.detail-card {
+  background: #fff;
+  margin: -40rpx 30rpx 0;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  box-shadow: 0 8rpx 32rpx rgba(0,0,0,0.08);
+  position: relative;
+}
+
+.detail-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 20rpx;
+}
+
+.detail-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  flex: 1;
+  margin-right: 16rpx;
+}
+
+.detail-status {
+  font-size: 24rpx;
+  padding: 6rpx 18rpx;
+  border-radius: 16rpx;
+  flex-shrink: 0;
+}
+
+.status-draft { background: #f3f4f6; color: #666; }
+.status-published { background: #d1fae5; color: #059669; }
+.status-cancelled { background: #fee2e2; color: #dc2626; }
+.status-completed { background: #dbeafe; color: #2563eb; }
+
+.detail-meta-row {
+  display: flex;
+  flex-direction: row;
+  gap: 24rpx;
+  margin-bottom: 24rpx;
+  padding-bottom: 20rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.meta-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 6rpx;
+}
+
+.meta-icon {
+  font-size: 26rpx;
+}
+
+.meta-text {
+  font-size: 26rpx;
+  color: #666;
+}
+
+.detail-stats {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+  margin-bottom: 24rpx;
+  padding-bottom: 20rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.stat-box {
+  flex: 1;
+  background: #f9fafb;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  text-align: center;
+}
+
+.stat-val {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #10B981;
+  display: block;
+}
+
+.stat-label {
+  font-size: 22rpx;
+  color: #888;
+  display: block;
+  margin-top: 4rpx;
+}
+
+.section-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 12rpx;
+}
+
+.desc-text {
+  font-size: 28rpx;
+  color: #666;
+  line-height: 1.7;
+  display: block;
+}
+
+.detail-desc {
+  margin-bottom: 24rpx;
+}
+
+.detail-actions {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  margin-top: 24rpx;
+}
+
+.btn-edit {
+  background: linear-gradient(135deg, #10B981, #059669);
+  padding: 18rpx 64rpx;
+  border-radius: 40rpx;
+  box-shadow: 0 4rpx 16rpx rgba(16, 185, 129, 0.3);
+}
+
+.btn-edit:active { opacity: 0.8; }
+
+.btn-edit text {
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: bold;
+}
+
+.loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 80rpx;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+</style>

+ 493 - 0
cfc-frontend/pages/guide/activities/index.vue

@@ -0,0 +1,493 @@
+<template>
+  <view class="container">
+    <view class="header">
+      <text class="title">活动管理</text>
+      <view class="btn-create" @click="goToCreate">
+        <text class="btn-create-text">+ 新建活动</text>
+      </view>
+    </view>
+
+    <view class="filter-bar">
+      <view class="filter-chips">
+        <view
+          v-for="f in filterOptions"
+          :key="f.value"
+          :class="['filter-chip', currentFilter === f.value ? 'active' : '']"
+          @click="onFilterChange(f.value)"
+        >
+          <text>{{ f.label }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="activity-list" v-if="activities.length > 0">
+      <view class="activity-card" v-for="act in activities" :key="act.id"
+        @click="goToDetail(act.id)">
+        <view class="act-cover" v-if="act.coverImage">
+          <image class="cover-img" :src="act.coverImage" mode="aspectFill" />
+        </view>
+        <view class="act-cover placeholder" v-else>
+          <text class="cover-placeholder-text">{{ categoryIcon(act.category) }}</text>
+        </view>
+        <view class="act-body">
+          <view class="act-header">
+            <text class="act-title">{{ act.title || '未命名活动' }}</text>
+            <text class="act-status" :class="'status-' + act.status">{{ statusText(act.status) }}</text>
+          </view>
+          <view class="act-meta">
+            <text class="meta-item" v-if="act.activityDate">
+              &#x1F4C5; {{ formatDate(act.activityDate) }}
+            </text>
+            <text class="meta-item" v-if="act.location">
+              &#x1F4CD; {{ act.location }}
+            </text>
+          </view>
+          <view class="act-desc" v-if="act.description">{{ act.description }}</view>
+          <view class="act-stats">
+            <text class="stat-item">&#x1F465; {{ act.currentParticipants || 0 }}/{{ act.maxParticipants || 20 }}人</text>
+            <text class="stat-item" v-if="act.price > 0">&#x1F4B0; {{ act.price / 100 }}元</text>
+            <text class="stat-item free" v-else>免费</text>
+          </view>
+          <view class="act-actions" @click.stop>
+            <view class="act-btn outline" @click="goToDetail(act.id)">
+              <text>{{ act.status === 'draft' ? '编辑' : '查看' }}</text>
+            </view>
+            <view class="act-btn primary" v-if="act.status === 'draft'" @click="handlePublish(act.id)">
+              <text>发布</text>
+            </view>
+            <view class="act-btn warning" v-if="act.status === 'published'" @click="handleCancel(act.id)">
+              <text>取消</text>
+            </view>
+            <view class="act-btn danger" v-if="act.status === 'draft'" @click="handleDelete(act.id)">
+              <text>删除</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="empty" v-else>
+      <text class="empty-icon">&#x1F3E0;</text>
+      <text class="empty-title">暂无活动</text>
+      <text class="empty-desc">创建户外/自然/文化/体育等主题活动,吸引家庭参与</text>
+      <view class="empty-btn" @click="goToCreate">
+        <text>立即创建</text>
+      </view>
+    </view>
+
+    <view class="loading" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getActivityList, publishActivity, cancelActivity, deleteActivity } from '../../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      activities: [],
+      currentFilter: '',
+      loading: false,
+      filterOptions: [
+        { value: '', label: '全部' },
+        { value: 'draft', label: '草稿' },
+        { value: 'published', label: '已发布' },
+        { value: 'cancelled', label: '已取消' },
+        { value: 'completed', label: '已完成' }
+      ]
+    }
+  },
+  onShow() {
+    this.loadActivities()
+  },
+  methods: {
+    loadActivities() {
+      var self = this
+      self.loading = true
+      getActivityList(self.currentFilter).then(function(res) {
+        self.loading = false
+        if (res.code === 200) {
+          self.activities = res.data || []
+        }
+      }).catch(function() {
+        self.loading = false
+      })
+    },
+    onFilterChange(value) {
+      this.currentFilter = value
+      this.loadActivities()
+    },
+    goToCreate() {
+      uni.navigateTo({ url: '/pages/guide/activities/detail?activityId=0' })
+    },
+    goToDetail(activityId) {
+      uni.navigateTo({ url: '/pages/guide/activities/detail?activityId=' + activityId })
+    },
+    handlePublish(activityId) {
+      var self = this
+      uni.showModal({
+        title: '确认发布',
+        content: '发布后活动将对外展示,是否继续?',
+        success: function(res) {
+          if (res.confirm) {
+            uni.showLoading({ title: '发布中...' })
+            publishActivity(activityId).then(function(res) {
+              uni.hideLoading()
+              if (res.code === 200) {
+                uni.showToast({ title: '发布成功', icon: 'success' })
+                self.loadActivities()
+              } else {
+                uni.showToast({ title: res.message || '发布失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.hideLoading()
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    handleCancel(activityId) {
+      var self = this
+      uni.showModal({
+        title: '确认取消',
+        content: '取消后活动将不再对外展示,是否继续?',
+        success: function(res) {
+          if (res.confirm) {
+            cancelActivity(activityId).then(function(r) {
+              if (r.code === 200) {
+                uni.showToast({ title: '已取消', icon: 'success' })
+                self.loadActivities()
+              } else {
+                uni.showToast({ title: r.message || '操作失败', icon: 'none' })
+              }
+            }).catch(function() {})
+          }
+        }
+      })
+    },
+    handleDelete(activityId) {
+      var self = this
+      uni.showModal({
+        title: '确认删除',
+        content: '删除后无法恢复,是否确定删除?',
+        success: function(res) {
+          if (res.confirm) {
+            deleteActivity(activityId).then(function(r) {
+              if (r.code === 200) {
+                uni.showToast({ title: '已删除', icon: 'success' })
+                self.loadActivities()
+              } else {
+                uni.showToast({ title: r.message || '删除失败', icon: 'none' })
+              }
+            }).catch(function() {})
+          }
+        }
+      })
+    },
+    statusText(status) {
+      var map = { draft: '草稿', published: '已发布', cancelled: '已取消', completed: '已完成' }
+      return map[status] || status || '草稿'
+    },
+    categoryIcon(category) {
+      var map = { outdoor: '&#x26F5;', nature: '&#x1F33F;', culture: '&#x1F3A8;', sports: '&#x26BD;', other: '&#x1F3E0;' }
+      return map[category] || '&#x1F3E0;'
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      var d = new Date(dateStr)
+      var y = d.getFullYear()
+      var m = ('0' + (d.getMonth() + 1)).slice(-2)
+      var day = ('0' + d.getDate()).slice(-2)
+      var h = ('0' + d.getHours()).slice(-2)
+      var mi = ('0' + d.getMinutes()).slice(-2)
+      return y + '-' + m + '-' + day + ' ' + h + ':' + mi
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 40rpx;
+}
+
+.header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: 30rpx;
+  background: #fff;
+}
+
+.title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.btn-create {
+  background: linear-gradient(135deg, #10B981, #059669);
+  padding: 14rpx 32rpx;
+  border-radius: 32rpx;
+  box-shadow: 0 4rpx 12rpx rgba(16, 185, 129, 0.3);
+}
+
+.btn-create:active { opacity: 0.8; }
+
+.btn-create-text {
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: 500;
+}
+
+.filter-bar {
+  background: #fff;
+  padding: 0 30rpx 20rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.filter-chips {
+  display: flex;
+  flex-direction: row;
+  gap: 12rpx;
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+}
+
+.filter-chip {
+  flex-shrink: 0;
+  padding: 10rpx 24rpx;
+  border-radius: 20rpx;
+  background: #f3f4f6;
+  border: 1rpx solid #e5e7eb;
+}
+
+.filter-chip.active {
+  background: #d1fae5;
+  border-color: #10B981;
+}
+
+.filter-chip text {
+  font-size: 26rpx;
+  color: #666;
+}
+
+.filter-chip.active text {
+  color: #059669;
+  font-weight: 500;
+}
+
+.activity-list {
+  padding: 20rpx 30rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.activity-card {
+  background: #fff;
+  border-radius: 20rpx;
+  overflow: hidden;
+  box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.05);
+  display: flex;
+  flex-direction: row;
+}
+
+.act-cover {
+  width: 200rpx;
+  height: 200rpx;
+  flex-shrink: 0;
+}
+
+.cover-img {
+  width: 100%;
+  height: 100%;
+}
+
+.act-cover.placeholder {
+  background: linear-gradient(135deg, #10B981 0%, #059669 100%);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.cover-placeholder-text {
+  font-size: 60rpx;
+  color: #fff;
+}
+
+.act-body {
+  flex: 1;
+  padding: 20rpx;
+  display: flex;
+  flex-direction: column;
+}
+
+.act-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 8rpx;
+}
+
+.act-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+  flex: 1;
+  margin-right: 12rpx;
+}
+
+.act-status {
+  font-size: 22rpx;
+  padding: 4rpx 14rpx;
+  border-radius: 14rpx;
+  flex-shrink: 0;
+}
+
+.status-draft { background: #f3f4f6; color: #666; }
+.status-published { background: #d1fae5; color: #059669; }
+.status-cancelled { background: #fee2e2; color: #dc2626; }
+.status-completed { background: #dbeafe; color: #2563eb; }
+
+.act-meta {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  margin-bottom: 6rpx;
+}
+
+.meta-item {
+  font-size: 24rpx;
+  color: #888;
+}
+
+.act-desc {
+  font-size: 24rpx;
+  color: #888;
+  line-height: 1.4;
+  margin-bottom: 8rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.act-stats {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  margin-bottom: 12rpx;
+}
+
+.stat-item {
+  font-size: 24rpx;
+  color: #666;
+}
+
+.stat-item.free {
+  color: #10B981;
+  font-weight: 500;
+}
+
+.act-actions {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 10rpx;
+  margin-top: auto;
+  border-top: 1rpx solid #f0f0f0;
+  padding-top: 12rpx;
+}
+
+.act-btn {
+  padding: 8rpx 20rpx;
+  border-radius: 20rpx;
+  font-size: 24rpx;
+}
+
+.act-btn:active { opacity: 0.7; }
+
+.act-btn.outline {
+  background: #f9fafb;
+  color: #374151;
+  border: 1rpx solid #e5e7eb;
+}
+
+.act-btn.primary {
+  background: #10B981;
+  color: #fff;
+}
+
+.act-btn.warning {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+.act-btn.danger {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 40rpx 80rpx;
+}
+
+.empty-icon {
+  font-size: 100rpx;
+  margin-bottom: 20rpx;
+}
+
+.empty-title {
+  font-size: 32rpx;
+  color: #333;
+  font-weight: bold;
+  margin-bottom: 12rpx;
+}
+
+.empty-desc {
+  font-size: 26rpx;
+  color: #999;
+  text-align: center;
+  line-height: 1.6;
+  margin-bottom: 40rpx;
+}
+
+.empty-btn {
+  background: linear-gradient(135deg, #10B981, #059669);
+  padding: 16rpx 48rpx;
+  border-radius: 40rpx;
+  box-shadow: 0 4rpx 16rpx rgba(16, 185, 129, 0.3);
+}
+
+.empty-btn:active { opacity: 0.8; }
+
+.empty-btn text {
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: 500;
+}
+
+.loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 80rpx;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+</style>

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

@@ -631,6 +631,35 @@ export const deleteTrainingPlan = (planId) => {
   return request('/api/guide/training-plans/delete', 'POST', { planId })
   return request('/api/guide/training-plans/delete', 'POST', { planId })
 }
 }
 
 
+// ===== 活动管理 =====
+export const getActivityList = (status) => {
+  return request('/api/guide/activities/list', 'POST', status ? { status } : {})
+}
+
+export const getActivityDetail = (activityId) => {
+  return request('/api/guide/activities/detail', 'POST', { activityId })
+}
+
+export const createActivity = (activity) => {
+  return request('/api/guide/activities/create', 'POST', activity)
+}
+
+export const updateActivity = (activity) => {
+  return request('/api/guide/activities/update', 'POST', activity)
+}
+
+export const publishActivity = (activityId) => {
+  return request('/api/guide/activities/publish', 'POST', { activityId })
+}
+
+export const cancelActivity = (activityId) => {
+  return request('/api/guide/activities/cancel', 'POST', { activityId })
+}
+
+export const deleteActivity = (activityId) => {
+  return request('/api/guide/activities/delete', 'POST', { activityId })
+}
+
 // 添加成长规划师套餐
 // 添加成长规划师套餐
 export const addGuidePackage = (guideId, packageId, price, commissionRate) => {
 export const addGuidePackage = (guideId, packageId, price, commissionRate) => {
   return request('/api/guide/package/add', 'POST', { guideId, packageId, price, commissionRate })
   return request('/api/guide/package/add', 'POST', { guideId, packageId, price, commissionRate })