Ver Fonte

Phase10: 投屏按课程配置显示, 课程计时与环节进度, 小组进展矩阵, 投票结果录入与展示, 课程资料展示

liaoxg há 2 semanas atrás
pai
commit
9ba5889

+ 265 - 0
train-backend/src/main/java/com/train/controller/admin/AdminScreenController.java

@@ -0,0 +1,265 @@
+package com.train.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.train.common.Result;
+import com.train.entity.TrainClass;
+import com.train.entity.TrainScreenConfig;
+import com.train.entity.TrainVote;
+import com.train.mapper.TrainClassMapper;
+import com.train.mapper.TrainScreenConfigMapper;
+import com.train.mapper.TrainVoteMapper;
+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.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端-课程现场投屏控制:投屏配置读写、当前环节切换、课程开始/重置计时、投票结果录入。
+ * 助教/讲师可在后台设置课程进展数据,投屏大屏按配置实时展示。
+ */
+@Tag(name = "管理端-投屏控制", description = "投屏配置、课程环节、课程计时、投票结果录入")
+@RestController
+@RequestMapping("/api/admin/screen")
+public class AdminScreenController {
+
+    @Resource
+    private TrainClassMapper trainClassMapper;
+    @Resource
+    private TrainScreenConfigMapper trainScreenConfigMapper;
+    @Resource
+    private TrainVoteMapper trainVoteMapper;
+    @Resource
+    private ObjectMapper objectMapper;
+
+    // ============ 投屏配置 ============
+
+    /**
+     * 读取投屏配置(含课程开始时间)。
+     * 无配置时返回默认全开配置,便于首次使用。
+     */
+    @Operation(summary = "读取投屏配置")
+    @PostMapping("/config/get")
+    public Result<Map<String, Object>> configGet(@RequestBody Map<String, Object> body,
+                                                 @RequestAttribute("adminId") Long adminId) {
+        Long classId = Long.valueOf(body.get("classId").toString());
+        TrainScreenConfig cfg = findConfig(classId);
+        TrainClass cls = trainClassMapper.selectById(classId);
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("config", cfg == null ? defaultConfigMap() : toConfigMap(cfg));
+        data.put("startAt", cls == null ? null : cls.getStartAt());
+        return Result.success(data);
+    }
+
+    /**
+     * 保存投屏配置(幂等 upsert):未传的开关字段保留原值;agenda 传数组,存为 JSON。
+     */
+    @Operation(summary = "保存投屏配置")
+    @PostMapping("/config/save")
+    public Result<Boolean> configSave(@RequestBody Map<String, Object> body,
+                                      @RequestAttribute("adminId") Long adminId) {
+        Long classId = Long.valueOf(body.get("classId").toString());
+        TrainScreenConfig cfg = findConfig(classId);
+        if (cfg == null) {
+            cfg = new TrainScreenConfig();
+            cfg.setClassId(classId);
+            applyDefaults(cfg);
+            cfg.setAgenda("[]");
+            cfg.setCurrentStep(0);
+        }
+        if (body.get("showScoreboard") != null) cfg.setShowScoreboard(Integer.valueOf(body.get("showScoreboard").toString()));
+        if (body.get("showCheckin") != null) cfg.setShowCheckin(Integer.valueOf(body.get("showCheckin").toString()));
+        if (body.get("showProgress") != null) cfg.setShowProgress(Integer.valueOf(body.get("showProgress").toString()));
+        if (body.get("showGroupProgress") != null) cfg.setShowGroupProgress(Integer.valueOf(body.get("showGroupProgress").toString()));
+        if (body.get("showVote") != null) cfg.setShowVote(Integer.valueOf(body.get("showVote").toString()));
+        if (body.get("showMaterial") != null) cfg.setShowMaterial(Integer.valueOf(body.get("showMaterial").toString()));
+        if (body.get("showBanner") != null) cfg.setShowBanner(Integer.valueOf(body.get("showBanner").toString()));
+        if (body.get("showTimer") != null) cfg.setShowTimer(Integer.valueOf(body.get("showTimer").toString()));
+        if (body.get("agenda") != null) {
+            try {
+                cfg.setAgenda(objectMapper.writeValueAsString(body.get("agenda")));
+            } catch (Exception e) {
+                return Result.error("环节数据格式错误");
+            }
+        }
+        if (body.get("currentStep") != null) cfg.setCurrentStep(Integer.valueOf(body.get("currentStep").toString()));
+        if (body.get("banner") != null) cfg.setBanner(body.get("banner").toString());
+
+        if (cfg.getId() == null) {
+            trainScreenConfigMapper.insert(cfg);
+        } else {
+            trainScreenConfigMapper.updateById(cfg);
+        }
+        return Result.success(true);
+    }
+
+    /**
+     * 切换当前环节(助教控制课程进展进度)。
+     */
+    @Operation(summary = "切换当前环节")
+    @PostMapping("/step")
+    public Result<Boolean> step(@RequestBody Map<String, Object> body,
+                                @RequestAttribute("adminId") Long adminId) {
+        Long classId = Long.valueOf(body.get("classId").toString());
+        int step = Integer.parseInt(body.get("step").toString());
+        TrainScreenConfig cfg = findConfig(classId);
+        if (cfg == null) {
+            cfg = new TrainScreenConfig();
+            cfg.setClassId(classId);
+            applyDefaults(cfg);
+            cfg.setAgenda("[]");
+            cfg.setCurrentStep(step);
+            trainScreenConfigMapper.insert(cfg);
+        } else {
+            cfg.setCurrentStep(step);
+            trainScreenConfigMapper.updateById(cfg);
+        }
+        return Result.success(true);
+    }
+
+    // ============ 课程计时 ============
+
+    /**
+     * 开始课程:记录课程开始时间(幂等,已开始不重设),投屏开始计时。
+     */
+    @Operation(summary = "开始课程(记录开始时间)")
+    @PostMapping("/start")
+    public Result<Boolean> start(@RequestBody Map<String, Object> body,
+                                 @RequestAttribute("adminId") Long adminId) {
+        Long classId = Long.valueOf(body.get("classId").toString());
+        TrainClass cls = trainClassMapper.selectById(classId);
+        if (cls == null) {
+            return Result.error("班级不存在");
+        }
+        if (cls.getStartAt() == null) {
+            cls.setStartAt(new Date());
+            trainClassMapper.updateById(cls);
+        }
+        return Result.success(true);
+    }
+
+    /**
+     * 重置课程:清空开始时间(次日重新开课或误操作回退)。
+     */
+    @Operation(summary = "重置课程(清空开始时间)")
+    @PostMapping("/reset")
+    public Result<Boolean> reset(@RequestBody Map<String, Object> body,
+                                 @RequestAttribute("adminId") Long adminId) {
+        Long classId = Long.valueOf(body.get("classId").toString());
+        TrainClass cls = trainClassMapper.selectById(classId);
+        if (cls == null) {
+            return Result.error("班级不存在");
+        }
+        cls.setStartAt(null);
+        trainClassMapper.updateById(cls);
+        return Result.success(true);
+    }
+
+    // ============ 投票结果录入 ============
+
+    /**
+     * 手工录入投票结果(线下投票/展示用):
+     * 先清空该班该轮的手工录入记录(voterUid &lt;= 0),再按各组票数重新写入,
+     * 投票结果统计接口可自然汇总。
+     */
+    @Operation(summary = "录入投票结果(线下投票)")
+    @PostMapping("/vote/record")
+    public Result<Boolean> voteRecord(@RequestBody Map<String, Object> body,
+                                      @RequestAttribute("adminId") Long adminId) {
+        Long classId = Long.valueOf(body.get("classId").toString());
+        Integer round = Integer.valueOf(body.get("round").toString());
+        List<?> records = (List<?>) body.get("records");
+        if (records == null) {
+            return Result.error("投票结果不能为空");
+        }
+        // 清空该班该轮手工录入记录(负数 voterUid 标记)
+        trainVoteMapper.delete(new LambdaQueryWrapper<TrainVote>()
+                .eq(TrainVote::getClassId, classId)
+                .eq(TrainVote::getRound, round)
+                .le(TrainVote::getVoterUid, 0));
+        for (Object rec : records) {
+            Map<?, ?> m = (Map<?, ?>) rec;
+            Long groupId = Long.valueOf(m.get("groupId").toString());
+            int count = Integer.parseInt(m.get("count").toString());
+            for (int i = 0; i < count; i++) {
+                TrainVote vote = new TrainVote();
+                vote.setClassId(classId);
+                vote.setRound(round);
+                vote.setVoterUid(-adminId); // 负数标记为助教手工录入
+                vote.setTargetGroupId(groupId);
+                trainVoteMapper.insert(vote);
+            }
+        }
+        return Result.success(true);
+    }
+
+    // ============ 内部辅助 ============
+
+    private TrainScreenConfig findConfig(Long classId) {
+        return trainScreenConfigMapper.selectOne(
+                new LambdaQueryWrapper<TrainScreenConfig>().eq(TrainScreenConfig::getClassId, classId));
+    }
+
+    private void applyDefaults(TrainScreenConfig cfg) {
+        cfg.setShowScoreboard(1);
+        cfg.setShowCheckin(1);
+        cfg.setShowProgress(1);
+        cfg.setShowGroupProgress(1);
+        cfg.setShowVote(1);
+        cfg.setShowMaterial(1);
+        cfg.setShowBanner(1);
+        cfg.setShowTimer(1);
+    }
+
+    private Map<String, Object> defaultConfigMap() {
+        Map<String, Object> cfg = new HashMap<>();
+        cfg.put("showScoreboard", 1);
+        cfg.put("showCheckin", 1);
+        cfg.put("showProgress", 1);
+        cfg.put("showGroupProgress", 1);
+        cfg.put("showVote", 1);
+        cfg.put("showMaterial", 1);
+        cfg.put("showBanner", 1);
+        cfg.put("showTimer", 1);
+        cfg.put("agenda", new ArrayList<>());
+        cfg.put("currentStep", 0);
+        cfg.put("banner", "");
+        return cfg;
+    }
+
+    private Map<String, Object> toConfigMap(TrainScreenConfig cfg) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("showScoreboard", cfg.getShowScoreboard());
+        m.put("showCheckin", cfg.getShowCheckin());
+        m.put("showProgress", cfg.getShowProgress());
+        m.put("showGroupProgress", cfg.getShowGroupProgress());
+        m.put("showVote", cfg.getShowVote());
+        m.put("showMaterial", cfg.getShowMaterial());
+        m.put("showBanner", cfg.getShowBanner());
+        m.put("showTimer", cfg.getShowTimer());
+        m.put("currentStep", cfg.getCurrentStep());
+        m.put("banner", cfg.getBanner() == null ? "" : cfg.getBanner());
+        List<Object> agenda = new ArrayList<>();
+        if (StringUtils.hasText(cfg.getAgenda())) {
+            try {
+                agenda = objectMapper.readValue(cfg.getAgenda(), List.class);
+            } catch (Exception e) {
+                // 历史脏数据兜底为空列表
+            }
+        }
+        m.put("agenda", agenda);
+        return m;
+    }
+}

+ 105 - 0
train-backend/src/main/java/com/train/controller/admin/AdminStatsController.java

@@ -1,6 +1,7 @@
 package com.train.controller.admin;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.train.common.Result;
 import com.train.entity.*;
 import com.train.mapper.*;
@@ -8,6 +9,7 @@ import com.train.service.CertService;
 import com.train.service.ScoreService;
 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.*;
 
 import javax.annotation.Resource;
@@ -47,9 +49,17 @@ public class AdminStatsController {
     @Resource
     private TrainScoreMapper trainScoreMapper;
     @Resource
+    private TrainScreenConfigMapper trainScreenConfigMapper;
+    @Resource
+    private TrainAssignmentMapper trainAssignmentMapper;
+    @Resource
+    private TrainCourseMaterialMapper trainCourseMaterialMapper;
+    @Resource
     private ScoreService scoreService;
     @Resource
     private CertService certService;
+    @Resource
+    private ObjectMapper objectMapper;
 
     @Operation(summary = "班级整体统计")
     @PostMapping("/stats/overview")
@@ -169,6 +179,32 @@ public class AdminStatsController {
         classInfo.put("name", cur.getName());
         data.put("classInfo", classInfo);
 
+        // 0 投屏配置与课程计时(助教在后台设置,大屏按配置显示)
+        TrainScreenConfig cfg = trainScreenConfigMapper.selectOne(
+                new LambdaQueryWrapper<TrainScreenConfig>().eq(TrainScreenConfig::getClassId, cur.getId()));
+        Map<String, Object> config = new HashMap<>();
+        config.put("showScoreboard", cfg != null && cfg.getShowScoreboard() != null ? cfg.getShowScoreboard() : 1);
+        config.put("showCheckin", cfg != null && cfg.getShowCheckin() != null ? cfg.getShowCheckin() : 1);
+        config.put("showProgress", cfg != null && cfg.getShowProgress() != null ? cfg.getShowProgress() : 1);
+        config.put("showGroupProgress", cfg != null && cfg.getShowGroupProgress() != null ? cfg.getShowGroupProgress() : 1);
+        config.put("showVote", cfg != null && cfg.getShowVote() != null ? cfg.getShowVote() : 1);
+        config.put("showMaterial", cfg != null && cfg.getShowMaterial() != null ? cfg.getShowMaterial() : 1);
+        config.put("showBanner", cfg != null && cfg.getShowBanner() != null ? cfg.getShowBanner() : 1);
+        config.put("showTimer", cfg != null && cfg.getShowTimer() != null ? cfg.getShowTimer() : 1);
+        config.put("currentStep", cfg != null && cfg.getCurrentStep() != null ? cfg.getCurrentStep() : 0);
+        config.put("banner", cfg != null && cfg.getBanner() != null ? cfg.getBanner() : "");
+        List<Object> agenda = new ArrayList<>();
+        if (cfg != null && org.springframework.util.StringUtils.hasText(cfg.getAgenda())) {
+            try {
+                agenda = objectMapper.readValue(cfg.getAgenda(), List.class);
+            } catch (Exception e) {
+                // 历史脏数据兜底为空列表
+            }
+        }
+        config.put("agenda", agenda);
+        data.put("config", config);
+        data.put("startAt", cur.getStartAt());
+
         // 1 小组积分榜
         List<Map<String, Object>> board = new ArrayList<>();
         List<TrainGroup> groups = trainGroupMapper.selectList(
@@ -262,6 +298,75 @@ public class AdminStatsController {
         progress.put("rows", rows);
         data.put("progress", progress);
 
+        // 4 小组进展矩阵(分工确认 / 任务上传截图 / 确认完成 / 路演材料)
+        List<Map<String, Object>> groupProgress = new ArrayList<>();
+        for (TrainGroup g : groups) {
+            Map<String, Object> gp = new HashMap<>();
+            gp.put("groupId", g.getId());
+            gp.put("groupName", g.getName());
+            // 成员数
+            Long mc = trainGroupMemberMapper.selectCount(
+                    new LambdaQueryWrapper<TrainGroupMember>().eq(TrainGroupMember::getGroupId, g.getId()));
+            long memberCount = mc == null ? 0 : mc;
+            gp.put("memberCount", memberCount);
+            // 分工确认:该组 status=confirmed 的分工记录数
+            Long assignConfirmed = trainAssignmentMapper.selectCount(
+                    new LambdaQueryWrapper<TrainAssignment>()
+                            .eq(TrainAssignment::getGroupId, g.getId())
+                            .eq(TrainAssignment::getStatus, "confirmed"));
+            long assignConfirmedCount = assignConfirmed == null ? 0 : assignConfirmed;
+            gp.put("assignConfirmed", assignConfirmedCount);
+            gp.put("assignDone", assignConfirmedCount >= memberCount);
+            // 任务上传:该组成果上传(含截图)记录数
+            Long uploadCount = trainSubmissionMapper.selectCount(
+                    new LambdaQueryWrapper<TrainSubmission>().eq(TrainSubmission::getGroupId, g.getId()));
+            gp.put("uploadCount", uploadCount == null ? 0 : uploadCount);
+            // 确认完成:该组成果审核通过(approved)数
+            Long approvedCount = trainSubmissionMapper.selectCount(
+                    new LambdaQueryWrapper<TrainSubmission>()
+                            .eq(TrainSubmission::getGroupId, g.getId())
+                            .eq(TrainSubmission::getStatus, "approved"));
+            gp.put("approvedCount", approvedCount == null ? 0 : approvedCount);
+            // 路演材料:该组是否已上传
+            Long roadmapCount = trainRoadmapMapper.selectCount(
+                    new LambdaQueryWrapper<TrainRoadmap>().eq(TrainRoadmap::getGroupId, g.getId()));
+            gp.put("roadmapUploaded", roadmapCount != null && roadmapCount > 0);
+            groupProgress.add(gp);
+        }
+        groupProgress.sort((a, b) -> Integer.compare(
+                ((Number) b.get("memberCount")).intValue(), ((Number) a.get("memberCount")).intValue()));
+        data.put("groupProgress", groupProgress);
+
+        // 5 课程资料(含通用资料,供投屏展示)
+        List<TrainCourseMaterial> materials = trainCourseMaterialMapper.selectList(
+                new LambdaQueryWrapper<TrainCourseMaterial>()
+                        .and(w -> w.eq(TrainCourseMaterial::getClassId, cur.getId())
+                                .or().isNull(TrainCourseMaterial::getClassId))
+                        .orderByAsc(TrainCourseMaterial::getSort)
+                        .orderByDesc(TrainCourseMaterial::getId()));
+        data.put("materials", materials);
+
+        // 6 投票结果(两轮柱状数据,供投屏展示)
+        Map<String, Object> votes = new HashMap<>();
+        for (int round = 1; round <= 2; round++) {
+            List<Map<String, Object>> bars = new ArrayList<>();
+            for (TrainGroup g : groups) {
+                Long cnt = trainVoteMapper.selectCount(
+                        new LambdaQueryWrapper<TrainVote>()
+                                .eq(TrainVote::getClassId, cur.getId())
+                                .eq(TrainVote::getRound, round)
+                                .eq(TrainVote::getTargetGroupId, g.getId()));
+                Map<String, Object> bar = new HashMap<>();
+                bar.put("groupId", g.getId());
+                bar.put("groupName", g.getName());
+                bar.put("count", cnt == null ? 0 : cnt);
+                bars.add(bar);
+            }
+            bars.sort((a, b) -> Integer.compare(((Number) b.get("count")).intValue(), ((Number) a.get("count")).intValue()));
+            votes.put("round" + round, bars);
+        }
+        data.put("votes", votes);
+
         return Result.success(data);
     }
 }

+ 1 - 0
train-backend/src/main/java/com/train/entity/TrainClass.java

@@ -27,6 +27,7 @@ public class TrainClass implements Serializable {
     private String place;
     private Integer capacity;
     private String status; // draft/active/finished
+    private Date startAt; // 课程开始时间(投屏计时基准,NULL=未开始)
     private Date createdAt;
     private Date updatedAt;
 }

+ 41 - 0
train-backend/src/main/java/com/train/entity/TrainScreenConfig.java

@@ -0,0 +1,41 @@
+package com.train.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("train_screen_config")
+public class TrainScreenConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long classId;
+
+    private Integer showScoreboard;   // 1=显示小组积分榜
+    private Integer showCheckin;      // 1=显示签到情况
+    private Integer showProgress;     // 1=显示完课进度
+    private Integer showGroupProgress;// 1=显示小组进展矩阵(分工/任务/路演)
+    private Integer showVote;         // 1=显示投票结果
+    private Integer showMaterial;     // 1=显示课程资料
+    private Integer showBanner;       // 1=显示公告横幅
+    private Integer showTimer;        // 1=显示课程进行时间
+
+    /** 课程环节 JSON: [{"title":"开场","minutes":10},...] */
+    private String agenda;
+    /** 当前环节序号(从 0 开始,助教切换) */
+    private Integer currentStep;
+    /** 投屏公告 */
+    private String banner;
+
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 7 - 0
train-backend/src/main/java/com/train/mapper/TrainScreenConfigMapper.java

@@ -0,0 +1,7 @@
+package com.train.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.train.entity.TrainScreenConfig;
+
+public interface TrainScreenConfigMapper extends BaseMapper<TrainScreenConfig> {
+}

+ 21 - 0
train-backend/src/main/resources/schema.sql

@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS train_user (
 ALTER TABLE train_class ADD COLUMN activity_id BIGINT COMMENT '关联 cfc 活动 ID(activities.id,cfc 同库)';
 ALTER TABLE train_class ADD COLUMN course_id BIGINT COMMENT '关联课程 ID(train_course.id,L0/L1/L2…)';
 ALTER TABLE train_user ADD COLUMN cfc_user_id BIGINT COMMENT 'cfc 统一会员 ID(users.id,cfc 同库)';
+ALTER TABLE train_class ADD COLUMN start_at DATETIME COMMENT '课程开始时间(投屏计时基准,NULL=未开始)';
 
 -- 小组表
 CREATE TABLE IF NOT EXISTS train_group (
@@ -335,3 +336,23 @@ CREATE TABLE IF NOT EXISTS train_course_material (
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_class_id (class_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课前资料包';
+
+-- 班级投屏配置表(每班一条,按下述模块开关控制大屏显示)
+CREATE TABLE IF NOT EXISTS train_screen_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    class_id BIGINT NOT NULL COMMENT '班次ID',
+    show_scoreboard TINYINT DEFAULT 1 COMMENT '1=显示小组积分榜',
+    show_checkin TINYINT DEFAULT 1 COMMENT '1=显示签到情况',
+    show_progress TINYINT DEFAULT 1 COMMENT '1=显示完课进度',
+    show_group_progress TINYINT DEFAULT 1 COMMENT '1=显示小组进展矩阵(分工/任务/路演)',
+    show_vote TINYINT DEFAULT 1 COMMENT '1=显示投票结果',
+    show_material TINYINT DEFAULT 1 COMMENT '1=显示课程资料',
+    show_banner TINYINT DEFAULT 1 COMMENT '1=显示公告横幅',
+    show_timer TINYINT DEFAULT 1 COMMENT '1=显示课程进行时间',
+    agenda VARCHAR(2000) COMMENT '课程环节JSON: [{"title":"开场","minutes":10},...]',
+    current_step INT DEFAULT 0 COMMENT '当前环节序号(从0开始,助教切换)',
+    banner VARCHAR(200) COMMENT '投屏公告',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_class_id (class_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级投屏配置';

+ 36 - 0
train-web/src/api/screen.js

@@ -0,0 +1,36 @@
+import request from '@/utils/request'
+
+/** 投屏聚合数据(班级列表 + 积分榜 + 签到 + 完课进度 + 小组进展 + 资料 + 投票结果 + 配置) */
+export function screenData(data) {
+  return request.post('/api/admin/stats/screen', data || {})
+}
+
+/** 读取投屏配置(含课程开始时间) */
+export function screenConfigGet(data) {
+  return request.post('/api/admin/screen/config/get', data || {})
+}
+
+/** 保存投屏配置(模块开关/agenda/环节/公告) */
+export function screenConfigSave(data) {
+  return request.post('/api/admin/screen/config/save', data || {})
+}
+
+/** 切换当前环节(助教控制) */
+export function screenStep(data) {
+  return request.post('/api/admin/screen/step', data || {})
+}
+
+/** 开始课程(记录开始时间) */
+export function screenStart(data) {
+  return request.post('/api/admin/screen/start', data || {})
+}
+
+/** 重置课程(清空开始时间) */
+export function screenReset(data) {
+  return request.post('/api/admin/screen/reset', data || {})
+}
+
+/** 录入投票结果(线下投票) */
+export function screenVoteRecord(data) {
+  return request.post('/api/admin/screen/vote/record', data || {})
+}

+ 708 - 15
train-web/src/views/Screen.vue

@@ -6,6 +6,18 @@
       <div class="title-box">
         <div class="course-title">{{ classInfo.name || '课程现场' }}</div>
         <div class="banner-text">{{ banner }}</div>
+        <!-- 课程进行时间(突出显示) -->
+        <div class="timer-row" v-if="showTimer">
+          <div class="timer-box" v-if="startAt">
+            <span class="timer-label">课程进行中</span>
+            <span class="timer-value">{{ elapsedText }}</span>
+            <span class="step-pill">{{ currentStepTitle }}</span>
+          </div>
+          <div class="timer-box idle" v-else>
+            <span class="timer-label">课程未开始</span>
+            <span class="timer-idle">等待助教开始课程</span>
+          </div>
+        </div>
       </div>
       <div class="clock">
         <div class="time">{{ now }}</div>
@@ -13,10 +25,19 @@
       </div>
     </div>
 
+    <!-- 环节步骤条 -->
+    <div class="agenda-bar" v-if="config.agenda && config.agenda.length > 0">
+      <div class="step-item" v-for="(ag, i) in config.agenda" :key="i"
+        :class="{ active: i === currentStep, done: i < currentStep }">
+        <div class="step-dot"></div>
+        <div class="step-name">{{ ag.title }}</div>
+      </div>
+    </div>
+
     <!-- 主体 -->
     <div class="screen-body">
       <!-- 左侧:积分榜 -->
-      <div class="panel scoreboard-panel">
+      <div class="panel scoreboard-panel" v-if="config.showScoreboard !== 0">
         <div class="panel-title">
           <span>小组积分榜</span>
           <span class="panel-sub">GROUP RANKING</span>
@@ -34,9 +55,9 @@
         </div>
       </div>
 
-      <!-- 右侧:签到 + 完课进度 -->
+      <!-- 右侧 -->
       <div class="right-col">
-        <div class="panel checkin-panel">
+        <div class="panel checkin-panel" v-if="config.showCheckin !== 0">
           <div class="panel-title">
             <span>签到情况</span>
             <span class="stat-badge">{{ checkins.checkedIn || 0 }} / {{ checkins.total || 0 }}</span>
@@ -55,7 +76,7 @@
           </div>
         </div>
 
-        <div class="panel progress-panel">
+        <div class="panel progress-panel" v-if="config.showProgress !== 0">
           <div class="panel-title">
             <span>完课进度</span>
             <span class="stat-badge">{{ progress.eligible || 0 }} / {{ progress.total || 0 }}</span>
@@ -74,6 +95,67 @@
           </div>
         </div>
       </div>
+
+      <!-- 中部:小组进展矩阵 -->
+      <div class="panel gp-panel" v-if="config.showGroupProgress !== 0">
+        <div class="panel-title">
+          <span>小组进展</span>
+          <span class="panel-sub">GROUP PROGRESS</span>
+        </div>
+        <div class="gp-table">
+          <div class="gp-head">
+            <div class="gp-cell group">小组</div>
+            <div class="gp-cell">分工确认</div>
+            <div class="gp-cell">任务上传</div>
+            <div class="gp-cell">确认完成</div>
+            <div class="gp-cell">路演材料</div>
+          </div>
+          <div class="gp-row" v-for="g in groupProgress" :key="g.groupId">
+            <div class="gp-cell group">{{ g.groupName }}</div>
+            <div class="gp-cell"><span :class="['status-pill', g.assignDone ? 'ok' : 'no']">{{ g.assignDone ? '已完成' : g.assignConfirmed + '/' + g.memberCount }}</span></div>
+            <div class="gp-cell"><span :class="['status-pill', g.uploadCount > 0 ? 'ok' : 'no']">{{ g.uploadCount > 0 ? g.uploadCount + ' 项' : '未上传' }}</span></div>
+            <div class="gp-cell"><span :class="['status-pill', g.approvedCount > 0 ? 'ok' : 'no']">{{ g.approvedCount > 0 ? '已确认' : '待确认' }}</span></div>
+            <div class="gp-cell"><span :class="['status-pill', g.roadmapUploaded ? 'ok' : 'no']">{{ g.roadmapUploaded ? '已上传' : '未上传' }}</span></div>
+          </div>
+          <div v-if="groupProgress.length === 0" class="empty">暂无小组</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 底部追加:投票结果 + 资料 -->
+    <div class="lower-row">
+      <div class="panel vote-panel" v-if="config.showVote !== 0">
+        <div class="panel-title">
+          <span>路演投票</span>
+          <span class="panel-sub">VOTE</span>
+        </div>
+        <div class="vote-tabs">
+          <span class="vote-tab" :class="{ active: voteRound === 1 }" @click="voteRound = 1">第1轮</span>
+          <span class="vote-tab" :class="{ active: voteRound === 2 }" @click="voteRound = 2">第2轮</span>
+        </div>
+        <div class="vote-bars">
+          <div class="v-row" v-for="b in currentVoteBars" :key="b.groupId">
+            <span class="v-name">{{ b.groupName }}</span>
+            <div class="v-track"><div class="v-fill" :style="{ width: voteWidth(b.count) + '%' }"></div></div>
+            <span class="v-count">{{ b.count }}</span>
+          </div>
+          <div v-if="currentVoteBars.length === 0" class="empty">暂无投票数据</div>
+        </div>
+      </div>
+
+      <div class="panel material-panel" v-if="config.showMaterial !== 0">
+        <div class="panel-title">
+          <span>课程资料</span>
+          <span class="panel-sub">MATERIAL</span>
+        </div>
+        <div class="material-list">
+          <div class="m-item" v-for="m in materials" :key="m.id">
+            <span class="m-icon">📄</span>
+            <span class="m-name">{{ m.title }}</span>
+          </div>
+          <div v-if="materials.length === 0" class="empty">暂无资料</div>
+        </div>
+      </div>
     </div>
 
     <!-- 底部状态条 -->
@@ -84,36 +166,124 @@
 
     <!-- 悬浮控制条(鼠标移动显示) -->
     <div class="control-bar" v-if="showControl">
-      <el-select v-model="classId" size="small" @change="fetchData" style="width: 200px">
+      <el-select v-model="classId" size="small" @change="onClassChange" style="width: 200px">
         <el-option v-for="c in classList" :key="c.id" :label="c.name" :value="String(c.id)" />
       </el-select>
       <el-button size="small" @click="toggleFullscreen">{{ isFullscreen ? '退出全屏' : '全屏' }}</el-button>
+      <el-button size="small" @click="startClass">开始课程</el-button>
+      <el-button size="small" @click="openStepEditor">设置环节</el-button>
+      <el-button size="small" @click="openVoteEditor">录入投票</el-button>
       <el-button size="small" @click="openBannerEditor">编辑公告</el-button>
+      <el-button size="small" @click="openConfigEditor">投屏设置</el-button>
       <el-button size="small" type="text" class="back-link" @click="goBack">返回后台</el-button>
     </div>
 
     <!-- 公告编辑弹窗 -->
     <el-dialog title="编辑投屏公告" :visible.sync="editBanner" width="480px" append-to-body>
-      <div class="banner-hint">显示在投屏顶部标题下方,保存到本地浏览器</div>
+      <div class="banner-hint">显示在投屏顶部标题下方,随投屏配置保存</div>
       <el-input v-model="bannerDraft" placeholder="例如:欢迎来到家立方-AI管家成长营" maxlength="60" show-word-limit></el-input>
       <div slot="footer">
         <el-button @click="editBanner = false">取消</el-button>
         <el-button type="primary" @click="saveBanner">保存</el-button>
       </div>
     </el-dialog>
+
+    <!-- 环节设置弹窗 -->
+    <el-dialog title="设置课程环节" :visible.sync="editStep" width="520px" append-to-body>
+      <div class="banner-hint">切换当前进行到的课程环节,投屏顶部同步高亮</div>
+      <div class="step-edit-list">
+        <div class="step-edit-item" v-for="(ag, i) in agendaDraft" :key="i"
+          :class="{ active: i === currentStepDraft }" @click="currentStepDraft = i">
+          <span class="step-edit-dot">{{ i + 1 }}</span>
+          <span class="step-edit-name">{{ ag.title }}</span>
+          <span class="step-edit-min">{{ ag.minutes }} 分钟</span>
+        </div>
+        <div v-if="agendaDraft.length === 0" class="empty">请先在「投屏设置」中配置课程环节</div>
+      </div>
+      <div slot="footer">
+        <el-button @click="editStep = false">取消</el-button>
+        <el-button type="primary" @click="saveStep">设为当前环节</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 投屏设置弹窗 -->
+    <el-dialog title="投屏设置" :visible.sync="editConfig" width="640px" append-to-body>
+      <el-form label-width="120px">
+        <el-form-item label="显示模块">
+          <el-checkbox v-model="cfgDraft.showScoreboard">小组积分榜</el-checkbox>
+          <el-checkbox v-model="cfgDraft.showCheckin">签到情况</el-checkbox>
+          <el-checkbox v-model="cfgDraft.showProgress">完课进度</el-checkbox>
+          <el-checkbox v-model="cfgDraft.showGroupProgress">小组进展</el-checkbox>
+          <el-checkbox v-model="cfgDraft.showVote">投票结果</el-checkbox>
+          <el-checkbox v-model="cfgDraft.showMaterial">课程资料</el-checkbox>
+          <el-checkbox v-model="cfgDraft.showBanner">公告</el-checkbox>
+          <el-checkbox v-model="cfgDraft.showTimer">进行时间</el-checkbox>
+        </el-form-item>
+        <el-form-item label="课程环节">
+          <div class="agenda-editor">
+            <div class="agenda-row" v-for="(ag, i) in cfgDraft.agenda" :key="i">
+              <el-input v-model="ag.title" placeholder="环节名称" size="small" style="width: 260px"></el-input>
+              <el-input v-model.number="ag.minutes" placeholder="分钟" size="small" style="width: 90px"></el-input>
+              <el-button size="small" type="text" @click="removeAgenda(i)">删除</el-button>
+            </div>
+            <el-button size="small" type="text" @click="addAgenda">+ 添加环节</el-button>
+          </div>
+        </el-form-item>
+        <el-form-item label="公告">
+          <el-input v-model="cfgDraft.banner" placeholder="投屏顶部公告" maxlength="60"></el-input>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="editConfig = false">取消</el-button>
+        <el-button type="primary" @click="saveConfig">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 投票录入弹窗(线下投票结果) -->
+    <el-dialog title="录入投票结果" :visible.sync="editVote" width="520px" append-to-body>
+      <div class="banner-hint">线下投票结果录入,投屏投票模块同步展示(负数标记为手工录入)</div>
+      <div class="vote-tabs light">
+        <span class="vote-tab" :class="{ active: voteRoundDraft === 1 }" @click="switchVoteRound(1)">第1轮</span>
+        <span class="vote-tab" :class="{ active: voteRoundDraft === 2 }" @click="switchVoteRound(2)">第2轮</span>
+      </div>
+      <div class="vote-edit-list">
+        <div class="vote-edit-row" v-for="g in voteDraftRows" :key="g.groupId">
+          <span class="vote-edit-name">{{ g.groupName }}</span>
+          <el-input-number v-model="g.count" :min="0" :max="999" size="small"></el-input-number>
+        </div>
+        <div v-if="voteDraftRows.length === 0" class="empty">暂无小组</div>
+      </div>
+      <div slot="footer">
+        <el-button @click="editVote = false">取消</el-button>
+        <el-button type="primary" @click="saveVote">保存投票结果</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import { statsScreen } from '@/api/stats'
+import { screenConfigGet, screenConfigSave, screenStep, screenStart, screenReset, screenVoteRecord } from '@/api/screen'
 import { classList } from '@/api/class'
 
 var BANNER_KEY = 'train-screen-banner'
+var CLASS_KEY = 'train-screen-classId'
 
 function pad(n) {
   return n < 10 ? '0' + n : '' + n
 }
 
+function parseAgenda(jsonText) {
+  if (!jsonText) return []
+  var arr = []
+  try {
+    arr = JSON.parse(jsonText)
+  } catch (e) {
+    arr = []
+  }
+  return (Array.isArray(arr) ? arr : []).filter(function (a) { return a && a.title })
+}
+
 export default {
   name: 'Screen',
   data: function () {
@@ -124,18 +294,60 @@ export default {
       scoreboard: [],
       checkins: {},
       progress: {},
+      groupProgress: [],
+      materials: [],
+      config: {},
+      startAt: '',
+      voteRound: 1,
+      votes: {},
       now: '',
       today: '',
       banner: '',
       bannerDraft: '',
       editBanner: false,
+      editStep: false,
+      editConfig: false,
+      editVote: false,
+      voteRoundDraft: 1,
+      voteDraftRows: [],
       showControl: false,
       isFullscreen: false,
+      currentStepDraft: 0,
+      agendaDraft: [],
+      cfgDraft: {
+        showScoreboard: true, showCheckin: true, showProgress: true,
+        showGroupProgress: true, showVote: true, showMaterial: true,
+        showBanner: true, showTimer: true, agenda: [], banner: ''
+      },
       pollTimer: null,
       clockTimer: null
     }
   },
   computed: {
+    showTimer: function () {
+      return this.config.showTimer !== 0
+    },
+    currentStep: function () {
+      return this.config.currentStep || 0
+    },
+    currentStepTitle: function () {
+      var agenda = this.config.agenda || []
+      var cs = this.currentStep
+      if (agenda.length === 0) return ''
+      if (cs >= agenda.length) return agenda[agenda.length - 1].title
+      return agenda[cs].title
+    },
+    elapsedText: function () {
+      if (!this.startAt) return '00:00:00'
+      var start = new Date(String(this.startAt).replace(/-/g, '/'))
+      var now = new Date()
+      var secs = Math.floor((now.getTime() - start.getTime()) / 1000)
+      if (secs < 0) secs = 0
+      var h = Math.floor(secs / 3600)
+      var m = Math.floor((secs % 3600) / 60)
+      var s = secs % 60
+      return pad(h) + ':' + pad(m) + ':' + pad(s)
+    },
     checkinPct: function () {
       var total = this.checkins.total || 0
       return total > 0 ? Math.round(((this.checkins.checkedIn || 0) / total) * 100) : 0
@@ -149,6 +361,10 @@ export default {
     },
     rows: function () {
       return (this.progress.rows || []).filter(function (u) { return !u.eligible })
+    },
+    currentVoteBars: function () {
+      var key = 'round' + this.voteRound
+      return this.votes[key] || []
     }
   },
   mounted: function () {
@@ -171,6 +387,10 @@ export default {
         self.classList = res.data || []
       }).catch(function () {})
     },
+    onClassChange: function () {
+      localStorage.setItem(CLASS_KEY, this.classId)
+      this.fetchData()
+    },
     fetchData: function () {
       var self = this
       var params = {}
@@ -181,10 +401,23 @@ export default {
         self.scoreboard = data.scoreboard || []
         self.checkins = data.checkins || {}
         self.progress = data.progress || {}
+        self.groupProgress = data.groupProgress || []
+        self.materials = data.materials || []
+        self.votes = data.votes || {}
+        self.config = data.config || {}
+        self.startAt = data.startAt || ''
+        if (self.config.banner !== undefined) {
+          self.banner = self.config.banner || ''
+        }
         var cls = data.classList || []
         if (cls.length > 0 && !self.classId) {
           self.classList = cls
-          self.classId = String(cls[0].id)
+          var saved = localStorage.getItem(CLASS_KEY)
+          if (saved && cls.some(function (c) { return String(c.id) === saved })) {
+            self.classId = saved
+          } else {
+            self.classId = String(cls[0].id)
+          }
         }
       }).catch(function () {})
     },
@@ -195,6 +428,120 @@ export default {
       this.today = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
         ' 星期' + week[d.getDay()]
     },
+    voteWidth: function (count) {
+      var max = 1
+      this.currentVoteBars.forEach(function (b) {
+        if (b.count > max) max = b.count
+      })
+      return Math.round((count / max) * 100)
+    },
+    startClass: function () {
+      var self = this
+      if (!this.classId) return
+      this.$confirm('确定开始课程?将记录课程开始时间并在投屏上开始计时。', '开始课程', {
+        confirmButtonText: '开始', cancelButtonText: '取消', type: 'info'
+      }).then(function () {
+        return screenStart({ classId: self.classId })
+      }).then(function () {
+        self.fetchData()
+        self.$message.success('课程已开始')
+      }).catch(function () {})
+    },
+    openStepEditor: function () {
+      var agenda = this.config.agenda || []
+      this.agendaDraft = agenda.map(function (a) { return { title: a.title, minutes: a.minutes } })
+      this.currentStepDraft = this.currentStep
+      this.editStep = true
+    },
+    saveStep: function () {
+      var self = this
+      screenStep({ classId: this.classId, step: this.currentStepDraft }).then(function () {
+        self.editStep = false
+        self.fetchData()
+        self.$message.success('已切换当前环节')
+      }).catch(function () {})
+    },
+    openVoteEditor: function () {
+      this.voteRoundDraft = this.voteRound
+      this.loadVoteDraftRows()
+      this.editVote = true
+    },
+    switchVoteRound: function (round) {
+      if (this.voteRoundDraft === round) return
+      this.voteRoundDraft = round
+      this.loadVoteDraftRows()
+    },
+    loadVoteDraftRows: function () {
+      var key = 'round' + this.voteRoundDraft
+      var bars = this.votes[key] || []
+      // 若尚无该轮投票数据,用积分榜的小组作为底稿
+      var rows = bars.length > 0 ? bars : this.scoreboard.map(function (g) {
+        return { groupId: g.groupId, groupName: g.groupName, count: 0 }
+      })
+      this.voteDraftRows = rows.map(function (b) {
+        return { groupId: b.groupId, groupName: b.groupName, count: b.count || 0 }
+      })
+    },
+    saveVote: function () {
+      var self = this
+      var records = this.voteDraftRows.map(function (r) {
+        return { groupId: r.groupId, count: Number(r.count) || 0 }
+      })
+      screenVoteRecord({ classId: this.classId, round: this.voteRoundDraft, records: records }).then(function () {
+        self.editVote = false
+        self.voteRound = self.voteRoundDraft
+        self.fetchData()
+        self.$message.success('投票结果已录入')
+      }).catch(function () {})
+    },
+    openConfigEditor: function () {
+      var cfg = this.config || {}
+      var agenda = (cfg.agenda || []).map(function (a) {
+        return { title: a.title, minutes: a.minutes === undefined ? 10 : a.minutes }
+      })
+      this.cfgDraft = {
+        showScoreboard: cfg.showScoreboard !== 0,
+        showCheckin: cfg.showCheckin !== 0,
+        showProgress: cfg.showProgress !== 0,
+        showGroupProgress: cfg.showGroupProgress !== 0,
+        showVote: cfg.showVote !== 0,
+        showMaterial: cfg.showMaterial !== 0,
+        showBanner: cfg.showBanner !== 0,
+        showTimer: cfg.showTimer !== 0,
+        agenda: agenda,
+        banner: cfg.banner || ''
+      }
+      this.editConfig = true
+    },
+    saveConfig: function () {
+      var self = this
+      var payload = {
+        classId: this.classId,
+        showScoreboard: this.cfgDraft.showScoreboard ? 1 : 0,
+        showCheckin: this.cfgDraft.showCheckin ? 1 : 0,
+        showProgress: this.cfgDraft.showProgress ? 1 : 0,
+        showGroupProgress: this.cfgDraft.showGroupProgress ? 1 : 0,
+        showVote: this.cfgDraft.showVote ? 1 : 0,
+        showMaterial: this.cfgDraft.showMaterial ? 1 : 0,
+        showBanner: this.cfgDraft.showBanner ? 1 : 0,
+        showTimer: this.cfgDraft.showTimer ? 1 : 0,
+        agenda: this.cfgDraft.agenda.map(function (a) {
+          return { title: a.title, minutes: Number(a.minutes) || 10 }
+        }),
+        banner: this.cfgDraft.banner
+      }
+      screenConfigSave(payload).then(function () {
+        self.editConfig = false
+        self.fetchData()
+        self.$message.success('投屏设置已保存')
+      }).catch(function () {})
+    },
+    addAgenda: function () {
+      this.cfgDraft.agenda.push({ title: '', minutes: 10 })
+    },
+    removeAgenda: function (i) {
+      this.cfgDraft.agenda.splice(i, 1)
+    },
     toggleFullscreen: function () {
       if (this.isFullscreen) {
         if (document.exitFullscreen) document.exitFullscreen()
@@ -214,9 +561,24 @@ export default {
       this.editBanner = true
     },
     saveBanner: function () {
-      this.banner = this.bannerDraft
-      localStorage.setItem(BANNER_KEY, this.banner)
-      this.editBanner = false
+      var self = this
+      var payload = { classId: this.classId, banner: this.bannerDraft }
+      var cfg = this.config || {}
+      payload.showScoreboard = cfg.showScoreboard === undefined ? 1 : cfg.showScoreboard
+      payload.showCheckin = cfg.showCheckin === undefined ? 1 : cfg.showCheckin
+      payload.showProgress = cfg.showProgress === undefined ? 1 : cfg.showProgress
+      payload.showGroupProgress = cfg.showGroupProgress === undefined ? 1 : cfg.showGroupProgress
+      payload.showVote = cfg.showVote === undefined ? 1 : cfg.showVote
+      payload.showMaterial = cfg.showMaterial === undefined ? 1 : cfg.showMaterial
+      payload.showBanner = cfg.showBanner === undefined ? 1 : cfg.showBanner
+      payload.showTimer = cfg.showTimer === undefined ? 1 : cfg.showTimer
+      if (cfg.agenda) payload.agenda = cfg.agenda
+      screenConfigSave(payload).then(function () {
+        self.banner = self.bannerDraft
+        self.editBanner = false
+        self.fetchData()
+        self.$message.success('公告已保存')
+      }).catch(function () {})
     },
     goBack: function () {
       this.$router.push('/dashboard')
@@ -495,6 +857,342 @@ export default {
   50% { opacity: 0.35; }
 }
 
+/* 课程进行时间(突出显示) */
+.timer-row {
+  margin-top: 14px;
+  display: flex;
+  justify-content: center;
+}
+.timer-box {
+  display: inline-flex;
+  align-items: center;
+  gap: 18px;
+  background: linear-gradient(90deg, rgba(56, 189, 248, 0.18), rgba(129, 140, 248, 0.18));
+  border: 1px solid rgba(56, 189, 248, 0.45);
+  border-radius: 40px;
+  padding: 10px 32px;
+  box-shadow: 0 0 34px rgba(56, 189, 248, 0.28);
+}
+.timer-box.idle {
+  background: rgba(148, 163, 184, 0.12);
+  border-color: rgba(148, 163, 184, 0.4);
+  box-shadow: none;
+}
+.timer-label {
+  font-size: 22px;
+  font-weight: 600;
+  color: #f8fafc;
+  letter-spacing: 2px;
+}
+.timer-value {
+  font-size: 60px;
+  font-weight: 800;
+  color: #38bdf8;
+  font-variant-numeric: tabular-nums;
+  line-height: 1;
+  text-shadow: 0 0 24px rgba(56, 189, 248, 0.5);
+}
+.timer-box.idle .timer-value,
+.timer-idle {
+  font-size: 30px;
+  color: #94a3b8;
+  font-weight: 600;
+}
+.step-pill {
+  font-size: 22px;
+  font-weight: 600;
+  color: #c4b5fd;
+  background: rgba(167, 139, 250, 0.2);
+  border: 1px solid rgba(167, 139, 250, 0.4);
+  border-radius: 20px;
+  padding: 4px 16px;
+}
+
+/* 环节步骤条 */
+.agenda-bar {
+  display: flex;
+  justify-content: center;
+  gap: 10px;
+  margin-bottom: 16px;
+  flex-shrink: 0;
+}
+.step-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  background: rgba(255, 255, 255, 0.05);
+  border: 1px solid rgba(148, 163, 184, 0.2);
+  border-radius: 20px;
+  padding: 6px 16px;
+  opacity: 0.55;
+}
+.step-item.active {
+  opacity: 1;
+  background: rgba(56, 189, 248, 0.2);
+  border-color: #38bdf8;
+  box-shadow: 0 0 16px rgba(56, 189, 248, 0.3);
+}
+.step-item.done {
+  opacity: 0.85;
+  border-color: rgba(52, 211, 153, 0.5);
+}
+.step-dot {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  background: #475569;
+}
+.step-item.active .step-dot {
+  background: #38bdf8;
+}
+.step-item.done .step-dot {
+  background: #34d399;
+}
+.step-name {
+  font-size: 18px;
+  color: #e2e8f0;
+  white-space: nowrap;
+}
+
+/* 小组进展矩阵 */
+.gp-panel {
+  flex: 1.4;
+}
+.gp-table {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  min-height: 0;
+  overflow-y: auto;
+}
+.gp-head,
+.gp-row {
+  display: grid;
+  grid-template-columns: 1.4fr repeat(4, 1fr);
+  align-items: center;
+  gap: 8px;
+}
+.gp-head {
+  color: #94a3b8;
+  font-size: 16px;
+  padding: 0 8px 4px;
+}
+.gp-cell {
+  text-align: center;
+  font-size: 18px;
+  color: #e2e8f0;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.gp-cell.group {
+  text-align: left;
+  font-weight: 700;
+  color: #f1f5f9;
+}
+.gp-row {
+  background: rgba(30, 58, 95, 0.45);
+  border-radius: 10px;
+  padding: 10px 8px;
+}
+.status-pill {
+  display: inline-block;
+  min-width: 74px;
+  font-size: 16px;
+  padding: 3px 10px;
+  border-radius: 16px;
+  border: 1px solid rgba(148, 163, 184, 0.4);
+  color: #94a3b8;
+}
+.status-pill.ok {
+  color: #34d399;
+  border-color: rgba(52, 211, 153, 0.5);
+  background: rgba(52, 211, 153, 0.12);
+}
+
+/* 底部区域:投票 + 资料 */
+.lower-row {
+  flex-shrink: 0;
+  display: grid;
+  grid-template-columns: 1.4fr 1fr;
+  gap: 24px;
+  margin-top: 20px;
+}
+.vote-panel,
+.material-panel {
+  max-height: 220px;
+  min-height: 0;
+}
+.vote-tabs {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 10px;
+  flex-shrink: 0;
+}
+.vote-tabs.light {
+  margin-bottom: 12px;
+}
+.vote-tabs.light .vote-tab {
+  color: #475569;
+  border-color: #cbd5e1;
+}
+.vote-tabs.light .vote-tab.active {
+  color: #0284c7;
+  border-color: #38bdf8;
+  background: #e0f2fe;
+}
+.vote-tab {
+  font-size: 16px;
+  color: #94a3b8;
+  padding: 3px 12px;
+  border-radius: 14px;
+  border: 1px solid rgba(148, 163, 184, 0.4);
+  cursor: pointer;
+}
+.vote-tab.active {
+  color: #38bdf8;
+  border-color: #38bdf8;
+  background: rgba(56, 189, 248, 0.15);
+}
+.vote-edit-list {
+  max-height: 320px;
+  overflow-y: auto;
+}
+.vote-edit-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 4px;
+  border-bottom: 1px solid rgba(148, 163, 184, 0.15);
+}
+.vote-edit-name {
+  font-size: 16px;
+  color: #e2e8f0;
+}
+.vote-bars {
+  flex: 1;
+  overflow-y: auto;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.v-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+.v-name {
+  width: 130px;
+  font-size: 18px;
+  color: #e2e8f0;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-align: right;
+}
+.v-track {
+  flex: 1;
+  height: 16px;
+  background: rgba(148, 163, 184, 0.2);
+  border-radius: 8px;
+  overflow: hidden;
+}
+.v-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #38bdf8, #a78bfa);
+  border-radius: 8px;
+  transition: width 0.6s ease;
+}
+.v-count {
+  width: 40px;
+  text-align: right;
+  font-size: 20px;
+  font-weight: 700;
+  color: #f8fafc;
+  font-variant-numeric: tabular-nums;
+}
+.material-list {
+  flex: 1;
+  overflow-y: auto;
+  min-height: 0;
+}
+.m-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 20px;
+  padding: 8px 4px;
+  color: #e2e8f0;
+}
+.m-icon {
+  font-size: 20px;
+}
+.m-name {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* 环节设置弹窗 */
+.step-edit-list {
+  max-height: 360px;
+  overflow-y: auto;
+}
+.step-edit-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px 14px;
+  border-radius: 10px;
+  border: 1px solid rgba(148, 163, 184, 0.2);
+  margin-bottom: 8px;
+  cursor: pointer;
+}
+.step-edit-item.active {
+  border-color: #38bdf8;
+  background: rgba(56, 189, 248, 0.1);
+}
+.step-edit-dot {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  background: #475569;
+  color: #e2e8f0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+.step-edit-item.active .step-edit-dot {
+  background: #38bdf8;
+}
+.step-edit-name {
+  flex: 1;
+  font-size: 16px;
+  color: #e2e8f0;
+}
+.step-edit-min {
+  font-size: 14px;
+  color: #94a3b8;
+}
+.agenda-editor {
+  width: 100%;
+}
+.agenda-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 8px;
+}
+.banner-hint {
+  font-size: 13px;
+  color: #94a3b8;
+  margin-bottom: 8px;
+}
+
 /* 控制条 */
 .control-bar {
   position: fixed;
@@ -514,9 +1212,4 @@ export default {
 .back-link {
   color: #93c5fd !important;
 }
-.banner-hint {
-  font-size: 13px;
-  color: #94a3b8;
-  margin-bottom: 8px;
-}
 </style>