|
|
@@ -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>
|