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 数据
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 头像 |
+ 姓名 |
+ 学号 |
+ 分组 |
+ 备注 |
+ 操作 |
+
+
+
+
+
+
暂无学生数据,点击"添加学生"或"导入 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 @@
+
+
+
+
+
+ 课堂随机点名 · 同学点名
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 本次抽题
+
+
+ 点击下方按钮开始抽题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
学生名单
+
+
+
+
+
+
+
+
+
+
+
+
+
+
本次课堂记录
+
+
+
+
尚未抽取
+
+
+
+
+
+ 设置
+
+
+
抽取行为
+
+
+ 动画速度
+ 控制抽取动画的快慢
+
+
+
+ 标准
+
+
+
+
+ 音效
+ 抽取时播放提示音
+
+
+
+
+
+
+
+ 自动记录历史
+ 将每次抽取结果记入课堂记录
+
+
+
+
+
+
+
+
+
数据管理
+
+
+ 管理控制台
+ 学生与题目管理已迁移至管理员端
+
+
+
+
+
+ 示例数据
+ 加载一组示例学生用于体验
+
+
+
+
+
+
+
+ 清空所有数据
+ 删除全部学生与课堂记录(不可恢复)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 ``;
+ }
+
+ 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 += [