瀏覽代碼

Phase9: 课程现场投屏大屏页

liaoxg 2 周之前
父節點
當前提交
47301fb

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

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.common.Result;
 import com.train.entity.*;
 import com.train.mapper.*;
+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;
@@ -47,6 +48,8 @@ public class AdminStatsController {
     private TrainScoreMapper trainScoreMapper;
     @Resource
     private ScoreService scoreService;
+    @Resource
+    private CertService certService;
 
     @Operation(summary = "班级整体统计")
     @PostMapping("/stats/overview")
@@ -129,4 +132,136 @@ public class AdminStatsController {
     private long count(List<?> list) {
         return list == null ? 0 : list.size();
     }
+
+    @Operation(summary = "课程现场投屏大屏数据聚合(积分榜+签到+完课进度)")
+    @PostMapping("/stats/screen")
+    public Result<Map<String, Object>> screen(@RequestBody(required = false) Map<String, Object> body) {
+        Map<String, Object> data = new HashMap<>();
+
+        // 班级列表(大屏端切换班级用)
+        List<Map<String, Object>> classList = new ArrayList<>();
+        List<TrainClass> classes = trainClassMapper.selectList(null);
+        for (TrainClass c : classes) {
+            Map<String, Object> cls = new HashMap<>();
+            cls.put("id", c.getId());
+            cls.put("name", c.getName());
+            classList.add(cls);
+        }
+        data.put("classList", classList);
+
+        // 当前展示班级:body.classId 优先,否则取第一个班
+        TrainClass cur = null;
+        if (body != null && body.get("classId") != null) {
+            cur = trainClassMapper.selectById(Long.valueOf(body.get("classId").toString()));
+        }
+        if (cur == null && !classes.isEmpty()) {
+            cur = classes.get(0);
+        }
+        if (cur == null) {
+            data.put("classInfo", null);
+            data.put("scoreboard", new ArrayList<>());
+            data.put("checkins", new HashMap<>());
+            data.put("progress", new HashMap<>());
+            return Result.success(data);
+        }
+        Map<String, Object> classInfo = new HashMap<>();
+        classInfo.put("id", cur.getId());
+        classInfo.put("name", cur.getName());
+        data.put("classInfo", classInfo);
+
+        // 1 小组积分榜
+        List<Map<String, Object>> board = new ArrayList<>();
+        List<TrainGroup> groups = trainGroupMapper.selectList(
+                new LambdaQueryWrapper<TrainGroup>().eq(TrainGroup::getClassId, cur.getId()));
+        for (TrainGroup g : groups) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("groupId", g.getId());
+            row.put("groupName", g.getName());
+            row.put("slogan", g.getSlogan());
+            List<TrainScore> scores = trainScoreMapper.selectList(
+                    new LambdaQueryWrapper<TrainScore>().eq(TrainScore::getGroupId, g.getId()));
+            row.put("groupTotal", scores.stream().mapToInt(TrainScore::getPoints).sum());
+            row.put("memberTotal", scoreService.groupMemberTotal(g.getId()));
+            Long mc = trainGroupMemberMapper.selectCount(
+                    new LambdaQueryWrapper<TrainGroupMember>().eq(TrainGroupMember::getGroupId, g.getId()));
+            row.put("memberCount", mc == null ? 0 : mc);
+            board.add(row);
+        }
+        board.sort((a, b) -> Integer.compare((int) b.get("groupTotal"), (int) a.get("groupTotal")));
+        data.put("scoreboard", board);
+
+        // 2 签到情况(该班学员是否已装机打卡)
+        List<TrainUser> users = trainUserMapper.selectList(
+                new LambdaQueryWrapper<TrainUser>().eq(TrainUser::getClassId, cur.getId()));
+        List<Long> uids = new ArrayList<>();
+        for (TrainUser u : users) {
+            if (u.getId() != null) {
+                uids.add(u.getId());
+            }
+        }
+        Set<Long> checkedUids = new HashSet<>();
+        if (!uids.isEmpty()) {
+            List<TrainCheckin> checkins = trainCheckinMapper.selectList(
+                    new LambdaQueryWrapper<TrainCheckin>()
+                            .in(TrainCheckin::getUid, uids)
+                            .eq(TrainCheckin::getStatus, "done"));
+            for (TrainCheckin ck : checkins) {
+                checkedUids.add(ck.getUid());
+            }
+        }
+        List<Map<String, Object>> checkedList = new ArrayList<>();
+        List<Map<String, Object>> uncheckedList = new ArrayList<>();
+        for (TrainUser u : users) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("uid", u.getId());
+            row.put("name", u.getName() == null ? "学员" + u.getId() : u.getName());
+            if (checkedUids.contains(u.getId())) {
+                checkedList.add(row);
+            } else {
+                uncheckedList.add(row);
+            }
+        }
+        Map<String, Object> checkins = new HashMap<>();
+        checkins.put("total", users.size());
+        checkins.put("checkedIn", checkedList.size());
+        checkins.put("unchecked", uncheckedList.size());
+        checkins.put("checkedList", checkedList);
+        checkins.put("uncheckedList", uncheckedList);
+        data.put("checkins", checkins);
+
+        // 3 完课进度(逐学员 6 项校验汇总)
+        int eligibleCount = 0;
+        int maxOk = 0;
+        List<Map<String, Object>> rows = new ArrayList<>();
+        for (TrainUser u : users) {
+            List<Map<String, Object>> items = certService.checkItemsOf(u.getId());
+            int ok = 0;
+            for (Map<String, Object> it : items) {
+                if (Boolean.TRUE.equals(it.get("ok"))) {
+                    ok++;
+                }
+            }
+            boolean eligible = ok >= items.size();
+            if (eligible) {
+                eligibleCount++;
+            }
+            if (ok > maxOk) {
+                maxOk = ok;
+            }
+            Map<String, Object> row = new HashMap<>();
+            row.put("uid", u.getId());
+            row.put("name", u.getName() == null ? "学员" + u.getId() : u.getName());
+            row.put("okCount", ok);
+            row.put("eligible", eligible);
+            rows.add(row);
+        }
+        Map<String, Object> progress = new HashMap<>();
+        progress.put("total", users.size());
+        progress.put("eligible", eligibleCount);
+        progress.put("maxOk", maxOk);
+        progress.put("rows", rows);
+        data.put("progress", progress);
+
+        return Result.success(data);
+    }
 }

+ 26 - 16
train-backend/src/main/java/com/train/service/CertService.java

@@ -45,9 +45,33 @@ public class CertService {
      * 返回结构:{eligible, certNo, issuedAt, items:[{key,label,ok}]}
      */
     public Map<String, Object> issue(Long uid) {
-        List<Map<String, Object>> items = new ArrayList<>();
+        List<Map<String, Object>> items = checkItemsOf(uid);
         TrainUser user = trainUserMapper.selectById(uid);
 
+        boolean eligible = items.stream().allMatch(i -> Boolean.TRUE.equals(i.get("ok")));
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("eligible", eligible);
+        result.put("items", items);
+        if (eligible) {
+            Long classId = user != null ? user.getClassId() : null;
+            String certNo = genCertNo(uid, classId);
+            result.put("certNo", certNo);
+            result.put("issuedAt", new Date());
+            ensureAuditLog(uid, "cert_issued", "领取完课证书 " + certNo);
+        } else {
+            result.put("certNo", null);
+        }
+        return result;
+    }
+
+    /**
+     * 离场验收 6 项逐项校验(供证书领取与大屏完课进度复用)。
+     * 返回:[{key,label,ok}]
+     */
+    public List<Map<String, Object>> checkItemsOf(Long uid) {
+        List<Map<String, Object>> items = new ArrayList<>();
+
         // 1 装机打卡:可独立打开 WorkBuddy 完成一次有效提问
         boolean checkinOk = trainCheckinMapper.selectCount(
                 new LambdaQueryWrapper<TrainCheckin>()
@@ -94,21 +118,7 @@ public class CertService {
                 new LambdaQueryWrapper<TrainPlan>().eq(TrainPlan::getUid, uid)) > 0;
         items.add(item("plan", "7天行动计划已提交", planOk));
 
-        boolean eligible = checkinOk && prepOk && assignOk && submitOk && cardOk && roadmapOk && planOk;
-
-        Map<String, Object> result = new HashMap<>();
-        result.put("eligible", eligible);
-        result.put("items", items);
-        if (eligible) {
-            Long classId = user != null ? user.getClassId() : null;
-            String certNo = genCertNo(uid, classId);
-            result.put("certNo", certNo);
-            result.put("issuedAt", new Date());
-            ensureAuditLog(uid, "cert_issued", "领取完课证书 " + certNo);
-        } else {
-            result.put("certNo", null);
-        }
-        return result;
+        return items;
     }
 
     private Map<String, Object> item(String key, String label, boolean ok) {

+ 4 - 0
train-web/src/api/stats.js

@@ -11,3 +11,7 @@ export function statsScoreboard(data) {
 export function statsUserScoreboard(data) {
   return request.post('/api/admin/stats/user-scoreboard', data || {})
 }
+
+export function statsScreen(data) {
+  return request.post('/api/admin/stats/screen', data || {})
+}

+ 1 - 0
train-web/src/config/menu-roles.js

@@ -2,6 +2,7 @@
 // admin 全量 / lecturer 审核评分 / assistant 限负责组(审核与评分)
 const MENU_ROLES = {
   '/dashboard': ['admin', 'lecturer', 'assistant'],
+  '/screen': ['admin', 'lecturer', 'assistant'],
   '/classes': ['admin'],
   '/enrollments': ['admin'],
   '/orders': ['admin'],

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

@@ -12,6 +12,12 @@ const routes = [
     component: () => import('@/views/Login.vue'),
     meta: { title: '登录' }
   },
+  {
+    path: '/screen',
+    name: 'Screen',
+    component: () => import('@/views/Screen.vue'),
+    meta: { title: '课程现场投屏' }
+  },
   {
     path: '/',
     component: Layout,

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

@@ -15,6 +15,10 @@
             <i class="el-icon-s-home"></i>
             <span slot="title">概览</span>
           </el-menu-item>
+          <el-menu-item index="/screen" v-if="canSee('/screen')">
+            <i class="el-icon-video-camera"></i>
+            <span slot="title">现场投屏</span>
+          </el-menu-item>
           <el-menu-item index="/classes" v-if="canSee('/classes')">
             <i class="el-icon-s-grid"></i>
             <span slot="title">班级管理</span>

+ 522 - 0
train-web/src/views/Screen.vue

@@ -0,0 +1,522 @@
+<template>
+  <div class="screen-wrap" @mousemove="showControl = true">
+    <!-- 顶栏 -->
+    <div class="screen-header">
+      <div class="brand">爱伴·AI之旅</div>
+      <div class="title-box">
+        <div class="course-title">{{ classInfo.name || '课程现场' }}</div>
+        <div class="banner-text">{{ banner }}</div>
+      </div>
+      <div class="clock">
+        <div class="time">{{ now }}</div>
+        <div class="date">{{ today }}</div>
+      </div>
+    </div>
+
+    <!-- 主体 -->
+    <div class="screen-body">
+      <!-- 左侧:积分榜 -->
+      <div class="panel scoreboard-panel">
+        <div class="panel-title">
+          <span>小组积分榜</span>
+          <span class="panel-sub">GROUP RANKING</span>
+        </div>
+        <div class="rank-list">
+          <div class="rank-card" v-for="(g, i) in scoreboard" :key="g.groupId" :class="{ 'rank-top': i === 0 }">
+            <div class="rank-no">{{ i + 1 }}</div>
+            <div class="rank-name">
+              <div class="g-name">{{ g.groupName }}</div>
+              <div class="g-slogan">{{ g.slogan }}</div>
+            </div>
+            <div class="rank-score">{{ g.groupTotal }}<span class="unit">分</span></div>
+          </div>
+          <div v-if="scoreboard.length === 0" class="empty">暂无小组数据</div>
+        </div>
+      </div>
+
+      <!-- 右侧:签到 + 完课进度 -->
+      <div class="right-col">
+        <div class="panel checkin-panel">
+          <div class="panel-title">
+            <span>签到情况</span>
+            <span class="stat-badge">{{ checkins.checkedIn || 0 }} / {{ checkins.total || 0 }}</span>
+          </div>
+          <div class="meter">
+            <div class="meter-fill" :style="{ width: checkinPct + '%' }"></div>
+          </div>
+          <div class="scroll-box">
+            <div class="c-item done" v-for="u in checkins.checkedList" :key="u.uid">
+              <span class="dot ok"></span>{{ u.name }}
+            </div>
+            <div class="c-item todo" v-for="u in checkins.uncheckedList" :key="u.uid">
+              <span class="dot no"></span>{{ u.name }}
+            </div>
+            <div v-if="checkins.total === 0" class="empty">暂无学员</div>
+          </div>
+        </div>
+
+        <div class="panel progress-panel">
+          <div class="panel-title">
+            <span>完课进度</span>
+            <span class="stat-badge">{{ progress.eligible || 0 }} / {{ progress.total || 0 }}</span>
+          </div>
+          <div class="meter">
+            <div class="meter-fill green" :style="{ width: progressPct + '%' }"></div>
+          </div>
+          <div class="scroll-box">
+            <div class="c-item done" v-for="u in certList" :key="u.uid">
+              <span class="dot ok"></span>{{ u.name }}<span class="cert-tag">已完课</span>
+            </div>
+            <div class="c-item todo" v-for="u in rows" :key="'r' + u.uid">
+              <span class="dot no"></span>{{ u.name }}<span class="cert-tag gray">{{ u.okCount }}/6</span>
+            </div>
+            <div v-if="progress.total === 0" class="empty">暂无学员</div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 底部状态条 -->
+    <div class="screen-footer">
+      <div class="foot-item">数据每 30 秒自动刷新</div>
+      <div class="foot-item live-dot">LIVE</div>
+    </div>
+
+    <!-- 悬浮控制条(鼠标移动显示) -->
+    <div class="control-bar" v-if="showControl">
+      <el-select v-model="classId" size="small" @change="fetchData" 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="openBannerEditor">编辑公告</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>
+      <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>
+  </div>
+</template>
+
+<script>
+import { statsScreen } from '@/api/stats'
+import { classList } from '@/api/class'
+
+var BANNER_KEY = 'train-screen-banner'
+
+function pad(n) {
+  return n < 10 ? '0' + n : '' + n
+}
+
+export default {
+  name: 'Screen',
+  data: function () {
+    return {
+      classList: [],
+      classId: '',
+      classInfo: {},
+      scoreboard: [],
+      checkins: {},
+      progress: {},
+      now: '',
+      today: '',
+      banner: '',
+      bannerDraft: '',
+      editBanner: false,
+      showControl: false,
+      isFullscreen: false,
+      pollTimer: null,
+      clockTimer: null
+    }
+  },
+  computed: {
+    checkinPct: function () {
+      var total = this.checkins.total || 0
+      return total > 0 ? Math.round(((this.checkins.checkedIn || 0) / total) * 100) : 0
+    },
+    progressPct: function () {
+      var total = this.progress.total || 0
+      return total > 0 ? Math.round(((this.progress.eligible || 0) / total) * 100) : 0
+    },
+    certList: function () {
+      return (this.progress.rows || []).filter(function (u) { return u.eligible })
+    },
+    rows: function () {
+      return (this.progress.rows || []).filter(function (u) { return !u.eligible })
+    }
+  },
+  mounted: function () {
+    this.banner = localStorage.getItem(BANNER_KEY) || ''
+    this.loadClasses()
+    this.fetchData()
+    this.clockTimer = setInterval(this.refreshClock, 1000)
+    this.pollTimer = setInterval(this.fetchData, 30000)
+    this.regFullscreenListener()
+    this.refreshClock()
+  },
+  beforeDestroy: function () {
+    if (this.clockTimer) clearInterval(this.clockTimer)
+    if (this.pollTimer) clearInterval(this.pollTimer)
+  },
+  methods: {
+    loadClasses: function () {
+      var self = this
+      classList().then(function (res) {
+        self.classList = res.data || []
+      }).catch(function () {})
+    },
+    fetchData: function () {
+      var self = this
+      var params = {}
+      if (self.classId) params.classId = self.classId
+      statsScreen(params).then(function (res) {
+        var data = res.data || {}
+        self.classInfo = data.classInfo || {}
+        self.scoreboard = data.scoreboard || []
+        self.checkins = data.checkins || {}
+        self.progress = data.progress || {}
+        var cls = data.classList || []
+        if (cls.length > 0 && !self.classId) {
+          self.classList = cls
+          self.classId = String(cls[0].id)
+        }
+      }).catch(function () {})
+    },
+    refreshClock: function () {
+      var d = new Date()
+      this.now = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
+      var week = ['日', '一', '二', '三', '四', '五', '六']
+      this.today = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
+        ' 星期' + week[d.getDay()]
+    },
+    toggleFullscreen: function () {
+      if (this.isFullscreen) {
+        if (document.exitFullscreen) document.exitFullscreen()
+      } else {
+        var el = document.documentElement
+        if (el.requestFullscreen) el.requestFullscreen()
+      }
+    },
+    regFullscreenListener: function () {
+      var self = this
+      document.addEventListener('fullscreenchange', function () {
+        self.isFullscreen = !!document.fullscreenElement
+      })
+    },
+    openBannerEditor: function () {
+      this.bannerDraft = this.banner
+      this.editBanner = true
+    },
+    saveBanner: function () {
+      this.banner = this.bannerDraft
+      localStorage.setItem(BANNER_KEY, this.banner)
+      this.editBanner = false
+    },
+    goBack: function () {
+      this.$router.push('/dashboard')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.screen-wrap {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: radial-gradient(1200px 600px at 20% -10%, #1e3a5f 0%, #0f172a 55%, #0a0f1e 100%);
+  color: #e2e8f0;
+  display: flex;
+  flex-direction: column;
+  padding: 28px 40px 20px;
+  font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
+  overflow: hidden;
+}
+
+/* 顶栏 */
+.screen-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20px;
+  flex-shrink: 0;
+}
+.brand {
+  font-size: 26px;
+  font-weight: 700;
+  letter-spacing: 2px;
+  background: linear-gradient(90deg, #60a5fa, #a78bfa);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+}
+.title-box {
+  text-align: center;
+}
+.course-title {
+  font-size: 40px;
+  font-weight: 700;
+  color: #f8fafc;
+  letter-spacing: 4px;
+  text-shadow: 0 0 30px rgba(96, 165, 250, 0.35);
+}
+.banner-text {
+  margin-top: 6px;
+  font-size: 20px;
+  color: #93c5fd;
+}
+.clock {
+  text-align: right;
+}
+.clock .time {
+  font-size: 44px;
+  font-weight: 700;
+  color: #ffffff;
+  font-variant-numeric: tabular-nums;
+}
+.clock .date {
+  font-size: 16px;
+  color: #94a3b8;
+  margin-top: 2px;
+}
+
+/* 主体 */
+.screen-body {
+  flex: 1;
+  display: flex;
+  gap: 24px;
+  min-height: 0;
+}
+.panel {
+  background: rgba(255, 255, 255, 0.06);
+  border: 1px solid rgba(148, 163, 184, 0.18);
+  border-radius: 16px;
+  padding: 20px 24px;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.panel-title {
+  font-size: 24px;
+  font-weight: 600;
+  color: #f1f5f9;
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  margin-bottom: 16px;
+  flex-shrink: 0;
+}
+.panel-sub {
+  font-size: 13px;
+  color: #64748b;
+  letter-spacing: 2px;
+}
+.stat-badge {
+  font-size: 26px;
+  color: #38bdf8;
+  font-weight: 700;
+}
+
+/* 积分榜 */
+.scoreboard-panel {
+  flex: 1.4;
+}
+.rank-list {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  min-height: 0;
+  overflow-y: auto;
+}
+.rank-card {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  background: rgba(30, 58, 95, 0.5);
+  border-radius: 12px;
+  padding: 14px 24px;
+  border-left: 4px solid #475569;
+}
+.rank-card.rank-top {
+  background: linear-gradient(90deg, rgba(250, 204, 21, 0.18), rgba(30, 58, 95, 0.5));
+  border-left-color: #facc15;
+}
+.rank-no {
+  font-size: 34px;
+  font-weight: 800;
+  width: 64px;
+  text-align: center;
+  color: #64748b;
+}
+.rank-top .rank-no {
+  color: #facc15;
+}
+.rank-name {
+  flex: 1;
+  min-width: 0;
+}
+.g-name {
+  font-size: 28px;
+  font-weight: 700;
+  color: #f1f5f9;
+}
+.g-slogan {
+  font-size: 14px;
+  color: #94a3b8;
+  margin-top: 2px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.rank-score {
+  font-size: 44px;
+  font-weight: 800;
+  color: #f8fafc;
+  font-variant-numeric: tabular-nums;
+}
+.rank-score .unit {
+  font-size: 18px;
+  color: #94a3b8;
+  font-weight: 400;
+  margin-left: 4px;
+}
+
+/* 右侧列 */
+.right-col {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+  min-width: 0;
+}
+.checkin-panel,
+.progress-panel {
+  flex: 1;
+}
+.meter {
+  height: 14px;
+  background: rgba(148, 163, 184, 0.2);
+  border-radius: 8px;
+  overflow: hidden;
+  margin-bottom: 12px;
+  flex-shrink: 0;
+}
+.meter-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #38bdf8, #818cf8);
+  border-radius: 8px;
+  transition: width 0.6s ease;
+}
+.meter-fill.green {
+  background: linear-gradient(90deg, #34d399, #38bdf8);
+}
+.scroll-box {
+  flex: 1;
+  overflow-y: auto;
+  min-height: 0;
+}
+.c-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 22px;
+  padding: 6px 4px;
+  color: #94a3b8;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.c-item.done {
+  color: #d1fae5;
+}
+.c-item.todo {
+  color: #94a3b8;
+}
+.dot {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.dot.ok {
+  background: #34d399;
+}
+.dot.no {
+  background: #475569;
+}
+.cert-tag {
+  font-size: 13px;
+  color: #34d399;
+  border: 1px solid rgba(52, 211, 153, 0.4);
+  border-radius: 4px;
+  padding: 0 6px;
+  margin-left: 8px;
+  flex-shrink: 0;
+}
+.cert-tag.gray {
+  color: #94a3b8;
+  border-color: rgba(148, 163, 184, 0.4);
+}
+.empty {
+  color: #475569;
+  font-size: 20px;
+  text-align: center;
+  padding: 30px 0;
+}
+
+/* 底部 */
+.screen-footer {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 16px;
+}
+.foot-item {
+  font-size: 14px;
+  color: #64748b;
+}
+.live-dot {
+  color: #34d399;
+  font-weight: 700;
+  letter-spacing: 2px;
+  animation: pulse 1.6s infinite;
+}
+@keyframes pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.35; }
+}
+
+/* 控制条 */
+.control-bar {
+  position: fixed;
+  top: 12px;
+  left: 50%;
+  transform: translateX(-50%);
+  background: rgba(15, 23, 42, 0.9);
+  border: 1px solid rgba(148, 163, 184, 0.3);
+  border-radius: 10px;
+  padding: 8px 12px;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  z-index: 100;
+  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
+}
+.back-link {
+  color: #93c5fd !important;
+}
+.banner-hint {
+  font-size: 13px;
+  color: #94a3b8;
+  margin-bottom: 8px;
+}
+</style>