Эх сурвалжийг харах

Phase6: 课前资料包+订阅消息骨架

liaoxg 2 долоо хоног өмнө
parent
commit
0065895fd7

+ 53 - 0
train-backend/src/main/java/com/train/controller/MaterialController.java

@@ -0,0 +1,53 @@
+package com.train.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.entity.TrainCourseMaterial;
+import com.train.entity.TrainUser;
+import com.train.mapper.TrainCourseMaterialMapper;
+import com.train.mapper.TrainUserMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 课前资料包(学员端):进班后可拉取本班次资料 + 通用资料。
+ */
+@Tag(name = "课前资料包", description = "学员端拉取课前资料")
+@RestController
+@RequestMapping("/api/class")
+public class MaterialController {
+
+    @Resource
+    private TrainCourseMaterialMapper trainCourseMaterialMapper;
+    @Resource
+    private TrainUserMapper trainUserMapper;
+
+    /**
+     * 课前资料列表:本班次资料(class_id=当前班次)+ 通用资料(class_id 为空),
+     * 按 sort 升序、id 降序排列;未进班不返回。
+     */
+    @Operation(summary = "课前资料列表")
+    @PostMapping("/materials")
+    public Result<List<TrainCourseMaterial>> materials(@RequestAttribute("userId") Long userId) {
+        TrainUser user = trainUserMapper.selectById(userId);
+        if (user == null || user.getClassId() == null) {
+            return Result.error("尚未进班,暂无可查资料");
+        }
+        List<TrainCourseMaterial> list = trainCourseMaterialMapper.selectList(
+                new LambdaQueryWrapper<TrainCourseMaterial>()
+                        .and(wrapper -> wrapper
+                                .eq(TrainCourseMaterial::getClassId, user.getClassId())
+                                .or()
+                                .isNull(TrainCourseMaterial::getClassId))
+                        .orderByAsc(TrainCourseMaterial::getSort)
+                        .orderByDesc(TrainCourseMaterial::getId));
+        return Result.success(list);
+    }
+}

+ 83 - 0
train-backend/src/main/java/com/train/controller/admin/AdminMaterialController.java

@@ -0,0 +1,83 @@
+package com.train.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.train.common.Result;
+import com.train.entity.TrainCourseMaterial;
+import com.train.mapper.TrainCourseMaterialMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 课前资料管理(管理端):资料列表、保存(幂等 upsert)。
+ */
+@Tag(name = "管理端-课前资料", description = "课前资料包配置")
+@RestController
+@RequestMapping("/api/admin/material")
+public class AdminMaterialController {
+
+    @Resource
+    private TrainCourseMaterialMapper trainCourseMaterialMapper;
+
+    /**
+     * 资料列表:classId 为空返回全部;否则只返回指定班次(含通用 classId 为空的资料,便于后台编辑)。
+     */
+    @Operation(summary = "课前资料列表")
+    @PostMapping("/list")
+    public Result<List<TrainCourseMaterial>> list(@RequestBody(required = false) Map<String, Object> body,
+                                                  @RequestAttribute("adminId") Long adminId) {
+        String classIdStr = body == null ? null
+                : (body.get("classId") == null ? null : body.get("classId").toString());
+        LambdaQueryWrapper<TrainCourseMaterial> wrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.hasText(classIdStr)) {
+            wrapper.and(w -> w
+                    .eq(TrainCourseMaterial::getClassId, Long.valueOf(classIdStr))
+                    .or()
+                    .isNull(TrainCourseMaterial::getClassId));
+        }
+        List<TrainCourseMaterial> list = trainCourseMaterialMapper.selectList(
+                wrapper.orderByAsc(TrainCourseMaterial::getSort).orderByDesc(TrainCourseMaterial::getId));
+        return Result.success(list);
+    }
+
+    /**
+     * 保存资料(幂等 upsert):带 id 则更新,否则新增。
+     */
+    @Operation(summary = "保存课前资料")
+    @PostMapping("/save")
+    public Result<Boolean> save(@RequestBody Map<String, Object> body,
+                                @RequestAttribute("adminId") Long adminId) {
+        String title = body.get("title") == null ? "" : body.get("title").toString();
+        if (!StringUtils.hasText(title)) {
+            return Result.error("资料标题不能为空");
+        }
+        TrainCourseMaterial material = new TrainCourseMaterial();
+        Object idObj = body.get("id");
+        if (idObj != null && StringUtils.hasText(idObj.toString())) {
+            material.setId(Long.valueOf(idObj.toString()));
+        }
+        String classIdStr = body.get("classId") == null ? null : body.get("classId").toString();
+        material.setClassId(StringUtils.hasText(classIdStr) ? Long.valueOf(classIdStr) : null);
+        material.setTitle(title);
+        material.setFileUrl(body.get("fileUrl") == null ? null : body.get("fileUrl").toString());
+        Object sortObj = body.get("sort");
+        material.setSort(sortObj == null ? 0 : Integer.valueOf(sortObj.toString()));
+
+        if (material.getId() == null) {
+            trainCourseMaterialMapper.insert(material);
+        } else {
+            // 覆盖更新:未传的字段置空,确保可清空 classId/fileUrl/sort
+            trainCourseMaterialMapper.updateById(material);
+        }
+        return Result.success(true);
+    }
+}

+ 41 - 0
train-backend/src/main/java/com/train/service/SubscribeMessageService.java

@@ -0,0 +1,41 @@
+package com.train.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+/**
+ * 订阅消息服务(骨架,二期/三期完善)。
+ * <p>触发点(设计文档 §4.7):报名成功、课前 T-7/T-3/T-1、成果被退回、投票结果揭晓、T+ 跟进提醒。
+ * test-mode:直接打日志模拟发送,不依赖微信 access_token 与模板;生产接入后需维护
+ * train_message_template 配置(模板 ID、跳转路径)。
+ */
+@Slf4j
+@Service
+public class SubscribeMessageService {
+
+    @Value("${wechat.test-mode}")
+    private boolean testMode;
+
+    /**
+     * 发送微信订阅消息(一次性模板)。
+     *
+     * @param openid     接收者 openid
+     * @param templateId 消息模板 ID(train_message_template 配置)
+     * @param page       点击跳转的小程序页面路径
+     * @param data       模板字段 {字段名: {value: xx}}
+     * @return 是否发送成功
+     */
+    public boolean sendSubscribeMessage(String openid, String templateId, String page, Map<String, Object> data) {
+        if (testMode) {
+            log.info("【测试模式】模拟订阅消息发送: openid={}, templateId={}, page={}, data={}",
+                    openid, templateId, page, data);
+            return true;
+        }
+        // TODO 生产接入:调微信 subscribeMessage.send(需 access_token,见 WechatService.getAccessToken)
+        log.warn("订阅消息生产发送待接入: openid={}, templateId={}, page={}", openid, templateId, page);
+        return false;
+    }
+}

+ 6 - 0
train-frontend/pages.json

@@ -126,6 +126,12 @@
       "style": {
         "navigationBarTitleText": "我的邀请"
       }
+    },
+    {
+      "path": "pages/material/index",
+      "style": {
+        "navigationBarTitleText": "课前资料"
+      }
     }
   ],
   "globalStyle": {

+ 76 - 0
train-frontend/pages/material/index.vue

@@ -0,0 +1,76 @@
+<template>
+  <view class="material-page">
+    <view class="section-card">
+      <text class="section-title">课前资料包</text>
+      <text class="card-desc">进班后可见本班次专属资料与通用资料</text>
+
+      <view v-if="loading" class="empty-text">加载中…</view>
+      <view v-else-if="list.length === 0" class="empty-text">暂无课前资料,请留意后续更新</view>
+
+      <view class="material-list">
+        <view class="material-item" v-for="(m, idx) in list" :key="idx">
+          <text class="material-icon">📄</text>
+          <view class="material-info">
+            <text class="material-title">{{ m.title }}</text>
+            <text class="material-badge" v-if="m.classId">班次专属</text>
+            <text class="material-badge general" v-else>通用</text>
+          </view>
+          <text class="material-preview" @click="preview(m)">预览</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getClassMaterials } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: true
+    }
+  },
+  onShow() {
+    this.loadMaterials()
+  },
+  methods: {
+    loadMaterials() {
+      var self = this
+      self.loading = true
+      getClassMaterials().then(function(resp) {
+        self.list = resp.data || []
+      }).catch(function(err) {
+        uni.showToast({ title: (err && err.message) || '获取资料失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    // 预览占位:test-mode 未接入文件服务,提示跳转后台配置的附件地址
+    preview(m) {
+      if (m.fileUrl) {
+        uni.showToast({ title: '预览功能待接入,请用文件地址访问', icon: 'none' })
+      } else {
+        uni.showToast({ title: '该资料暂无附件', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.material-page { min-height: 100vh; background: #F5F5F5; padding: 24rpx 32rpx; }
+.section-card { background: #FFF; border-radius: 16rpx; padding: 32rpx; margin-bottom: 24rpx; }
+.section-title { display: block; font-size: 32rpx; font-weight: 700; color: #1E293B; margin-bottom: 8rpx; border-left: 8rpx solid #F97316; padding-left: 16rpx; }
+.card-desc { display: block; font-size: 26rpx; color: #64748B; margin-bottom: 28rpx; }
+.material-list { margin-top: 8rpx; }
+.material-item { display: flex; align-items: center; padding: 24rpx 0; border-bottom: 1rpx solid #F1F5F9; }
+.material-icon { font-size: 40rpx; margin-right: 16rpx; }
+.material-info { flex: 1; display: flex; flex-direction: column; }
+.material-title { font-size: 28rpx; font-weight: 500; color: #1E293B; }
+.material-badge { display: inline-block; align-self: flex-start; font-size: 20rpx; color: #F97316; background: #FFF7ED; padding: 2rpx 12rpx; border-radius: 6rpx; margin-top: 8rpx; }
+.material-badge.general { color: #64748B; background: #F1F5F9; }
+.material-preview { font-size: 26rpx; color: #F97316; margin-left: 16rpx; }
+.empty-text { font-size: 26rpx; color: #94A3B8; text-align: center; padding: 40rpx 0; }
+</style>

+ 5 - 0
train-frontend/pages/mine/index.vue

@@ -20,6 +20,11 @@
         <text class="menu-text">分享中心</text>
         <text class="menu-arrow">›</text>
       </view>
+      <view class="menu-item" @click="goTo('/pages/material/index')">
+        <text class="menu-icon">📄</text>
+        <text class="menu-text">课前资料</text>
+        <text class="menu-arrow">›</text>
+      </view>
       <view class="menu-item" @click="goTo('/pages/group/index')">
         <text class="menu-icon">👥</text>
         <text class="menu-text">我的小组</text>

+ 5 - 0
train-frontend/utils/api.js

@@ -228,6 +228,11 @@ export const getInviteStats = () => {
   return request('/api/invite/stats', 'POST')
 }
 
+// 课前资料
+export const getClassMaterials = () => {
+  return request('/api/class/materials', 'POST')
+}
+
 // 文件上传
 export const uploadFile = (filePath) => {
   return new Promise((resolve, reject) => {

+ 11 - 0
train-web/src/api/material.js

@@ -0,0 +1,11 @@
+import request from '@/utils/request'
+
+// 课前资料列表(classId 为空 = 全部)
+export function materialList(data) {
+  return request.post('/api/admin/material/list', data || {})
+}
+
+// 保存课前资料(带 id 更新,不带 id 新增)
+export function materialSave(data) {
+  return request.post('/api/admin/material/save', data)
+}

+ 6 - 0
train-web/src/router/index.js

@@ -52,6 +52,12 @@ const routes = [
         component: () => import('@/views/Invites.vue'),
         meta: { title: '转介绍漏斗' }
       },
+      {
+        path: 'materials',
+        name: 'Materials',
+        component: () => import('@/views/Materials.vue'),
+        meta: { title: '课前资料' }
+      },
       {
         path: 'users',
         name: 'Users',

+ 4 - 0
train-web/src/views/Layout.vue

@@ -31,6 +31,10 @@
             <i class="el-icon-share"></i>
             <span slot="title">转介绍漏斗</span>
           </el-menu-item>
+          <el-menu-item index="/materials">
+            <i class="el-icon-document"></i>
+            <span slot="title">课前资料</span>
+          </el-menu-item>
           <el-menu-item index="/groups">
             <i class="el-icon-s-group"></i>
             <span slot="title">分组管理</span>

+ 172 - 0
train-web/src/views/Materials.vue

@@ -0,0 +1,172 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <h2 class="page-title">课前资料</h2>
+      <div class="filter-bar">
+        <el-select v-model="filterClassId" placeholder="全部班次" clearable style="width:200px" @change="fetchList">
+          <el-option v-for="c in classList" :key="c.id" :label="c.name" :value="c.id" />
+        </el-select>
+        <el-button type="primary" icon="el-icon-plus" @click="openEdit()">新增资料</el-button>
+        <el-button icon="el-icon-refresh" @click="fetchList">刷新</el-button>
+      </div>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe style="width:100%">
+      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="title" label="资料标题" min-width="200" />
+      <el-table-column label="适用班次" width="140">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.classId" size="small" type="warning">班次 #{{ scope.row.classId }}</el-tag>
+          <el-tag v-else size="small" type="info">通用</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="sort" label="排序" width="80" />
+      <el-table-column prop="fileUrl" label="文件地址" min-width="220" show-overflow-tooltip />
+      <el-table-column prop="createdAt" label="创建时间" width="170" />
+      <el-table-column label="操作" width="120" fixed="right">
+        <template slot-scope="scope">
+          <el-button type="text" size="small" @click="openEdit(scope.row)">编辑</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-empty v-if="!loading && list.length === 0" description="暂无课前资料" />
+
+    <el-dialog :title="form.id ? '编辑资料' : '新增资料'" :visible.sync="dialogVisible" width="560px">
+      <el-form :model="form" label-width="90px">
+        <el-form-item label="资料标题" required>
+          <el-input v-model="form.title" placeholder="如:课前预习手册" />
+        </el-form-item>
+        <el-form-item label="适用班次">
+          <el-select v-model="form.classId" placeholder="留空 = 通用资料" clearable style="width:100%">
+            <el-option v-for="c in classList" :key="c.id" :label="c.name" :value="c.id" />
+          </el-select>
+          <div class="form-tip">留空则所有班次学员可见(通用资料)</div>
+        </el-form-item>
+        <el-form-item label="文件地址">
+          <el-input v-model="form.fileUrl" placeholder="https://…(图片/PDF 链接,可后补)" />
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sort" :min="0" :max="9999" />
+          <div class="form-tip">数字越小越靠前展示</div>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { materialList, materialSave } from '@/api/material'
+import { classList as fetchClasses } from '@/api/class'
+
+export default {
+  name: 'Materials',
+  data: function () {
+    return {
+      list: [],
+      classList: [],
+      filterClassId: null,
+      loading: false,
+      saving: false,
+      dialogVisible: false,
+      form: {
+        id: null,
+        title: '',
+        classId: null,
+        fileUrl: '',
+        sort: 0
+      }
+    }
+  },
+  mounted: function () {
+    this.loadClasses()
+    this.fetchList()
+  },
+  methods: {
+    loadClasses: function () {
+      var self = this
+      fetchClasses().then(function (res) {
+        self.classList = res.data || []
+      }).catch(function () {
+        self.$message.error('获取班级列表失败')
+      })
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      materialList({ classId: self.filterClassId || undefined }).then(function (res) {
+        self.list = res.data || []
+      }).catch(function () {
+        self.$message.error('获取课前资料失败')
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    openEdit: function (row) {
+      this.form = {
+        id: row ? row.id : null,
+        title: row ? row.title : '',
+        classId: row ? row.classId : null,
+        fileUrl: row ? row.fileUrl : '',
+        sort: row ? row.sort : 0
+      }
+      this.dialogVisible = true
+    },
+    handleSave: function () {
+      var self = this
+      if (!self.form.title || !self.form.title.trim()) {
+        self.$message.warning('请填写资料标题')
+        return
+      }
+      self.saving = true
+      materialSave({
+        id: self.form.id,
+        title: self.form.title.trim(),
+        classId: self.form.classId || null,
+        fileUrl: self.form.fileUrl || null,
+        sort: self.form.sort || 0
+      }).then(function () {
+        self.$message.success('保存成功')
+        self.dialogVisible = false
+        self.fetchList()
+      }).catch(function () {
+        self.$message.error('保存失败')
+      }).finally(function () {
+        self.saving = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.page-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+
+.filter-bar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.form-tip {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+  line-height: 1.4;
+}
+</style>