diff --git a/.gitignore b/.gitignore index a261ec83..8bc8ca1b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,17 @@ __pycache__/ /TempLate/SVE/ # TempLate 二进制资源 CDN 上传源(仅本地参考,hb.cdn.gvsds.com 与其无关联) -/TempLate/assets - Copy/ \ No newline at end of file +/TempLate/assets - Copy/ + +# Viper 语言官网前端工程(独立 git 仓库) +/ViperWebSite/ +# Viper 官网生产部署产物(TempLate/Viper,由 deploy.ps1 同步) +/TempLate/Viper/ + +# Venom 包管理器官网前端工程(独立 git 仓库) +/VenomWebSite/ +# Venom 官网生产部署产物(TempLate/Venom,由 deploy.ps1 同步) +/TempLate/Venom/ + +# FTP 资源分发目录(二进制素材,仅本地/CDN 分发,不入库) +/TempLate/FTP/ \ No newline at end of file diff --git a/Main.py b/Main.py index f19336c0..1e9a75c9 100644 --- a/Main.py +++ b/Main.py @@ -35,7 +35,8 @@ SQL_IP = _secret_get('database', 'host', fallback='127.0.0.1') WHITE_HOSTS = [ "www.gvsds.com", "gvsds.com", "api.gvsds.com", "127.0.0.1", "localhost", SQL_IP, "gvsds.dev", "yc8z.org", "ycbz.org", "gvsds.org", "gvsds.studio", "114.66.59.25", - "librok.gvsds.com" + "librok.gvsds.com", "rollcall.gvsds.com", + "viper.gvsds.com", "venom.gvsds.com", ] BAN_IPS = [ "www.kanglaohui.com", "www.best-loving.cn", "11.4.5.14", "185.223.152.189" @@ -45,8 +46,11 @@ PIRACY_DIR = Path("TempLate") / "Errors" / "Piracy" PIRACY_LIST = os.listdir(PIRACY_DIR) if PIRACY_DIR.exists() else [] # 静态站点:host -> 站点根目录 +# viper.gvsds.com 走 Django SubdomainView(TempLate/Viper,Vue 官网产物) STATIC_SITES = { "librok.gvsds.com": Path("TempLate") / "rok" / "lib", + "rollcall.gvsds.com": Path("TempLate") / "rollcall", + "venom.gvsds.com": Path("TempLate") / "Venom", } LOG_CONFIG = { @@ -389,8 +393,11 @@ class SecurityMiddleware: start_response('403 Forbidden', [('Content-Type', 'text/plain')]) return [b'Server does not support this type of page.'] - # 静态站点路由:将指定 host 的请求直接映射到本地目录 + # 静态站点路由:将指定 host 的请求直接映射到本地目录; + # /API/ 前缀(如 rollcall 数据持久化接口)转交 Django 处理 if host in STATIC_SITES: + if path.startswith('/API/'): + return self.app(environ, start_response) return self._serve_static_site(environ, start_response, host, path) return self.app(environ, start_response) diff --git a/TODO b/TODO index c55a1552..cbf9cd6f 100644 --- a/TODO +++ b/TODO @@ -1,3 +1,5 @@ # 成员邮箱和 gitea 与 gerp 同步 像阿里云一样的百科,解释 GERP 的各个机制 一套配色系统(主题色和配色方案) +持有产品 +代码,业绩审计 diff --git a/TempLate/index.html b/TempLate/index.html index 5d73a728..247cc906 100644 --- a/TempLate/index.html +++ b/TempLate/index.html @@ -5,7 +5,7 @@ 银河锋锐(GVSDS) - + diff --git a/TempLate/rollcall/.trae/documents/PRD.md b/TempLate/rollcall/.trae/documents/PRD.md new file mode 100644 index 00000000..7b268616 --- /dev/null +++ b/TempLate/rollcall/.trae/documents/PRD.md @@ -0,0 +1,82 @@ +# 随机点名系统 - 产品需求文档 (PRD) + +## 1. 产品概述 +一款面向教师课堂使用的随机点名网页应用,通过 JSON 记录学生信息,提供富有仪式感的随机抽取动画与清晰的课堂展示界面。 +- 解决课堂点名公平性、趣味性与效率问题,目标用户为中小学及高校教师 +- 提升课堂互动氛围,让点名从"负担"变为"期待" + +## 2. 核心功能 + +### 2.2 功能模块 +1. **点名主页**:随机抽取动画区、学生卡片展示、抽取控制、历史记录 +2. **学生管理页**:学生列表、增删改查、批量导入导出 JSON、头像选择 +3. **设置页**:抽取模式配置(单人/多人/分组)、音效开关、主题切换 + +### 2.3 页面详情 +| 页面名称 | 模块名称 | 功能描述 | +|-----------|-------------|---------------------| +| 点名主页 | 抽取动画区 | 全屏卡片滚动/翻转动画,最终定格被抽中学生,伴随高光与粒子效果 | +| 点名主页 | 学生卡片墙 | 网格展示所有学生头像与姓名,被抽中时高亮,已抽中置灰 | +| 点名主页 | 抽取控制 | 开始/暂停按钮、模式切换(单人/多人/不重复模式)、重置 | +| 点名主页 | 历史记录 | 侧栏展示本次课堂已抽中学生列表,支持撤销 | +| 学生管理页 | 学生列表 | 表格展示学生信息,支持搜索筛选、行内编辑、删除 | +| 学生管理页 | 增删改查 | 添加学生弹窗、编辑、批量删除、头像选择器 | +| 学生管理页 | 数据导入导出 | 导出 JSON 文件、导入 JSON 文件、示例数据加载 | +| 设置页 | 抽取模式 | 单人抽取、多人抽取(指定数量)、分组抽取 | +| 设置页 | 个性化 | 音效开关、动画速度、深色/浅色主题 | + +## 3. 核心流程 +教师进入主页 → 点击"开始点名" → 卡片快速滚动动画 → 减速定格到随机学生 → 展示结果并记录 → 可继续抽取或重置 → 课后可导出记录 + +```mermaid +flowchart TD + A["教师打开应用"] --> B{"是否有学生数据?"} + B -->|"否"| C["加载示例数据/导入JSON"] + B -->|"是"| D["进入点名主页"] + C --> D + D --> E["点击开始点名"] + E --> F["卡片滚动动画启动"] + F --> G["动画减速定格"] + G --> H["展示被抽中学生"] + H --> I{"是否继续抽取?"} + I -->|"是"| F + I -->|"否"| J["查看历史记录"] + J --> K["导出/重置"] +``` + +## 4. 用户界面设计 + +### 4.1 设计风格 +- **主色调**:深墨蓝 `#1a2238`(沉稳学术)+ 暖米白 `#f5f0e6`(纸质感)+ 琥珀金 `#e8a838`(高光强调) +- **辅助色**:砖红 `#c84b4b`(警示/删除)、青绿 `#3a8c7a`(成功/已抽中) +- **按钮风格**:圆角矩形带柔和阴影,主按钮琥珀金渐变,次按钮描边样式 +- **字体**:标题用 `Noto Serif SC`(思源宋体,学术质感),正文用 `Noto Sans SC`(思源黑体,清晰易读) +- **布局风格**:卡片式网格 + 顶部导航,课堂展示时主区域占满屏幕 +- **图标风格**:线性图标 + 学生头像使用几何风格 SVG + +### 4.2 页面设计概览 +| 页面名称 | 模块名称 | UI 元素 | +|-----------|-------------|-------------| +| 点名主页 | 抽取动画区 | 深色背景 + 居中大卡片 + 滚动模糊效果 + 定格高光 + 粒子飞溅 | +| 点名主页 | 学生卡片墙 | 网格布局 + 圆形头像 + 姓名 + 悬浮放大 + 抽中高亮 | +| 点名主页 | 控制栏 | 底部固定栏 + 大号开始按钮 + 模式切换标签 | +| 学生管理页 | 学生表格 | 斑马纹表格 + 行内编辑 + 头像缩略图 + 操作按钮 | +| 设置页 | 配置表单 | 卡片分组 + 开关组件 + 滑块 + 单选标签 | + +### 4.3 响应式 +- 桌面优先设计,适配教室投影仪(1920×1080) +- 平板适配(1024×768),移动端基础可用 +- 触摸优化:按钮尺寸 ≥ 48px,支持手势操作 + +## 5. 数据存储 +- 使用 `localStorage` 以 JSON 格式持久化学生数据与课堂记录 +- 支持导出为 `.json` 文件备份 +- 支持导入 `.json` 文件恢复数据 +- 默认提供示例数据(含 SVG 头像) + +## 6. 默认 SVG 头像示例 +提供 12 种几何风格默认头像,基于首字母 + 不同背景色 + 装饰图案自动生成: +- 圆形/六边形/方形底框 +- 渐变色背景(6 种配色方案) +- 居中显示学生姓名首字 +- 可选装饰:星星、书本、铅笔、帽子等教育元素 diff --git a/TempLate/rollcall/_v.py b/TempLate/rollcall/_v.py new file mode 100644 index 00000000..f25c43b8 --- /dev/null +++ b/TempLate/rollcall/_v.py @@ -0,0 +1,16 @@ +import re, subprocess, tempfile, os + +js = open(r'D:\SMANAGE\TempLate\rollcall\script.js', encoding='utf-8').read() +with tempfile.NamedTemporaryFile('w', suffix='.js', delete=False, encoding='utf-8') as f: + f.write(js) + tmp = f.name +r = subprocess.run(['node', '--check', tmp], capture_output=True, text=True) +os.unlink(tmp) +print('script.js 语法:', 'OK' if r.returncode == 0 else r.stderr) + +html = open(r'D:\SMANAGE\TempLate\rollcall\index.html', encoding='utf-8').read() +html_ids = set(re.findall(r'id="([\w-]+)"', html)) +js_ids = set(re.findall(r"\$\('([\w-]+)'\)", js)) +missing = js_ids - html_ids +print('引用但未定义 id:', missing if missing else '无') +print('refreshBtn 存在:', 'refreshBtn' in html_ids) diff --git a/TempLate/rollcall/admin/index.html b/TempLate/rollcall/admin/index.html new file mode 100644 index 00000000..19bdff1f --- /dev/null +++ b/TempLate/rollcall/admin/index.html @@ -0,0 +1,789 @@ + + + + + + 同学点名 · 管理 + + + + + + + +
+
+ +

同学点名

+ 管理控制台 +
+ +
+ 返回点名主页 +
+
+ +
+ +
+
+
+

学生管理

+

添加、编辑学生信息,支持导入导出 JSON 数据

+
+
+ + + + +
+
+ + + +
+ + + + + + + + + + + + +
头像姓名学号分组备注操作
+ +
+
+ + +
+
+
+

题目管理

+

按学科管理题库,支持随机抽取题目用于课堂提问

+
+
+ + + + +
+
+ + + +
+ + + + + + + + + + +
学科题目内容难度操作
+ +
+
+
+ + + + + + + + + + + + + diff --git a/TempLate/rollcall/desktop.ini b/TempLate/rollcall/desktop.ini new file mode 100644 index 00000000..210dd11d --- /dev/null +++ b/TempLate/rollcall/desktop.ini @@ -0,0 +1,2 @@ +[.ShellClassInfo] +LocalizedResourceName=@ͬѧµãÃû,0 diff --git a/TempLate/rollcall/index.html b/TempLate/rollcall/index.html new file mode 100644 index 00000000..8e7e3881 --- /dev/null +++ b/TempLate/rollcall/index.html @@ -0,0 +1,253 @@ + + + + + + 课堂随机点名 · 同学点名 + + + + + + + +
+
+ +

同学点名

+ 课堂随机抽取系统 +
+ +
+ + 0 人 +
+
+ +
+ +
+
+ +
+
+
+
准备就绪
+
点击下方按钮开始点名
+
+
+ + + + + + +
+ + + + + + + + +
+ + +
+ + +
+
+
+ + + +
+
+ + +
+
+ + + +
+ + + + + +
+
+

学生名单

+
+ + + +
+
+
+ +
+ + +
+
+

本次课堂记录

+ +
+
+
尚未抽取
+
+
+ + +
+

设置

+ +
+

抽取行为

+
+
+ 动画速度 + 控制抽取动画的快慢 +
+
+ + 标准 +
+
+
+
+ 音效 + 抽取时播放提示音 +
+
+ +
+
+
+
+ 自动记录历史 + 将每次抽取结果记入课堂记录 +
+
+ +
+
+
+ +
+

数据管理

+
+
+ 管理控制台 + 学生与题目管理已迁移至管理员端 +
+ +
+
+
+ 示例数据 + 加载一组示例学生用于体验 +
+
+ +
+
+
+
+ 清空所有数据 + 删除全部学生与课堂记录(不可恢复) +
+
+ +
+
+
+ +

数据保存在浏览器本地 (localStorage),不会上传服务器。

+
+
+ + + + + + + diff --git a/TempLate/rollcall/script.js b/TempLate/rollcall/script.js new file mode 100644 index 00000000..f999f603 --- /dev/null +++ b/TempLate/rollcall/script.js @@ -0,0 +1,1033 @@ +/* ============================================================ + * 同学点名 - 课堂随机抽取系统 + * 原生 HTML/CSS/JS,localStorage 持久化 + * ============================================================ */ + +(function () { + 'use strict'; + + /* ============ 常量 ============ */ + const STORAGE_KEY = 'tongxue-dianming-data-v1'; + const SETTINGS_KEY = 'tongxue-dianming-settings-v1'; + + /* ============ 默认示例数据 ============ */ + const DEMO_STUDENTS = [ + { id: 's1', name: '张明轩', no: '2024001', note: '班长', avatar: 0, group: '第一组' }, + { id: 's2', name: '李思琪', no: '2024002', note: '学习委员', avatar: 1, group: '第一组' }, + { id: 's3', name: '王浩然', no: '2024003', note: '', avatar: 2, group: '第一组' }, + { id: 's4', name: '陈雨桐', no: '2024004', note: '', avatar: 3, group: '第二组' }, + { id: 's5', name: '刘子涵', no: '2024005', note: '体育委员', avatar: 4, group: '第二组' }, + { id: 's6', name: '杨欣怡', no: '2024006', note: '', avatar: 5, group: '第二组' }, + { id: 's7', name: '黄宇辰', no: '2024007', note: '', avatar: 6, group: '第三组' }, + { id: 's8', name: '赵梓萱', no: '2024008', note: '', avatar: 7, group: '第三组' }, + { id: 's9', name: '周奕辰', no: '2024009', note: '', avatar: 8, group: '第三组' }, + { id: 's10', name: '吴沐瑶', no: '2024010', note: '文艺委员', avatar: 9, group: '第四组' }, + { id: 's11', name: '徐子睿', no: '2024011', note: '', avatar: 10, group: '第四组' }, + { id: 's12', name: '孙若汐', no: '2024012', note: '', avatar: 11, group: '第四组' }, + ]; + + const DEMO_QUESTIONS = [ + { id: 'q1', subject: '语文', content: '请背诵《静夜思》并解释"举头望明月"的意境。', difficulty: '简单' }, + { id: 'q2', subject: '语文', content: '分析《背影》一文中父亲形象的特点及其表现手法。', difficulty: '中等' }, + { id: 'q3', subject: '语文', content: '请举例说明比喻与拟人修辞手法的区别。', difficulty: '简单' }, + { id: 'q4', subject: '数学', content: '已知函数 f(x) = x² - 4x + 3,求其顶点坐标和对称轴。', difficulty: '中等' }, + { id: 'q5', subject: '数学', content: '证明:三角形任意两边之和大于第三边。', difficulty: '困难' }, + { id: 'q6', subject: '数学', content: '计算:sin30° + cos60° - tan45° 的值。', difficulty: '简单' }, + { id: 'q7', subject: '英语', content: 'Please introduce yourself in English within 5 sentences.', difficulty: '简单' }, + { id: 'q8', subject: '英语', content: 'Translate the following sentence: "Knowledge is power."', difficulty: '中等' }, + { id: 'q9', subject: '英语', content: 'Explain the difference between "have been to" and "have gone to".', difficulty: '困难' }, + { id: 'q10', subject: '物理', content: '解释牛顿第一定律的内容,并举例说明惯性现象。', difficulty: '中等' }, + { id: 'q11', subject: '物理', content: '一个物体从高处自由下落,3秒后落地,求高度(g=10m/s²)。', difficulty: '中等' }, + { id: 'q12', subject: '历史', content: '简述丝绸之路的开通及其历史意义。', difficulty: '中等' }, + ]; + + // 学科配色 + const SUBJECT_COLORS = { + '语文': '#c84b4b', '数学': '#3a8c7a', '英语': '#4a7c8c', + '物理': '#6b5b95', '化学': '#d97e4f', '生物': '#88a36e', + '历史': '#b8558c', '地理': '#5c6b73', '政治': '#c9974a', + }; + + /* ============ SVG 头像生成 ============ */ + // 12 种配色方案 + const AVATAR_PALETTES = [ + { bg: '#e8a838', bg2: '#c8861a', fg: '#fff', accent: '#fff' }, // 琥珀金 + { bg: '#3a8c7a', bg2: '#2a6b5c', fg: '#fff', accent: '#f4c668' }, // 青绿 + { bg: '#c84b4b', bg2: '#a83a3a', fg: '#fff', accent: '#f4c668' }, // 砖红 + { bg: '#1a2238', bg2: '#0f1525', fg: '#fff', accent: '#e8a838' }, // 墨蓝 + { bg: '#6b5b95', bg2: '#524675', fg: '#fff', accent: '#f4c668' }, // 紫 + { bg: '#88a36e', bg2: '#6b8455', fg: '#fff', accent: '#fff' }, // 草绿 + { bg: '#d97e4f', bg2: '#b8653a', fg: '#fff', accent: '#fff' }, // 橙 + { bg: '#4a7c8c', bg2: '#355f6c', fg: '#fff', accent: '#f4c668' }, // 钢蓝 + { bg: '#b8558c', bg2: '#944471', fg: '#fff', accent: '#fff' }, // 玫红 + { bg: '#5c6b73', bg2: '#445259', fg: '#fff', accent: '#e8a838' }, // 石灰 + { bg: '#c9974a', bg2: '#a87d35', fg: '#fff', accent: '#1a2238' }, // 麦黄 + { bg: '#3d6b7d', bg2: '#2c5260', fg: '#fff', accent: '#f4c668' }, // 深青 + ]; + + // 装饰图案类型 + const PATTERNS = ['dots', 'stars', 'books', 'pencil', 'waves', 'grid']; + + /** + * 生成 SVG 头像字符串 + * @param {string} name - 学生姓名 + * @param {number} avatarIdx - 头像索引 0-11 + */ + function makeAvatar(name, avatarIdx) { + const idx = ((avatarIdx % 12) + 12) % 12; + const p = AVATAR_PALETTES[idx]; + const pattern = PATTERNS[idx % PATTERNS.length]; + const ch = (name || '?').trim().charAt(0) || '?'; + const gid = 'g' + idx + '_' + Math.random().toString(36).slice(2, 7); + + let patternSvg = ''; + switch (pattern) { + case 'dots': + patternSvg = ` + + + + + `; + break; + case 'stars': + patternSvg = ` + + + + `; + break; + case 'books': + patternSvg = ` + + + + `; + break; + case 'pencil': + patternSvg = ` + + + `; + break; + case 'waves': + patternSvg = ` + + + `; + break; + case 'grid': + patternSvg = ` + + + + + + + `; + break; + } + + return ` + + + + + + + + ${patternSvg} + ${escapeXml(ch)} + `; + } + + function escapeXml(s) { + return String(s).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])); + } + + /* ============ 数据存储 ============ */ + const Store = { + students: [], + questions: [], + history: [], // [{id, studentId, name, time}] + pickedIds: new Set(), // 本次课堂已抽中学生 + pickedQuestionIds: new Set(), // 本次课堂已抽中题目 + + load() { + // 优先从后端 API 读取;失败回退本地缓存(后端持久化,多端/多浏览器共享) + return fetch('/API/RollCall/Data', { headers: { 'Accept': 'application/json' } }) + .then(r => r.json()) + .then(data => { + this.students = Array.isArray(data.students) ? data.students : []; + this.questions = Array.isArray(data.questions) ? data.questions : []; + this.history = Array.isArray(data.history) ? data.history : []; + this.saveLocal(); + }) + .catch(() => { this.loadLocal(); }); + }, + save() { + // 先写本地缓存(立即生效),再异步同步后端 + this.saveLocal(); + fetch('/API/RollCall/Data', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + students: this.students, + questions: this.questions, + history: this.history, + }), + }).catch(e => console.warn('后端保存失败(已存本地缓存)', e)); + }, + loadLocal() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const data = JSON.parse(raw); + this.students = Array.isArray(data.students) ? data.students : []; + this.questions = Array.isArray(data.questions) ? data.questions : []; + this.history = Array.isArray(data.history) ? data.history : []; + } + } catch (e) { console.warn('加载本地缓存失败', e); } + }, + saveLocal() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + students: this.students, + questions: this.questions, + history: this.history, + })); + } catch (e) { console.warn('保存本地缓存失败', e); } + }, + getSettings() { + try { + const raw = localStorage.getItem(SETTINGS_KEY); + if (raw) return Object.assign(defaultSettings(), JSON.parse(raw)); + } catch (e) {} + return defaultSettings(); + }, + saveSettings(s) { + try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch (e) {} + }, + addStudent(s) { + s.id = 's' + Date.now() + Math.random().toString(36).slice(2, 5); + this.students.push(s); + this.save(); + }, + updateStudent(id, patch) { + const s = this.students.find(x => x.id === id); + if (s) { Object.assign(s, patch); this.save(); } + }, + removeStudent(id) { + this.students = this.students.filter(x => x.id !== id); + this.save(); + }, + addQuestion(q) { + q.id = 'q' + Date.now() + Math.random().toString(36).slice(2, 5); + this.questions.push(q); + this.save(); + }, + updateQuestion(id, patch) { + const q = this.questions.find(x => x.id === id); + if (q) { Object.assign(q, patch); this.save(); } + }, + removeQuestion(id) { + this.questions = this.questions.filter(x => x.id !== id); + this.save(); + }, + getGroups() { + const set = new Set(); + this.students.forEach(s => { if (s.group) set.add(s.group); }); + return Array.from(set); + }, + getSubjects() { + const set = new Set(); + this.questions.forEach(q => { if (q.subject) set.add(q.subject); }); + return Array.from(set); + }, + resetPicked() { + this.pickedIds.clear(); + this.pickedQuestionIds.clear(); + this.history = []; + this.save(); + }, + }; + + function defaultSettings() { + return { speed: 3, sound: false, history: true, mode: 'single', multiCount: 3, pickPerson: true, pickQuestion: false, questionNoRepeat: false }; + } + + let settings = Store.getSettings(); + + /* ============ DOM 引用 ============ */ + const $ = (id) => document.getElementById(id); + const els = { + // tabs + tabs: document.querySelectorAll('.tab'), + views: document.querySelectorAll('.view'), + countBadge: $('countBadge'), + refreshBtn: $('refreshBtn'), + // roll + rollerCard: $('rollerCard'), + rollerAvatar: $('rollerAvatar'), + rollerName: $('rollerName'), + rollerTag: $('rollerTag'), + rollBtn: $('rollBtn'), + resetBtn: $('resetBtn'), + modeSwitch: $('modeSwitch'), + multiConfig: $('multiConfig'), + stepVal: $('stepVal'), + stepUp: $('stepUp'), + stepDown: $('stepDown'), + confetti: $('confetti'), + resultToast: $('resultToast'), + resultName: $('resultName'), + groupSelect: $('groupSelect'), + // mode toggles + modeToggles: document.querySelectorAll('.mode-toggle'), + personControls: $('personControls'), + questionControls: $('questionControls'), + // multi results + multiResults: $('multiResults'), + multiResultsCount: $('multiResultsCount'), + multiResultsGrid: $('multiResultsGrid'), + // question result + questionResult: $('questionResult'), + subjectSelect: $('subjectSelect'), + questionDisplay: $('questionDisplay'), + questionNoRepeat: $('questionNoRepeat'), + // wall + wall: $('wall'), + wallEmpty: $('wallEmpty'), + cntAll: $('cntAll'), + cntPending: $('cntPending'), + cntPicked: $('cntPicked'), + loadDemoBtn: $('loadDemoBtn'), + // history + historyList: $('historyList'), + historyEmpty: $('historyEmpty'), + clearHistoryBtn: $('clearHistoryBtn'), + // settings + speedRange: $('speedRange'), + speedVal: $('speedVal'), + soundToggle: $('soundToggle'), + historyToggle: $('historyToggle'), + loadDemoBtn2: $('loadDemoBtn2'), + clearAllBtn: $('clearAllBtn'), + // toast + toast: $('toast'), + }; + + /* ============ 工具 ============ */ + let toastTimer = null; + function toast(msg, type) { + els.toast.textContent = msg; + els.toast.className = 'toast' + (type ? ' ' + type : ''); + els.toast.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { els.toast.hidden = true; }, 2200); + } + + function fmtTime(t) { + const d = new Date(t); + return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0') + ':' + d.getSeconds().toString().padStart(2, '0'); + } + + function uid() { return 's' + Date.now() + Math.random().toString(36).slice(2, 6); } + + /* ============ 视图切换 ============ */ + function switchView(name) { + els.tabs.forEach(t => t.classList.toggle('active', t.dataset.view === name)); + els.views.forEach(v => v.classList.toggle('active', v.id === 'view-' + name)); + if (name === 'settings') renderSettings(); + } + + /* ============ 渲染:分组下拉 ============ */ + function renderGroupSelect() { + const groups = Store.getGroups(); + const current = els.groupSelect.value; + els.groupSelect.innerHTML = '' + + groups.map(g => ``).join(''); + } + + /* ============ 渲染:学科下拉 ============ */ + function renderSubjectSelects() { + const subjects = Store.getSubjects(); + const opts = '' + + subjects.map(s => ``).join(''); + els.subjectSelect.innerHTML = opts; + } + + function subjectColor(sub) { + return SUBJECT_COLORS[sub] || '#5c6b73'; + } + + /* ============ 模式开关 UI 更新 ============ */ + function updateModeToggleUI() { + els.modeToggles.forEach(btn => { + const key = btn.dataset.toggle === 'person' ? 'pickPerson' : 'pickQuestion'; + btn.classList.toggle('active', settings[key]); + }); + els.personControls.hidden = !settings.pickPerson; + els.questionControls.hidden = !settings.pickQuestion; + // 多人数量配置也跟随抽人模式 + if (settings.pickPerson) { + els.multiConfig.hidden = settings.mode !== 'multi'; + } else { + els.multiConfig.hidden = true; + } + // 更新按钮文字 + if (!rolling) { + els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText(); + } + } + + /* ============ 渲染:学生卡片墙 ============ */ + let wallFilter = 'all'; + + function renderWall() { + const list = Store.students; + const pending = list.filter(s => !Store.pickedIds.has(s.id)); + const picked = list.filter(s => Store.pickedIds.has(s.id)); + + els.cntAll.textContent = list.length; + els.cntPending.textContent = pending.length; + els.cntPicked.textContent = picked.length; + els.countBadge.textContent = list.length + ' 人'; + + let show = list; + if (wallFilter === 'pending') show = pending; + else if (wallFilter === 'picked') show = picked; + + els.wall.innerHTML = ''; + if (list.length === 0) { + els.wallEmpty.hidden = false; + els.wallEmpty.querySelector('button').onclick = loadDemo; + return; + } + els.wallEmpty.hidden = true; + + show.forEach(s => { + const card = document.createElement('div'); + card.className = 'wall-card' + (Store.pickedIds.has(s.id) ? ' picked' : ''); + card.dataset.id = s.id; + card.innerHTML = ` +
${makeAvatar(s.name, s.avatar || 0)}
+
${escapeHtml(s.name)}
+
${escapeHtml(s.no || '')}
${s.group ? `
${escapeHtml(s.group)}
` : ''}`; + els.wall.appendChild(card); + }); + } + + function highlightWallCard(id) { + document.querySelectorAll('.wall-card').forEach(c => c.classList.remove('current')); + if (id) { + const card = els.wall.querySelector(`.wall-card[data-id="${id}"]`); + if (card) card.classList.add('current'); + } + } + + /* ============ 渲染:历史记录 ============ */ + function renderHistory() { + const list = Store.history; + if (list.length === 0) { + els.historyList.innerHTML = ''; + els.historyEmpty.hidden = false; + return; + } + els.historyEmpty.hidden = true; + els.historyList.innerHTML = list.map((h, i) => { + const s = Store.students.find(x => x.id === h.studentId) || { avatar: 0 }; + return `
+ ${i + 1} +
${makeAvatar(h.name, s.avatar || 0)}
+ ${escapeHtml(h.name)} + ${escapeHtml(h.no || '')} + ${fmtTime(h.time)} +
`; + }).join(''); + } + + /* ============ 渲染:设置 ============ */ + function renderSettings() { + els.speedRange.value = settings.speed; + els.speedVal.textContent = speedLabel(settings.speed); + els.soundToggle.checked = settings.sound; + els.historyToggle.checked = settings.history; + els.questionNoRepeat.checked = settings.questionNoRepeat; + } + + function speedLabel(v) { + return ['', '极快', '快速', '标准', '舒缓', '慢速'][v] || '标准'; + } + + /* ============ 抽取动画核心 ============ */ + let rolling = false; + let multiResults = []; // 多人模式暂存结果 + + function getCandidates() { + const group = els.groupSelect.value; + let pool = Store.students; + if (group) pool = pool.filter(s => s.group === group); + if (settings.mode === 'norepeat') { + return pool.filter(s => !Store.pickedIds.has(s.id)); + } + return pool; + } + + function getQuestionPool() { + const subj = els.subjectSelect.value; + let pool = Store.questions; + if (subj) pool = pool.filter(q => q.subject === subj); + if (settings.questionNoRepeat) { + pool = pool.filter(q => !Store.pickedQuestionIds.has(q.id)); + } + return pool; + } + + function startRoll() { + if (rolling) return; + const doPerson = settings.pickPerson; + const doQuestion = settings.pickQuestion; + + if (!doPerson && !doQuestion) { + toast('请至少开启一个模式(抽人或抽题)', 'error'); + return; + } + + // 预选学生 + let personResults = []; + if (doPerson) { + const candidates = getCandidates(); + if (candidates.length === 0) { + toast(settings.mode === 'norepeat' ? '所有学生已被抽中,请重置' : '暂无学生数据', 'error'); + return; + } + const count = settings.mode === 'multi' ? Math.min(settings.multiCount, candidates.length) : 1; + const pool = candidates.slice(); + for (let i = 0; i < count; i++) { + personResults.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); + } + } + + // 预选题目 + let questionTarget = null; + if (doQuestion) { + const qPool = getQuestionPool(); + if (qPool.length === 0) { + if (Store.questions.length === 0) { + toast('暂无题目数据', 'error'); + } else if (settings.questionNoRepeat) { + toast('所有题目已被抽完,请重置或关闭不重复', 'error'); + } else { + toast('该学科暂无题目', 'error'); + } + return; + } + questionTarget = qPool[Math.floor(Math.random() * qPool.length)]; + } + + rolling = true; + multiResults = []; + els.rollBtn.disabled = true; + els.rollBtn.querySelector('.btn-text').textContent = '抽取中…'; + els.resultToast.hidden = true; + els.rollerCard.classList.remove('done'); + els.rollerCard.classList.remove('question-mode'); + els.rollerCard.classList.add('spinning'); + // 清空上一次结果(防止缓存残留) + els.multiResults.hidden = true; + els.multiResultsGrid.innerHTML = ''; + els.questionResult.hidden = true; + els.questionDisplay.classList.remove('has-question'); + els.questionDisplay.innerHTML = '抽取中…'; + highlightWallCard(null); + // 清空 roller 卡片内容,避免显示上次结果 + els.rollerAvatar.innerHTML = ''; + els.rollerName.textContent = '抽取中…'; + els.rollerTag.textContent = ''; + + if (doPerson) { + runPersonRoll(personResults, () => { + if (doQuestion) { + // 人物完成后,揭示题目 + setTimeout(() => revealQuestionResult(questionTarget), 500); + } + }); + } else if (doQuestion) { + // 仅抽题:在 roller 上做题目滚动动画 + runQuestionRoll(questionTarget); + } + } + + /* ---- 人物滚动动画 ---- */ + function runPersonRoll(results, onComplete) { + const list = Store.students.length > 0 ? Store.students : results; + const durations = [0, 1600, 2200, 3000, 3800, 4600]; + const total = settings.mode === 'multi' ? durations[settings.speed] + 600 : durations[settings.speed]; + const start = performance.now(); + const minInterval = settings.mode === 'multi' ? 160 : 180; + + function tick(now) { + const elapsed = now - start; + const progress = Math.min(elapsed / total, 1); + const eased = easeOutCubic(progress); + const interval = 50 + (minInterval - 50) * eased; + + if (progress < 1) { + const random = list[Math.floor(Math.random() * list.length)]; + showRollerCard(random); + setTimeout(() => requestAnimationFrame(tick), interval); + } else { + finishPersonRoll(results, onComplete); + } + } + requestAnimationFrame(tick); + } + + function finishPersonRoll(results, onComplete) { + els.rollerCard.classList.remove('spinning'); + els.rollerCard.classList.add('done'); + + if (settings.mode === 'multi') { + multiResults = results.slice(); + els.multiResults.hidden = false; + els.multiResultsCount.textContent = '共 ' + results.length + ' 人'; + els.multiResultsGrid.innerHTML = ''; + revealMulti(0, onComplete); + } else { + const target = results[0]; + showRollerCard(target); + els.resultName.textContent = target.name; + els.resultToast.hidden = false; + highlightWallCard(target.id); + recordPick(target); + fireConfetti(); + playSound(); + setTimeout(() => { els.resultToast.hidden = true; }, 3000); + if (onComplete) { + rolling = false; + els.rollBtn.disabled = false; + els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText(); + onComplete(); + } + } + } + + function revealMulti(i, onComplete) { + if (i >= multiResults.length) { + els.resultName.textContent = multiResults.map(s => s.name).join('、'); + els.resultToast.hidden = false; + fireConfetti(); + playSound(); + setTimeout(() => { els.resultToast.hidden = true; }, 3500); + rolling = false; + els.rollBtn.disabled = false; + els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText(); + if (onComplete) onComplete(); + return; + } + const target = multiResults[i]; + showRollerCard(target); + highlightWallCard(target.id); + recordPick(target); + addMultiResultCard(target, i + 1); + playSound(); + fireConfetti(true); + setTimeout(() => revealMulti(i + 1, onComplete), 900); + } + + /* ---- 题目滚动动画 ---- */ + function runQuestionRoll(target) { + els.rollerCard.classList.add('question-mode'); + const list = Store.questions; + const durations = [0, 1400, 2000, 2800, 3600, 4400]; + const total = durations[settings.speed]; + const start = performance.now(); + + function tick(now) { + const elapsed = now - start; + const progress = Math.min(elapsed / total, 1); + const eased = easeOutCubic(progress); + const interval = 60 + (200 - 60) * eased; + + if (progress < 1) { + const random = list[Math.floor(Math.random() * list.length)]; + showRollerQuestion(random); + setTimeout(() => requestAnimationFrame(tick), interval); + } else { + finishQuestionRoll(target); + } + } + requestAnimationFrame(tick); + } + + function finishQuestionRoll(target) { + els.rollerCard.classList.remove('spinning'); + els.rollerCard.classList.add('done'); + showRollerQuestion(target); + fireConfetti(); + playSound(); + revealQuestionResult(target); + rolling = false; + els.rollBtn.disabled = false; + els.rollBtn.querySelector('.btn-text').textContent = getRollBtnText(); + } + + function revealQuestionResult(question) { + // 记录已抽中题目(不重复模式) + if (settings.questionNoRepeat) { + Store.pickedQuestionIds.add(question.id); + } + els.questionResult.hidden = false; + els.questionDisplay.classList.add('has-question'); + // 重置动画 + els.questionDisplay.style.animation = 'none'; + void els.questionDisplay.offsetHeight; + els.questionDisplay.style.animation = ''; + els.questionDisplay.innerHTML = ` +
+
+ ${escapeHtml(question.subject)} + ${escapeHtml(question.difficulty || '中等')} +
+
${escapeHtml(question.content)}
+
`; + playSound(); + } + + function showRollerCard(student) { + els.rollerCard.classList.remove('question-mode'); + els.rollerAvatar.innerHTML = makeAvatar(student.name, student.avatar || 0); + els.rollerName.textContent = student.name; + els.rollerTag.textContent = student.no || ''; + } + + function showRollerQuestion(question) { + els.rollerCard.classList.add('question-mode'); + els.rollerAvatar.innerHTML = `
${escapeHtml((question.subject || '?').charAt(0))}
`; + els.rollerName.textContent = question.subject || '题目'; + const content = question.content || ''; + els.rollerTag.textContent = content.length > 30 ? content.slice(0, 30) + '…' : content; + } + + function getRollBtnText() { + const p = settings.pickPerson, q = settings.pickQuestion; + if (p && q) return '开始抽取'; + if (p) return '开始点名'; + if (q) return '开始抽题'; + return '开始'; + } + + function addMultiResultCard(student, idx) { + const card = document.createElement('div'); + card.className = 'multi-result-card'; + card.innerHTML = ` + ${idx} +
${makeAvatar(student.name, student.avatar || 0)}
+
${escapeHtml(student.name)}
+
${escapeHtml(student.no || '')}
`; + els.multiResultsGrid.appendChild(card); + } + + function recordPick(student) { + if (settings.mode === 'norepeat') { + Store.pickedIds.add(student.id); + } + if (settings.history) { + Store.history.push({ id: uid(), studentId: student.id, name: student.name, no: student.no, time: Date.now() }); + Store.save(); + } + renderWall(); + renderHistory(); + } + + function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } + + /* ============ 粒子效果 ============ */ + const confettiCtx = els.confetti.getContext('2d'); + let confettiParticles = []; + let confettiRaf = null; + + function resizeConfetti() { + const r = els.confetti.getBoundingClientRect(); + els.confetti.width = r.width * devicePixelRatio; + els.confetti.height = r.height * devicePixelRatio; + confettiCtx.scale(devicePixelRatio, devicePixelRatio); + } + + function fireConfetti(small) { + resizeConfetti(); + const r = els.confetti.getBoundingClientRect(); + const cx = r.width / 2; + const cy = r.height / 2; + const colors = ['#e8a838', '#f4c668', '#3a8c7a', '#c84b4b', '#fff']; + const count = small ? 40 : 80; + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5; + const speed = 3 + Math.random() * 6; + confettiParticles.push({ + x: cx, y: cy, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 2, + size: 4 + Math.random() * 6, + color: colors[Math.floor(Math.random() * colors.length)], + rot: Math.random() * Math.PI, + vr: (Math.random() - 0.5) * 0.3, + life: 1, + }); + } + if (!confettiRaf) animateConfetti(); + } + + function animateConfetti() { + const r = els.confetti.getBoundingClientRect(); + confettiCtx.clearRect(0, 0, r.width, r.height); + confettiParticles = confettiParticles.filter(p => p.life > 0); + confettiParticles.forEach(p => { + p.x += p.vx; + p.y += p.vy; + p.vy += 0.18; // 重力 + p.vx *= 0.99; + p.rot += p.vr; + p.life -= 0.012; + confettiCtx.save(); + confettiCtx.translate(p.x, p.y); + confettiCtx.rotate(p.rot); + confettiCtx.globalAlpha = Math.max(p.life, 0); + confettiCtx.fillStyle = p.color; + confettiCtx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6); + confettiCtx.restore(); + }); + if (confettiParticles.length > 0) { + confettiRaf = requestAnimationFrame(animateConfetti); + } else { + confettiRaf = null; + confettiCtx.clearRect(0, 0, r.width, r.height); + } + } + + /* ============ 音效(Web Audio 合成) ============ */ + let audioCtx = null; + function playSound() { + if (!settings.sound) return; + try { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const t = audioCtx.currentTime; + // 两个短音 + [880, 1320].forEach((freq, i) => { + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0, t + i * 0.12); + gain.gain.linearRampToValueAtTime(0.15, t + i * 0.12 + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, t + i * 0.12 + 0.25); + osc.connect(gain).connect(audioCtx.destination); + osc.start(t + i * 0.12); + osc.stop(t + i * 0.12 + 0.3); + }); + } catch (e) {} + } + + /* ============ 示例数据 / 清空 ============ */ + function loadDemo() { + Store.students = DEMO_STUDENTS.map(s => ({ ...s })); + Store.questions = DEMO_QUESTIONS.map(q => ({ ...q })); + Store.history = []; + Store.pickedIds.clear(); + Store.save(); + renderWall(); + renderHistory(); + renderGroupSelect(); + renderSubjectSelects(); + toast('已加载示例数据', 'success'); + } + + function clearAll() { + if (!confirm('确定清空所有学生、题目数据与课堂记录?此操作不可恢复。')) return; + Store.students = []; + Store.questions = []; + Store.history = []; + Store.pickedIds.clear(); + Store.save(); + renderWall(); + renderHistory(); + renderGroupSelect(); + renderSubjectSelects(); + toast('已清空', 'success'); + } + + /* ============ 工具:HTML 转义 ============ */ + function escapeHtml(s) { + return String(s).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])); + } + + /* ============ 事件绑定 ============ */ + function bindEvents() { + // 标签切换 + els.tabs.forEach(t => t.addEventListener('click', () => switchView(t.dataset.view))); + + // 模式切换 + els.modeSwitch.querySelectorAll('.mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + els.modeSwitch.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + settings.mode = btn.dataset.mode; + els.multiConfig.hidden = !(settings.pickPerson && settings.mode === 'multi'); + Store.saveSettings(settings); + }); + }); + + // 多人数量 + els.stepUp.addEventListener('click', () => { + settings.multiCount = Math.min(settings.multiCount + 1, 20); + els.stepVal.textContent = settings.multiCount; + Store.saveSettings(settings); + }); + els.stepDown.addEventListener('click', () => { + settings.multiCount = Math.max(settings.multiCount - 1, 1); + els.stepVal.textContent = settings.multiCount; + Store.saveSettings(settings); + }); + + // 抽取 / 重置 + els.rollBtn.addEventListener('click', startRoll); + els.resetBtn.addEventListener('click', () => { + if (rolling) return; + Store.resetPicked(); + els.rollerCard.classList.remove('done'); + els.rollerCard.classList.remove('question-mode'); + els.rollerAvatar.innerHTML = ''; + els.rollerName.textContent = '准备就绪'; + els.rollerTag.textContent = '点击下方按钮开始'; + els.resultToast.hidden = true; + els.multiResults.hidden = true; + els.multiResultsGrid.innerHTML = ''; + els.questionResult.hidden = true; + els.questionDisplay.classList.remove('has-question'); + els.questionDisplay.innerHTML = '点击下方按钮开始抽题'; + highlightWallCard(null); + renderWall(); + renderHistory(); + toast('已重置', 'success'); + }); + + // 分组筛选 + els.groupSelect.addEventListener('change', renderWall); + + // 模式开关(抽人/抽题) + els.modeToggles.forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.toggle === 'person' ? 'pickPerson' : 'pickQuestion'; + settings[key] = !settings[key]; + // 至少保留一个 + if (!settings.pickPerson && !settings.pickQuestion) { + settings[key] = true; + toast('至少需要开启一个模式', 'error'); + return; + } + Store.saveSettings(settings); + updateModeToggleUI(); + }); + }); + + // 题目不重复开关 + els.questionNoRepeat.addEventListener('change', () => { + settings.questionNoRepeat = els.questionNoRepeat.checked; + Store.saveSettings(settings); + }); + + // 卡片墙筛选 + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + wallFilter = btn.dataset.filter; + renderWall(); + }); + }); + + // 历史清空 + els.clearHistoryBtn.addEventListener('click', () => { + Store.history = []; + Store.pickedIds.clear(); + Store.save(); + renderWall(); + renderHistory(); + toast('已清空记录', 'success'); + }); + + // 刷新:运行时从服务器重拉学生名单与题库(教师在管理端改动后即时同步) + els.refreshBtn.addEventListener('click', () => { + if (rolling) { toast('抽取进行中,请稍后再试', 'error'); return; } + Store.load().then(() => { + renderWall(); + renderHistory(); + renderGroupSelect(); + renderSubjectSelects(); + updateModeToggleUI(); + toast('已刷新学生名单与题库', 'success'); + }); + }); + + // 加载示例 + els.loadDemoBtn.addEventListener('click', loadDemo); + + // 设置 + els.speedRange.addEventListener('input', () => { + settings.speed = parseInt(els.speedRange.value); + els.speedVal.textContent = speedLabel(settings.speed); + Store.saveSettings(settings); + }); + els.soundToggle.addEventListener('change', () => { + settings.sound = els.soundToggle.checked; + Store.saveSettings(settings); + }); + els.historyToggle.addEventListener('change', () => { + settings.history = els.historyToggle.checked; + Store.saveSettings(settings); + }); + els.loadDemoBtn2.addEventListener('click', loadDemo); + els.clearAllBtn.addEventListener('click', clearAll); + + // 键盘快捷键 + document.addEventListener('keydown', (e) => { + if (e.key === ' ' && document.querySelector('.view-roll.active') && !isInputFocused()) { + e.preventDefault(); + startRoll(); + } + }); + + window.addEventListener('resize', () => { if (confettiParticles.length) resizeConfetti(); }); + } + + function isInputFocused() { + const el = document.activeElement; + return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'); + } + + /* ============ 初始化 ============ */ + function init() { + // 数据改为后端 API 加载(失败回退本地缓存),加载完成后再渲染 + Store.load().then(() => { + // 恢复设置到 UI + els.modeSwitch.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === settings.mode)); + els.multiConfig.hidden = !(settings.pickPerson && settings.mode === 'multi'); + els.stepVal.textContent = settings.multiCount; + els.speedRange.value = settings.speed; + els.speedVal.textContent = speedLabel(settings.speed); + els.soundToggle.checked = settings.sound; + els.historyToggle.checked = settings.history; + els.questionNoRepeat.checked = settings.questionNoRepeat; + + bindEvents(); + renderWall(); + renderHistory(); + renderGroupSelect(); + renderSubjectSelects(); + updateModeToggleUI(); + }); + } + + document.addEventListener('DOMContentLoaded', init); +})(); diff --git a/TempLate/rollcall/style.css b/TempLate/rollcall/style.css new file mode 100644 index 00000000..05526b34 --- /dev/null +++ b/TempLate/rollcall/style.css @@ -0,0 +1,1053 @@ +/* ============ 设计变量 ============ */ +:root { + --ink: #1a2238; + --ink-soft: #2c3654; + --ink-deep: #0f1525; + --paper: #f5f0e6; + --paper-2: #efe8d8; + --paper-3: #e8e0cc; + --card: #fffdf8; + --card-2: #fbf6ea; + --gold: #e8a838; + --gold-light: #f4c668; + --gold-deep: #c8861a; + --gold-glow: rgba(232, 168, 56, 0.35); + --red: #c84b4b; + --red-soft: #fce8e8; + --green: #3a8c7a; + --green-soft: #e6f4ea; + --muted: #8a8275; + --muted-2: #b5ac9c; + --border: #e2dac8; + --border-soft: #efe8d8; + --shadow-xs: 0 1px 3px rgba(26, 34, 56, 0.06); + --shadow-sm: 0 4px 14px rgba(26, 34, 56, 0.08); + --shadow: 0 8px 30px rgba(26, 34, 56, 0.12); + --shadow-lg: 0 20px 60px rgba(26, 34, 56, 0.22); + --shadow-gold: 0 8px 28px rgba(232, 168, 56, 0.4); + --radius: 14px; + --radius-sm: 10px; + --radius-lg: 20px; + --serif: "Noto Serif SC", "Songti SC", serif; + --sans: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { height: 100%; } + +body { + font-family: var(--sans); + background: var(--paper); + background-image: + radial-gradient(circle at 12% 8%, rgba(232, 168, 56, 0.12), transparent 40%), + radial-gradient(circle at 88% 92%, rgba(58, 140, 122, 0.10), transparent 42%), + radial-gradient(circle at 50% 50%, rgba(232, 168, 56, 0.04), transparent 60%); + background-attachment: fixed; + color: var(--ink); + min-height: 100vh; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* ============ 顶部导航 ============ */ +.topbar { + display: flex; + align-items: center; + gap: 28px; + padding: 14px 32px; + background: rgba(255, 253, 248, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + box-shadow: 0 2px 20px rgba(26, 34, 56, 0.06); + position: sticky; + top: 0; + z-index: 50; +} + +.brand { display: flex; align-items: center; gap: 12px; } +.brand-mark { display: flex; align-items: center; filter: drop-shadow(0 2px 6px rgba(232, 168, 56, 0.3)); } +.brand-title { + font-family: var(--serif); + font-size: 22px; + font-weight: 900; + letter-spacing: 1px; + color: var(--ink); +} +.brand-sub { + font-size: 12px; + color: var(--muted); + padding-left: 12px; + margin-left: 4px; + border-left: 1px solid var(--border); + letter-spacing: 0.5px; +} + +.tabs { display: flex; gap: 4px; margin-left: 12px; } +.tab { + border: none; + background: transparent; + font-family: var(--sans); + font-size: 15px; + font-weight: 500; + color: var(--muted); + padding: 9px 18px; + border-radius: 999px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} +.tab:hover { color: var(--ink); background: var(--paper-2); } +.tab.active { color: var(--card); background: var(--ink); box-shadow: 0 4px 12px rgba(26, 34, 56, 0.25); } + +.topbar-right { margin-left: auto; } +.count-badge { + font-size: 13px; + font-weight: 700; + color: var(--ink); + background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 100%); + padding: 6px 14px; + border-radius: 999px; + box-shadow: 0 4px 12px rgba(232, 168, 56, 0.3); +} + +/* ============ 主区域 ============ */ +.app { max-width: 1280px; margin: 0 auto; padding: 28px 32px 80px; } + +.view { display: none; animation: fadeIn 0.35s ease; } +.view.active { display: block; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + +/* ============ 点名舞台 ============ */ +.roll-stage { + position: relative; + background: linear-gradient(160deg, var(--ink) 0%, var(--ink-soft) 60%, var(--ink-deep) 100%); + border-radius: var(--radius-lg); + padding: 60px 32px; + overflow: hidden; + box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255,255,255,0.08); + min-height: 360px; + display: flex; + align-items: center; + justify-content: center; +} +.roll-stage::before { + content: ""; + position: absolute; + inset: 0; + background-image: + radial-gradient(circle at 20% 30%, rgba(232, 168, 56, 0.22), transparent 35%), + radial-gradient(circle at 80% 70%, rgba(58, 140, 122, 0.18), transparent 38%), + radial-gradient(circle at 50% 100%, rgba(232, 168, 56, 0.12), transparent 50%); + pointer-events: none; +} +.roll-stage::after { + content: ""; + position: absolute; + inset: 0; + background-image: + repeating-linear-gradient(45deg, transparent 0 28px, rgba(255,255,255,0.022) 28px 29px), + repeating-linear-gradient(-45deg, transparent 0 56px, rgba(232,168,56,0.015) 56px 57px); + pointer-events: none; +} + +.roller { position: relative; z-index: 2; text-align: center; } +.roller-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + transition: transform 0.15s; +} +.roller-card.spinning { animation: cardShake 0.12s linear infinite; } +@keyframes cardShake { + 0% { transform: translateX(-2px); } + 50% { transform: translateX(2px); } + 100% { transform: translateX(-2px); } +} + +.roller-avatar { + width: 136px; + height: 136px; + border-radius: 30px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: 0 16px 50px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.1); + border: 3px solid rgba(255,255,255,0.18); + position: relative; +} +.roller-avatar svg { width: 100%; height: 100%; display: block; } + +.roller-name { + font-family: var(--serif); + font-size: 46px; + font-weight: 900; + color: #fff; + letter-spacing: 2px; + text-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 40px rgba(232,168,56,0.2); + min-height: 60px; +} +.roller-tag { + font-size: 15px; + color: rgba(255,255,255,0.65); + letter-spacing: 1px; +} + +.roller-card.done .roller-name { + color: var(--gold-light); + animation: namePop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); + text-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 60px rgba(232,168,56,0.6); +} +@keyframes namePop { + 0% { transform: scale(0.6); opacity: 0; } + 60% { transform: scale(1.12); } + 100% { transform: scale(1); opacity: 1; } +} +.roller-card.done .roller-avatar { + animation: avatarGlow 0.8s ease; + border-color: var(--gold); + box-shadow: 0 0 60px rgba(232, 168, 56, 0.7), 0 16px 50px rgba(0,0,0,0.45); +} +@keyframes avatarGlow { + 0% { transform: scale(0.8) rotate(-8deg); } + 50% { transform: scale(1.1) rotate(4deg); } + 100% { transform: scale(1) rotate(0); } +} + +/* 题目模式下的 roller 卡片 */ +.roller-card.question-mode .roller-name { + font-size: 36px; +} +.roller-card.question-mode .roller-tag { + font-size: 16px; + max-width: 600px; + line-height: 1.6; + color: rgba(255,255,255,0.85); +} +.roller-card.question-mode.done .roller-name { + color: var(--gold-light); +} + +.confetti { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 3; +} + +.result-toast { + position: absolute; + top: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 4; + animation: toastIn 0.5s ease; +} +.result-toast[hidden] { display: none; } +@keyframes toastIn { from { opacity: 0; transform: translate(-50%, -16px); } to { opacity: 1; transform: translate(-50%, 0); } } +.result-toast-inner { + background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 100%); + color: var(--ink); + padding: 9px 24px; + border-radius: 999px; + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 10px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.4); +} +.result-label { opacity: 0.85; } +.result-name { font-family: var(--serif); font-size: 17px; } + +/* ============ 控制栏 ============ */ +.controls { + display: flex; + align-items: center; + gap: 16px; + margin-top: 24px; + flex-wrap: wrap; +} +.mode-switch { + display: flex; + background: var(--card); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px; + box-shadow: var(--shadow-xs); +} +.mode-btn { + border: none; + background: transparent; + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + color: var(--muted); + padding: 8px 18px; + border-radius: 999px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} +.mode-btn:hover { color: var(--ink); } +.mode-btn.active { color: var(--card); background: var(--ink); box-shadow: 0 4px 10px rgba(26, 34, 56, 0.2); } + +.multi-config { + display: flex; + align-items: center; + gap: 12px; + margin-top: 14px; + color: var(--muted); + font-size: 14px; +} +.multi-config[hidden] { display: none; } +.stepper { display: flex; align-items: center; gap: 10px; } +.step-btn { + width: 32px; height: 32px; + border: 1px solid var(--border); + background: var(--card); + border-radius: 8px; + font-size: 18px; + font-weight: 700; + color: var(--ink); + cursor: pointer; + transition: all 0.15s; +} +.step-btn:hover { background: var(--ink); color: var(--card); border-color: var(--ink); transform: translateY(-1px); } +.step-btn:active { transform: translateY(0); } +.step-val { min-width: 28px; text-align: center; font-weight: 700; font-size: 16px; color: var(--ink); font-variant-numeric: tabular-nums; } + +/* ============ 按钮 ============ */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--border); + background: var(--card); + font-family: var(--sans); + font-size: 14px; + font-weight: 600; + color: var(--ink); + padding: 10px 20px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + position: relative; + overflow: hidden; +} +.btn:hover { transform: translateY(-2px); box-shadow: var(--shadow); } +.btn:active { transform: translateY(0); box-shadow: var(--shadow-xs); } +.btn-ghost { background: transparent; } +.btn-ghost:hover { background: var(--card); } +.btn-primary { + background: linear-gradient(135deg, var(--gold) 0%, var(--gold-deep) 100%); + color: #fff; + border-color: transparent; + box-shadow: var(--shadow-gold); +} +.btn-primary:hover { box-shadow: 0 12px 32px rgba(232, 168, 56, 0.55); } +.btn-danger { color: var(--red); border-color: rgba(200, 75, 75, 0.3); } +.btn-danger:hover { background: var(--red); color: #fff; border-color: var(--red); box-shadow: 0 8px 20px rgba(200, 75, 75, 0.35); } +.btn-lg { font-size: 16px; padding: 15px 36px; border-radius: 12px; } +.btn-sm { font-size: 13px; padding: 7px 14px; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } + +.btn-icon { display: flex; } + +/* ============ 学生卡片墙 ============ */ +.wall-section, .history-section { margin-top: 36px; } +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; + flex-wrap: wrap; + gap: 12px; +} +.section-title { + font-family: var(--serif); + font-size: 22px; + font-weight: 700; + color: var(--ink); + position: relative; + padding-left: 14px; +} +.section-title::before { + content: ""; + position: absolute; + left: 0; top: 50%; + transform: translateY(-50%); + width: 4px; height: 20px; + background: var(--gold); + border-radius: 2px; +} +.section-desc { font-size: 13px; color: var(--muted); margin-top: 4px; } + +.wall-filter { display: flex; gap: 6px; } +.filter-btn { + border: 1px solid var(--border); + background: var(--card); + font-family: var(--sans); + font-size: 13px; + color: var(--muted); + padding: 6px 14px; + border-radius: 999px; + cursor: pointer; + transition: all 0.15s; +} +.filter-btn span { font-weight: 700; color: var(--ink); margin-left: 4px; } +.filter-btn:hover { border-color: var(--gold); transform: translateY(-1px); } +.filter-btn.active { background: var(--ink); color: #fff; border-color: var(--ink); box-shadow: 0 4px 10px rgba(26, 34, 56, 0.2); } +.filter-btn.active span { color: var(--gold-light); } + +.wall { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 14px; +} +.wall-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 10px 12px; + text-align: center; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: default; + position: relative; + overflow: hidden; +} +.wall-card::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(135deg, transparent 0%, rgba(232,168,56,0.06) 100%); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} +.wall-card:hover { transform: translateY(-4px); box-shadow: var(--shadow); border-color: var(--gold); } +.wall-card:hover::before { opacity: 1; } +.wall-card.picked { opacity: 0.55; } +.wall-card.picked::after { + content: "已抽"; + position: absolute; + top: 8px; right: 8px; + font-size: 10px; + font-weight: 700; + color: #fff; + background: var(--green); + padding: 2px 8px; + border-radius: 999px; + box-shadow: 0 2px 6px rgba(58, 140, 122, 0.4); +} +.wall-card.current { + border-color: var(--gold); + box-shadow: 0 0 0 3px rgba(232, 168, 56, 0.3), var(--shadow); + transform: translateY(-4px) scale(1.04); +} +.wall-card.current::before { opacity: 1; } +.wall-avatar { width: 56px; height: 56px; margin: 0 auto 8px; border-radius: 14px; overflow: hidden; box-shadow: var(--shadow-xs); } +.wall-avatar svg { width: 100%; height: 100%; display: block; } +.wall-name { font-size: 14px; font-weight: 600; color: var(--ink); } +.wall-no { font-size: 11px; color: var(--muted); margin-top: 2px; } +.wall-group { font-size: 10px; color: var(--gold-deep); margin-top: 3px; background: rgba(232,168,56,0.12); padding: 1px 8px; border-radius: 999px; display: inline-block; } + +.empty-state { + text-align: center; + padding: 48px 20px; + color: var(--muted); +} +.empty-state p { margin-bottom: 16px; font-size: 15px; } + +/* ============ 历史记录 ============ */ +.history-list { display: flex; flex-direction: column; gap: 8px; } +.history-item { + display: flex; + align-items: center; + gap: 14px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 16px; + animation: slideIn 0.3s ease; + transition: all 0.15s; +} +.history-item:hover { border-color: var(--gold); box-shadow: var(--shadow-xs); } +@keyframes slideIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: none; } } +.history-idx { + font-family: var(--serif); + font-size: 18px; + font-weight: 900; + color: var(--gold); + min-width: 32px; +} +.history-avatar { width: 36px; height: 36px; border-radius: 10px; overflow: hidden; flex-shrink: 0; box-shadow: var(--shadow-xs); } +.history-avatar svg { width: 100%; height: 100%; } +.history-name { font-weight: 600; color: var(--ink); } +.history-no { font-size: 12px; color: var(--muted); margin-left: 8px; } +.history-time { margin-left: auto; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } +.history-empty { text-align: center; color: var(--muted); padding: 24px; font-size: 14px; } +.history-empty[hidden] { display: none; } + +/* ============ 学生管理 ============ */ +.manage-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; margin-bottom: 24px; flex-wrap: wrap; } +.manage-actions { display: flex; gap: 10px; flex-wrap: wrap; } + +.search-bar { + display: flex; + align-items: center; + gap: 10px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 999px; + padding: 10px 18px; + margin-bottom: 18px; + color: var(--muted); +} +.search-bar:focus-within { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(232,168,56,0.15); } +.search-bar input { border: none; outline: none; background: transparent; font-family: var(--sans); font-size: 14px; color: var(--ink); flex: 1; } + +.table-wrap { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); } +.student-table { width: 100%; border-collapse: collapse; } +.student-table th { + text-align: left; + font-size: 13px; + font-weight: 700; + color: var(--muted); + padding: 14px 18px; + background: var(--paper-2); + border-bottom: 1px solid var(--border); + letter-spacing: 0.5px; +} +.student-table td { padding: 12px 18px; border-bottom: 1px solid var(--border-soft); font-size: 14px; color: var(--ink); } +.student-table tbody tr:last-child td { border-bottom: none; } +.student-table tbody tr { transition: background 0.15s; } +.student-table tbody tr:hover { background: var(--paper); } +.row-avatar { width: 40px; height: 40px; border-radius: 10px; overflow: hidden; box-shadow: var(--shadow-xs); } +.row-avatar svg { width: 100%; height: 100%; } +.row-actions { display: flex; gap: 8px; } +.icon-btn { + width: 32px; height: 32px; + border: 1px solid var(--border); + background: var(--card); + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + transition: all 0.15s; +} +.icon-btn:hover { color: var(--ink); border-color: var(--ink); transform: translateY(-1px); } +.icon-btn.danger:hover { color: #fff; background: var(--red); border-color: var(--red); } + +/* ============ 设置 ============ */ +.settings-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 24px; + margin-bottom: 18px; + max-width: 720px; + box-shadow: var(--shadow-sm); +} +.settings-group-title { font-family: var(--serif); font-size: 17px; font-weight: 700; padding: 18px 0 8px; border-bottom: 1px solid var(--border); } +.setting-row { display: flex; align-items: center; justify-content: space-between; padding: 18px 0; border-bottom: 1px solid var(--border); gap: 20px; } +.setting-row:last-child { border-bottom: none; } +.setting-name { font-size: 15px; font-weight: 600; color: var(--ink); display: block; } +.setting-desc { font-size: 13px; color: var(--muted); display: block; margin-top: 2px; } +.setting-control { display: flex; align-items: center; gap: 14px; } +.setting-val { font-size: 13px; color: var(--muted); min-width: 48px; text-align: right; } + +.slider { -webkit-appearance: none; appearance: none; width: 180px; height: 6px; background: var(--paper-2); border-radius: 3px; outline: none; } +.slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; background: var(--gold); border-radius: 50%; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.2); } +.slider::-moz-range-thumb { width: 18px; height: 18px; background: var(--gold); border-radius: 50%; cursor: pointer; border: none; } + +.switch { position: relative; display: inline-block; cursor: pointer; } +.switch input { opacity: 0; width: 0; height: 0; } +.switch-track { display: block; width: 46px; height: 26px; background: var(--paper-2); border-radius: 999px; transition: 0.2s; position: relative; } +.switch-thumb { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: 0.25s; box-shadow: 0 2px 6px rgba(0,0,0,0.2); } +.switch input:checked + .switch-track { background: var(--green); } +.switch input:checked + .switch-track .switch-thumb { transform: translateX(20px); } + +.settings-foot { font-size: 13px; color: var(--muted); margin-top: 8px; } + +/* ============ 弹窗 ============ */ +.modal-mask { + position: fixed; + inset: 0; + background: rgba(15, 21, 37, 0.6); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: maskIn 0.2s ease; +} +.modal-mask[hidden] { display: none; } +@keyframes maskIn { from { opacity: 0; } to { opacity: 1; } } +.modal { + background: var(--card); + border-radius: var(--radius-lg); + width: 460px; + max-width: 92vw; + max-height: 90vh; + overflow: auto; + box-shadow: 0 30px 80px rgba(0,0,0,0.4); + animation: modalIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} +@keyframes modalIn { from { opacity: 0; transform: scale(0.92) translateY(20px); } to { opacity: 1; transform: none; } } +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 22px 24px 14px; } +.modal-head h3 { font-family: var(--serif); font-size: 20px; font-weight: 700; } +.modal-close { border: none; background: transparent; font-size: 26px; color: var(--muted); cursor: pointer; line-height: 1; padding: 0 4px; transition: color 0.15s; } +.modal-close:hover { color: var(--ink); } +.modal-body { padding: 0 24px 8px; } +.modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px 24px; } + +.avatar-picker { margin-bottom: 18px; } +.avatar-preview { width: 80px; height: 80px; border-radius: 18px; overflow: hidden; margin: 0 auto 14px; box-shadow: var(--shadow); } +.avatar-preview svg { width: 100%; height: 100%; } +.avatar-options { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } +.avatar-opt { + width: 40px; height: 40px; + border-radius: 10px; + overflow: hidden; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.15s; +} +.avatar-opt:hover { transform: scale(1.1); } +.avatar-opt.selected { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,168,56,0.3); } +.avatar-opt svg { width: 100%; height: 100%; } + +.form-row { margin-bottom: 14px; } +.form-row label { display: block; font-size: 13px; font-weight: 600; color: var(--ink); margin-bottom: 6px; } +.req { color: var(--red); } +.form-row input { + width: 100%; + border: 1px solid var(--border); + background: var(--paper); + border-radius: var(--radius-sm); + padding: 11px 14px; + font-family: var(--sans); + font-size: 14px; + color: var(--ink); + outline: none; + transition: all 0.15s; +} +.form-row input:focus { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(232,168,56,0.15); background: var(--card); } + +/* ============ Toast ============ */ +.toast { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + background: var(--ink); + color: #fff; + padding: 12px 24px; + border-radius: 999px; + font-size: 14px; + font-weight: 500; + z-index: 200; + box-shadow: var(--shadow-lg); + animation: toastUp 0.3s ease; +} +.toast[hidden] { display: none; } +.toast.error { background: var(--red); } +.toast.success { background: var(--green); } +@keyframes toastUp { from { opacity: 0; transform: translate(-50%, 16px); } to { opacity: 1; transform: translate(-50%, 0); } } + +/* ============ 响应式 ============ */ +@media (max-width: 768px) { + .topbar { padding: 12px 16px; gap: 12px; flex-wrap: wrap; } + .brand-sub { display: none; } + .app { padding: 20px 16px 60px; } + .roll-stage { padding: 40px 16px; min-height: 300px; border-radius: var(--radius); } + .roller-avatar { width: 104px; height: 104px; border-radius: 26px; } + .roller-name { font-size: 34px; } + .roller-card.question-mode .roller-name { font-size: 26px; } + .controls { gap: 10px; } + .wall { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; } + .manage-head { flex-direction: column; align-items: stretch; } + .manage-actions { flex-wrap: wrap; } + .multi-results-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); } + .section-title { font-size: 19px; } +} + +@media (max-width: 480px) { + .topbar { padding: 10px 14px; gap: 8px; } + .brand-title { font-size: 18px; } + .tabs { margin-left: 0; gap: 2px; } + .tab { padding: 7px 12px; font-size: 13px; } + .app { padding: 16px 12px 50px; } + .roll-stage { padding: 32px 12px; min-height: 260px; } + .roller-avatar { width: 88px; height: 88px; border-radius: 22px; } + .roller-name { font-size: 28px; } + .roller-card.question-mode .roller-name { font-size: 22px; } + .roller-tag { font-size: 13px; } + .mode-toggle { padding: 8px 16px; font-size: 14px; } + .btn-lg { font-size: 15px; padding: 13px 28px; } + .wall { grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); } + .wall-card { padding: 12px 8px 10px; } + .wall-avatar { width: 48px; height: 48px; } + .wall-name { font-size: 13px; } +} + +/* ============ 分组筛选 ============ */ +.group-filter { + display: flex; + align-items: center; + gap: 8px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 4px 4px 16px; +} +.group-label { font-size: 13px; color: var(--muted); font-weight: 600; } +.group-select { + border: none; + background: var(--paper-2); + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + color: var(--ink); + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + outline: none; + transition: all 0.15s; +} +.group-select:hover { background: var(--ink); color: var(--card); } + +/* ============ 模式开关(抽人/抽题) ============ */ +.mode-toggles { + display: flex; + gap: 10px; + margin-top: 20px; + margin-bottom: 4px; +} +.mode-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + border: 2px solid var(--border); + background: var(--card); + font-family: var(--sans); + font-size: 15px; + font-weight: 600; + color: var(--muted); + padding: 10px 22px; + border-radius: 999px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} +.mode-toggle:hover { border-color: var(--gold); color: var(--ink); transform: translateY(-1px); } +.mode-toggle.active { + border-color: var(--ink); + background: linear-gradient(135deg, var(--ink) 0%, var(--ink-soft) 100%); + color: #fff; + box-shadow: 0 8px 20px rgba(26, 34, 56, 0.3); +} +.mode-toggle.active svg { color: var(--gold-light); } + +.person-controls, .question-controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.person-controls[hidden], .question-controls[hidden] { display: none; } + +/* 内联复选框 */ +.check-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 500; + color: var(--ink); + cursor: pointer; + user-select: none; +} +.check-inline input { width: 16px; height: 16px; accent-color: var(--ink); cursor: pointer; } + +/* ============ 题目结果展示区 ============ */ +.question-result { + margin-top: 20px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; + animation: fadeIn 0.4s ease; + box-shadow: var(--shadow-sm); +} +.question-result[hidden] { display: none; } +.question-result-head { margin-bottom: 14px; } +.qr-title { + font-family: var(--serif); + font-size: 17px; + font-weight: 700; + color: var(--ink); + padding-left: 14px; + position: relative; +} +.qr-title::before { + content: ""; + position: absolute; + left: 0; top: 50%; + transform: translateY(-50%); + width: 4px; height: 18px; + background: var(--green); + border-radius: 2px; +} + +/* ============ 多人结果展示 ============ */ +.multi-results { + margin-top: 20px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; + animation: fadeIn 0.4s ease; + box-shadow: var(--shadow-sm); +} +.multi-results[hidden] { display: none; } +.multi-results-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} +.multi-results-title { + font-family: var(--serif); + font-size: 17px; + font-weight: 700; + color: var(--ink); +} +.multi-results-count { + font-size: 13px; + color: var(--muted); + background: var(--paper-2); + padding: 3px 10px; + border-radius: 999px; +} +.multi-results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 14px; +} +.multi-result-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 18px 10px 14px; + background: linear-gradient(160deg, var(--paper) 0%, var(--paper-2) 100%); + border: 2px solid var(--gold); + border-radius: var(--radius); + animation: cardAppear 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; + position: relative; + box-shadow: 0 6px 20px rgba(232, 168, 56, 0.2); +} +@keyframes cardAppear { + from { opacity: 0; transform: scale(0.7) translateY(20px); } + to { opacity: 1; transform: none; } +} +.multi-result-card .mr-avatar { width: 64px; height: 64px; border-radius: 16px; overflow: hidden; box-shadow: var(--shadow); } +.multi-result-card .mr-avatar svg { width: 100%; height: 100%; } +.multi-result-card .mr-name { font-family: var(--serif); font-size: 17px; font-weight: 700; color: var(--ink); } +.multi-result-card .mr-no { font-size: 12px; color: var(--muted); } +.multi-result-card .mr-idx { + position: absolute; + top: -8px; left: -8px; + font-family: var(--serif); + font-size: 13px; + font-weight: 900; + color: #fff; + background: linear-gradient(135deg, var(--gold) 0%, var(--gold-deep) 100%); + width: 26px; height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 10px rgba(232, 168, 56, 0.4); +} + +/* ============ 随机题目区 ============ */ +.question-picker { + margin-top: 20px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; +} +.question-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.qp-title { + font-family: var(--serif); + font-size: 17px; + font-weight: 700; + color: var(--ink); + padding-left: 14px; + position: relative; +} +.qp-title::before { + content: ""; + position: absolute; + left: 0; top: 50%; + transform: translateY(-50%); + width: 4px; height: 18px; + background: var(--green); + border-radius: 2px; +} +.subject-select, .subject-filter { + border: 1px solid var(--border); + background: var(--card); + font-family: var(--sans); + font-size: 14px; + color: var(--ink); + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + outline: none; + transition: all 0.15s; +} +.subject-select:hover, .subject-filter:hover { border-color: var(--gold); } +.subject-select:focus, .subject-filter:focus { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(232,168,56,0.15); } + +.question-display { + background: linear-gradient(135deg, var(--paper) 0%, var(--paper-2) 100%); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 24px 28px; + min-height: 80px; + margin-bottom: 14px; + display: flex; + align-items: center; + transition: all 0.3s; +} +.question-display.has-question { + border-color: var(--green); + background: linear-gradient(135deg, #f0f7f4 0%, #e6f0ec 100%); + animation: questionReveal 0.5s ease; + box-shadow: 0 4px 14px rgba(58, 140, 122, 0.12); +} +@keyframes questionReveal { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: none; } +} +.question-placeholder { color: var(--muted); font-size: 14px; } +.question-content { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} +.question-meta { + display: flex; + gap: 8px; + align-items: center; +} +.question-subject-tag { + font-size: 12px; + font-weight: 700; + color: #fff; + background: var(--green); + padding: 3px 10px; + border-radius: 999px; + box-shadow: 0 2px 6px rgba(58, 140, 122, 0.3); +} +.question-diff-tag { + font-size: 12px; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; +} +.question-diff-tag.简单 { background: var(--green-soft); color: var(--green); } +.question-diff-tag.中等 { background: #fef3e0; color: var(--gold-deep); } +.question-diff-tag.困难 { background: var(--red-soft); color: var(--red); } +.question-text { + font-size: 17px; + line-height: 1.7; + color: var(--ink); + font-weight: 500; +} + +/* ============ 题目表格学科标签 ============ */ +.subject-tag { + display: inline-block; + font-size: 12px; + font-weight: 700; + color: #fff; + padding: 3px 12px; + border-radius: 999px; + white-space: nowrap; +} +.group-tag { + display: inline-block; + font-size: 12px; + font-weight: 600; + color: var(--gold-deep); + background: rgba(232,168,56,0.15); + padding: 3px 10px; + border-radius: 999px; + white-space: nowrap; +} +.diff-tag { + display: inline-block; + font-size: 12px; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; +} +.diff-tag.简单 { background: #e6f4ea; color: var(--green); } +.diff-tag.中等 { background: #fef3e0; color: var(--gold-deep); } +.diff-tag.困难 { background: #fce8e8; color: var(--red); } + +/* ============ 难度选择器 ============ */ +.difficulty-picker { display: flex; gap: 8px; } +.diff-btn { + border: 1px solid var(--border); + background: var(--card); + font-family: var(--sans); + font-size: 14px; + color: var(--muted); + padding: 8px 20px; + border-radius: 999px; + cursor: pointer; + transition: all 0.15s; +} +.diff-btn:hover { border-color: var(--gold); color: var(--ink); transform: translateY(-1px); } +.diff-btn.active { background: var(--ink); color: #fff; border-color: var(--ink); box-shadow: 0 4px 12px rgba(26, 34, 56, 0.25); } + +/* ============ Textarea ============ */ +.form-row textarea { + width: 100%; + border: 1px solid var(--border); + background: var(--paper); + border-radius: var(--radius-sm); + padding: 11px 14px; + font-family: var(--sans); + font-size: 14px; + color: var(--ink); + outline: none; + transition: all 0.15s; + resize: vertical; + line-height: 1.6; +} +.form-row textarea:focus { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(232,168,56,0.15); background: var(--card); } + +/* 学科筛选下拉在搜索栏内 */ +.search-bar .subject-filter { margin-left: 8px; } + diff --git a/TempLate/rollcall/示例数据.json b/TempLate/rollcall/示例数据.json new file mode 100644 index 00000000..a725d83a --- /dev/null +++ b/TempLate/rollcall/示例数据.json @@ -0,0 +1,32 @@ +{ + "students": [ + { "id": "s1", "name": "张明轩", "no": "2024001", "note": "班长", "avatar": 0, "group": "第一组" }, + { "id": "s2", "name": "李思琪", "no": "2024002", "note": "学习委员", "avatar": 1, "group": "第一组" }, + { "id": "s3", "name": "王浩然", "no": "2024003", "note": "", "avatar": 2, "group": "第一组" }, + { "id": "s4", "name": "陈雨桐", "no": "2024004", "note": "", "avatar": 3, "group": "第二组" }, + { "id": "s5", "name": "刘子涵", "no": "2024005", "note": "体育委员", "avatar": 4, "group": "第二组" }, + { "id": "s6", "name": "杨欣怡", "no": "2024006", "note": "", "avatar": 5, "group": "第二组" }, + { "id": "s7", "name": "黄宇辰", "no": "2024007", "note": "", "avatar": 6, "group": "第三组" }, + { "id": "s8", "name": "赵梓萱", "no": "2024008", "note": "", "avatar": 7, "group": "第三组" }, + { "id": "s9", "name": "周奕辰", "no": "2024009", "note": "", "avatar": 8, "group": "第三组" }, + { "id": "s10", "name": "吴沐瑶", "no": "2024010", "note": "文艺委员", "avatar": 9, "group": "第四组" }, + { "id": "s11", "name": "徐子睿", "no": "2024011", "note": "", "avatar": 10, "group": "第四组" }, + { "id": "s12", "name": "孙若汐", "no": "2024012", "note": "", "avatar": 11, "group": "第四组" } + ], + "questions": [ + { "id": "q1", "subject": "语文", "content": "请背诵《静夜思》并解释\"举头望明月\"的意境。", "difficulty": "简单" }, + { "id": "q2", "subject": "语文", "content": "分析《背影》一文中父亲形象的特点及其表现手法。", "difficulty": "中等" }, + { "id": "q3", "subject": "语文", "content": "请举例说明比喻与拟人修辞手法的区别。", "difficulty": "简单" }, + { "id": "q4", "subject": "数学", "content": "已知函数 f(x) = x² - 4x + 3,求其顶点坐标和对称轴。", "difficulty": "中等" }, + { "id": "q5", "subject": "数学", "content": "证明:三角形任意两边之和大于第三边。", "difficulty": "困难" }, + { "id": "q6", "subject": "数学", "content": "计算:sin30° + cos60° - tan45° 的值。", "difficulty": "简单" }, + { "id": "q7", "subject": "英语", "content": "Please introduce yourself in English within 5 sentences.", "difficulty": "简单" }, + { "id": "q8", "subject": "英语", "content": "Translate the following sentence: \"Knowledge is power.\"", "difficulty": "中等" }, + { "id": "q9", "subject": "英语", "content": "Explain the difference between \"have been to\" and \"have gone to\".", "difficulty": "困难" }, + { "id": "q10", "subject": "物理", "content": "解释牛顿第一定律的内容,并举例说明惯性现象。", "difficulty": "中等" }, + { "id": "q11", "subject": "物理", "content": "一个物体从高处自由下落,3秒后落地,求高度(g=10m/s²)。", "difficulty": "中等" }, + { "id": "q12", "subject": "历史", "content": "简述丝绸之路的开通及其历史意义。", "difficulty": "中等" } + ], + "history": [], + "exportedAt": "2026-06-23T00:00:00.000Z" +} diff --git a/_toast.ps1 b/_toast.ps1 new file mode 100644 index 00000000..0ed59c42 --- /dev/null +++ b/_toast.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = 'SilentlyContinue' +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null +$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) +$n = $t.GetElementsByTagName('text') +$n.Item(0).AppendChild($t.CreateTextNode('部门/工作组列表页批量调职完成')) | Out-Null +$n.Item(1).AppendChild($t.CreateTextNode('弹窗改为部门树/工作组树形选择目标,可勾选多个并批量新增/删除职务,已部署')) | Out-Null +$toast = [Windows.UI.Notifications.ToastNotification]::new($t) +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('TraeCode Agent').Show($toast) +Write-Output 'toast-ok' diff --git a/backup/www/index.html b/backup/www/index.html index 7a38047e..056ba726 100644 --- a/backup/www/index.html +++ b/backup/www/index.html @@ -5,8 +5,8 @@ 银河锋锐(GVSDS) - - + +
diff --git a/gvsds/apps/rollcall/__init__.py b/gvsds/apps/rollcall/__init__.py new file mode 100644 index 00000000..ae763616 --- /dev/null +++ b/gvsds/apps/rollcall/__init__.py @@ -0,0 +1,7 @@ +"""RollCall 应用——课堂随机点名数据持久化后端。 + +纯前端点名应用(TempLate/rollcall)由 localStorage 升级为后端 API 持久化: +学生/题目/课堂记录以 JSON 整体存储于 RollCallData 单表(DataKey 数据槽,默认 main)。 +""" + +default_app_config = 'gvsds.apps.rollcall.apps.RollCallConfig' diff --git a/gvsds/apps/rollcall/apps.py b/gvsds/apps/rollcall/apps.py new file mode 100644 index 00000000..577bb7c6 --- /dev/null +++ b/gvsds/apps/rollcall/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class RollCallConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gvsds.apps.rollcall' + label = 'rollcall' + verbose_name = 'RollCall' diff --git a/gvsds/apps/rollcall/manager.py b/gvsds/apps/rollcall/manager.py new file mode 100644 index 00000000..62f895cb --- /dev/null +++ b/gvsds/apps/rollcall/manager.py @@ -0,0 +1,35 @@ +"""模块:RollCall 课堂点名数据访问层 + +点名数据为「单行整体读写」结构(学生/题目/课堂记录三段 JSON), +数据访问收敛在此,视图层不直接操作 ORM。所有方法遵循帕斯卡命名法。 +""" + +from .models import RollCallData + + +class RollCallManager: + """课堂点名数据管理器——按 DataKey 数据槽整体读写""" + + @staticmethod + def GetData(DataKey): + """读取数据槽(不存在返回 None)""" + return RollCallData.objects.filter(DataKey=DataKey).first() + + @staticmethod + def SaveData(DataKey, Students, Questions, History): + """整体覆盖保存数据槽,返回数据行实例""" + row, _ = RollCallData.objects.update_or_create( + DataKey=DataKey, + defaults={ + 'Students': Students, + 'Questions': Questions, + 'History': History, + }, + ) + return row + + @staticmethod + def ClearData(DataKey): + """清空数据槽,返回删除行数""" + deleted, _ = RollCallData.objects.filter(DataKey=DataKey).delete() + return deleted diff --git a/gvsds/apps/rollcall/migrations/0001_initial.py b/gvsds/apps/rollcall/migrations/0001_initial.py new file mode 100644 index 00000000..dcf8e0a6 --- /dev/null +++ b/gvsds/apps/rollcall/migrations/0001_initial.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='RollCallData', + fields=[ + ('data_key', models.CharField(db_column='DataKey', default='main', max_length=32, primary_key=True, serialize=False, verbose_name='数据槽')), + ('students', models.JSONField(db_column='Students', default=list, verbose_name='学生列表')), + ('questions', models.JSONField(db_column='Questions', default=list, verbose_name='题库列表')), + ('history', models.JSONField(db_column='History', default=list, verbose_name='课堂记录')), + ('updated_at', models.DateTimeField(auto_now=True, db_column='UpdatedAt', verbose_name='更新时间')), + ], + options={ + 'verbose_name': '点名数据', + 'verbose_name_plural': '点名数据', + 'db_table': 'rollcall_data', + }, + ), + ] diff --git a/gvsds/apps/rollcall/migrations/__init__.py b/gvsds/apps/rollcall/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gvsds/apps/rollcall/models.py b/gvsds/apps/rollcall/models.py new file mode 100644 index 00000000..0ee233f7 --- /dev/null +++ b/gvsds/apps/rollcall/models.py @@ -0,0 +1,27 @@ +"""RollCall 数据模型——课堂点名数据持久化(单表 JSON 整体存储)。 + +学生 / 题目 / 课堂记录以 JSON 数组整体存于一行(DataKey 数据槽), +与前端原始 localStorage 结构完全一致(students/questions/history), +GET 整体读取、POST 整体覆盖,保持前端最小改动。 +""" + +from django.db import models + + +class RollCallData(models.Model): + """点名数据槽——一个 DataKey 对应一份完整点名数据(默认 main 单班)""" + + DataKey = models.CharField(max_length=32, primary_key=True, default='main', db_column='DataKey', verbose_name='数据槽') + Students = models.JSONField(default=list, db_column='Students', verbose_name='学生列表') + Questions = models.JSONField(default=list, db_column='Questions', verbose_name='题库列表') + History = models.JSONField(default=list, db_column='History', verbose_name='课堂记录') + UpdatedAt = models.DateTimeField(auto_now=True, db_column='UpdatedAt', verbose_name='更新时间') + + class Meta: + db_table = 'rollcall_data' + app_label = 'rollcall' + verbose_name = '点名数据' + verbose_name_plural = '点名数据' + + def __str__(self): + return f'RollCallData[{self.DataKey}]' diff --git a/gvsds/apps/rollcall/urls.py b/gvsds/apps/rollcall/urls.py new file mode 100644 index 00000000..1731a2fe --- /dev/null +++ b/gvsds/apps/rollcall/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('Data', views.RollCallDataView.as_view()), +] diff --git a/gvsds/apps/rollcall/views.py b/gvsds/apps/rollcall/views.py new file mode 100644 index 00000000..f6e407d8 --- /dev/null +++ b/gvsds/apps/rollcall/views.py @@ -0,0 +1,49 @@ +"""RollCall API 视图——整体读写点名数据(学生/题目/课堂记录)。 + +- GET /API/RollCall/Data 读取全部数据(无记录返回空数组) +- POST /API/RollCall/Data 整体覆盖保存(body: {students, questions, history}) +- DELETE /API/RollCall/Data 清空数据 + +点名站点为公开教学场景,未做登录鉴权(如需保护可在 Main.py 层或后续加 token)。 +""" + +from rest_framework.response import Response +from rest_framework.views import APIView + +from .manager import RollCallManager + +DATA_KEY = 'main' + + +class RollCallDataView(APIView): + authentication_classes = [] + permission_classes = [] + + def get(self, request): + row = RollCallManager.GetData(DATA_KEY) + if row is None: + return Response({'students': [], 'questions': [], 'history': []}) + return Response({ + 'students': row.Students or [], + 'questions': row.Questions or [], + 'history': row.History or [], + }) + + def post(self, request): + data = request.data if isinstance(request.data, dict) else {} + students = data.get('students') + questions = data.get('questions') + history = data.get('history') + if not isinstance(students, list) or not isinstance(questions, list): + return Response({'error': 'students/questions 必须为数组'}, status=400) + row = RollCallManager.SaveData( + DATA_KEY, + Students=students, + Questions=questions, + History=history if isinstance(history, list) else [], + ) + return Response({'ok': True, 'updatedAt': row.UpdatedAt}) + + def delete(self, request): + deleted = RollCallManager.ClearData(DATA_KEY) + return Response({'ok': True, 'deleted': deleted}) diff --git a/gvsds/settings.py b/gvsds/settings.py index 71c7a226..dd780248 100644 --- a/gvsds/settings.py +++ b/gvsds/settings.py @@ -61,6 +61,7 @@ INSTALLED_APPS = _app_getlist('apps', 'INSTALLED_APPS', fallback=[ 'gvsds.apps.t4', 'gvsds.apps.mc', 'gvsds.apps.doc', + 'gvsds.apps.rollcall', 'gvsds.apps.api', 'gvsds.apps.community', 'gvsdsdk.msync', diff --git a/gvsds/urls.py b/gvsds/urls.py index fed8f218..276e05a1 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -292,6 +292,9 @@ urlpatterns = [ path('API/ConversationHistory', invite_views.InviteConversationViewSet.as_view({'get': 'history', 'post': 'history'}), name='invite_history'), path('API/UserBind.api', mc_views.UserBindView.as_view(), name='mc_user_bind'), + + # RollCall 课堂点名——数据持久化(学生/题目/课堂记录整体读写) + path('API/RollCall/', include('gvsds.apps.rollcall.urls')), ] urlpatterns += [