chore(baseline): 注册 RollCall/Viper/Venom 子域与站点,补 rollcall 应用

Main.py:WHITE_HOSTS 增 rollcall/viper/venom 主机,STATIC_SITES 增 rollcall/venom 静态根,静态站点路由下 /API/ 前缀请求转交 Django 处理。

gvsds:settings.py 注册 gvsds.apps.rollcall,urls.py 挂载 API/RollCall/;新增 rollcall 应用(模型/视图/路由/迁移 + RollCallManager 数据访问层)与 TempLate/rollcall 前端站点。

.gitignore:排除 Viper/Venom 前端工程与部署产物、TempLate/FTP 二进制分发目录。
This commit is contained in:
TermiNexus committed 2026-09-14 12:30:06 +08:00
1 parent 9cb0d8e186
commit f8d356f3ff
24 files changed
+3462 -6

No files matched your search

+14 -1
View File
@@ -55,4 +55,17 @@ __pycache__/
/TempLate/SVE/
# TempLate 二进制资源 CDN 上传源(仅本地参考,hb.cdn.gvsds.com 与其无关联)
/TempLate/assets - Copy/
/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/
+9 -2
View File
@@ -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)
+2
View File
@@ -1,3 +1,5 @@
# 成员邮箱和 gitea 与 gerp 同步
像阿里云一样的百科,解释 GERP 的各个机制
一套配色系统(主题色和配色方案)
持有产品
代码,业绩审计
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>银河锋锐(GVSDS)</title>
<link rel="icon" href="https://hb.cdn.gvsds.com/assets/icon/ICON.png">
<script type="module" crossorigin src="/GVSite/index-BoGW0LdY.js"></script>
<script type="module" crossorigin src="/GVSite/index-3r1fnTcU.js"></script>
<link rel="stylesheet" crossorigin href="/GVSite/index-BJ4Iq5BO.css">
</head>
<body>
+82
View File
@@ -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 种配色方案)
- 居中显示学生姓名首字
- 可选装饰:星星、书本、铅笔、帽子等教育元素
+16
View File
@@ -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)
+789
View File
@@ -0,0 +1,789 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>同学点名 · 管理</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@600;700;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="../style.css" />
</head>
<body>
<!-- 顶部导航 -->
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28">
<rect x="3" y="6" width="26" height="20" rx="3" fill="#e8a838"/>
<rect x="3" y="6" width="26" height="6" rx="3" fill="#1a2238"/>
<circle cx="11" cy="19" r="3" fill="#f5f0e6"/>
<rect x="16" y="17" width="9" height="2" rx="1" fill="#1a2238"/>
<rect x="16" y="21" width="7" height="2" rx="1" fill="#1a2238"/>
</svg>
</span>
<h1 class="brand-title">同学点名</h1>
<span class="brand-sub">管理控制台</span>
</div>
<nav class="tabs" role="tablist">
<button class="tab active" data-view="manage" role="tab">学生管理</button>
<button class="tab" data-view="question" role="tab">题目管理</button>
</nav>
<div class="topbar-right">
<a class="count-badge" href="../" style="text-decoration:none;" title="返回点名主页">返回点名主页</a>
</div>
</header>
<main class="app">
<!-- ============ 学生管理视图 ============ -->
<section class="view view-manage active" id="view-manage">
<div class="manage-head">
<div>
<h2 class="section-title">学生管理</h2>
<p class="section-desc">添加、编辑学生信息,支持导入导出 JSON 数据</p>
</div>
<div class="manage-actions">
<button class="btn btn-primary" id="addStudentBtn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加学生
</button>
<button class="btn btn-ghost" id="exportBtn">导出 JSON</button>
<button class="btn btn-ghost" id="importBtn">导入 JSON</button>
<input type="file" id="importInput" accept=".json" hidden />
</div>
</div>
<div class="search-bar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="searchInput" placeholder="搜索姓名或学号…" />
</div>
<div class="table-wrap">
<table class="student-table">
<thead>
<tr>
<th style="width:56px">头像</th>
<th>姓名</th>
<th>学号</th>
<th>分组</th>
<th>备注</th>
<th style="width:140px">操作</th>
</tr>
</thead>
<tbody id="studentTbody"></tbody>
</table>
<div class="empty-state" id="manageEmpty" hidden>
<p>暂无学生数据,点击"添加学生"或"导入 JSON"开始</p>
</div>
</div>
</section>
<!-- ============ 题目管理视图 ============ -->
<section class="view view-question" id="view-question">
<div class="manage-head">
<div>
<h2 class="section-title">题目管理</h2>
<p class="section-desc">按学科管理题库,支持随机抽取题目用于课堂提问</p>
</div>
<div class="manage-actions">
<button class="btn btn-primary" id="addQuestionBtn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加题目
</button>
<button class="btn btn-ghost" id="exportQBtn">导出题库</button>
<button class="btn btn-ghost" id="importQBtn">导入题库</button>
<input type="file" id="importQInput" accept=".json" hidden />
</div>
</div>
<div class="search-bar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="qSearchInput" placeholder="搜索题目内容…" />
<select class="subject-filter" id="qSubjectFilter">
<option value="">全部学科</option>
</select>
</div>
<div class="table-wrap">
<table class="student-table">
<thead>
<tr>
<th style="width:90px">学科</th>
<th>题目内容</th>
<th style="width:80px">难度</th>
<th style="width:140px">操作</th>
</tr>
</thead>
<tbody id="questionTbody"></tbody>
</table>
<div class="empty-state" id="questionEmpty" hidden>
<p>暂无题目数据,点击"添加题目"开始</p>
</div>
</div>
</section>
</main>
<!-- 添加/编辑学生弹窗 -->
<div class="modal-mask" id="studentModal" hidden>
<div class="modal">
<div class="modal-head">
<h3 id="modalTitle">添加学生</h3>
<button class="modal-close" id="modalClose" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<div class="avatar-picker">
<div class="avatar-preview" id="avatarPreview"></div>
<div class="avatar-options" id="avatarOptions"></div>
</div>
<div class="form-row">
<label>姓名 <span class="req">*</span></label>
<input type="text" id="formName" placeholder="请输入学生姓名" maxlength="20" />
</div>
<div class="form-row">
<label>学号</label>
<input type="text" id="formNo" placeholder="可选" maxlength="20" />
</div>
<div class="form-row">
<label>分组</label>
<input type="text" id="formGroup" placeholder="可选,如:第一组 / A组" list="groupList" maxlength="20" />
<datalist id="groupList"></datalist>
</div>
<div class="form-row">
<label>备注</label>
<input type="text" id="formNote" placeholder="可选,如班级、职务" maxlength="40" />
</div>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" id="modalCancel">取消</button>
<button class="btn btn-primary" id="modalSave">保存</button>
</div>
</div>
</div>
<!-- 添加/编辑题目弹窗 -->
<div class="modal-mask" id="questionModal" hidden>
<div class="modal">
<div class="modal-head">
<h3 id="qModalTitle">添加题目</h3>
<button class="modal-close" id="qModalClose" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<div class="form-row">
<label>学科 <span class="req">*</span></label>
<input type="text" id="qFormSubject" placeholder="如:语文、数学、英语" list="subjectList" maxlength="20" />
<datalist id="subjectList"></datalist>
</div>
<div class="form-row">
<label>题目内容 <span class="req">*</span></label>
<textarea id="qFormContent" placeholder="请输入题目内容" rows="4" maxlength="500"></textarea>
</div>
<div class="form-row">
<label>难度</label>
<div class="difficulty-picker" id="difficultyPicker">
<button class="diff-btn" data-diff="简单">简单</button>
<button class="diff-btn active" data-diff="中等">中等</button>
<button class="diff-btn" data-diff="困难">困难</button>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" id="qModalCancel">取消</button>
<button class="btn btn-primary" id="qModalSave">保存</button>
</div>
</div>
</div>
<!-- Toast 提示 -->
<div class="toast" id="toast" hidden></div>
<script>
(function () {
'use strict';
/* ============ 常量 ============ */
const STORAGE_KEY = 'tongxue-dianming-data-v1';
/* ============ 头像生成(平移自点名端) ============ */
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'];
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 = `<g opacity="0.18" fill="${p.accent}"><circle cx="20" cy="20" r="2.5"/><circle cx="80" cy="20" r="2.5"/><circle cx="20" cy="80" r="2.5"/><circle cx="80" cy="80" r="2.5"/><circle cx="50" cy="15" r="2"/><circle cx="15" cy="50" r="2"/><circle cx="85" cy="50" r="2"/><circle cx="50" cy="85" r="2"/></g>`;
break;
case 'stars':
patternSvg = `<g opacity="0.2" fill="${p.accent}"><path d="M18 18l2 5 5 2-5 2-2 5-2-5-5-2 5-2z"/><path d="M82 78l2 5 5 2-5 2-2 5-2-5-5-2 5-2z"/><path d="M82 22l1.5 4 4 1.5-4 1.5-1.5 4-1.5-4-4-1.5 4-1.5z"/></g>`;
break;
case 'books':
patternSvg = `<g opacity="0.2" fill="${p.accent}"><rect x="12" y="74" width="18" height="12" rx="1"/><rect x="32" y="74" width="18" height="12" rx="1"/><rect x="52" y="74" width="18" height="12" rx="1"/></g>`;
break;
case 'pencil':
patternSvg = `<g opacity="0.2" fill="${p.accent}"><path d="M78 16l6 6-20 20-6 0 0-6z"/><rect x="76" y="14" width="4" height="8" transform="rotate(45 78 18)"/></g>`;
break;
case 'waves':
patternSvg = `<g opacity="0.18" stroke="${p.accent}" stroke-width="2.5" fill="none" stroke-linecap="round"><path d="M10 78 Q30 70 50 78 T90 78"/><path d="M10 86 Q30 78 50 86 T90 86"/></g>`;
break;
case 'grid':
patternSvg = `<g opacity="0.12" stroke="${p.accent}" stroke-width="1"><line x1="25" y1="10" x2="25" y2="90"/><line x1="50" y1="10" x2="50" y2="90"/><line x1="75" y1="10" x2="75" y2="90"/><line x1="10" y1="25" x2="90" y2="25"/><line x1="10" y1="50" x2="90" y2="50"/><line x1="10" y1="75" x2="90" y2="75"/></g>`;
break;
}
return `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="${gid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="${p.bg}"/><stop offset="100%" stop-color="${p.bg2}"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#${gid})"/>${patternSvg}<text x="50" y="58" text-anchor="middle" font-family="'Noto Serif SC', serif" font-size="42" font-weight="900" fill="${p.fg}">${escapeXml(ch)}</text></svg>`;
}
function escapeXml(s) {
return String(s).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
}
function escapeHtml(s) {
return String(s).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c]));
}
/* ============ 学科配色 ============ */
const SUBJECT_COLORS = {
'语文': '#c84b4b', '数学': '#3a8c7a', '英语': '#4a7c8c',
'物理': '#6b5b95', '化学': '#d97e4f', '生物': '#88a36e',
'历史': '#b8558c', '地理': '#5c6b73', '政治': '#c9974a',
};
function subjectColor(sub) { return SUBJECT_COLORS[sub] || '#5c6b73'; }
/* ============ 数据存储(与点名端共享 localStorage) ============ */
const Store = {
students: [],
questions: [],
history: [],
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); }
},
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);
},
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();
},
};
/* ============ DOM 引用 ============ */
const $ = (id) => document.getElementById(id);
const els = {
tabs: document.querySelectorAll('.tab'),
views: document.querySelectorAll('.view'),
// manage
addStudentBtn: $('addStudentBtn'),
exportBtn: $('exportBtn'),
importBtn: $('importBtn'),
importInput: $('importInput'),
searchInput: $('searchInput'),
studentTbody: $('studentTbody'),
manageEmpty: $('manageEmpty'),
// question management
addQuestionBtn: $('addQuestionBtn'),
exportQBtn: $('exportQBtn'),
importQBtn: $('importQBtn'),
importQInput: $('importQInput'),
qSearchInput: $('qSearchInput'),
qSubjectFilter: $('qSubjectFilter'),
questionTbody: $('questionTbody'),
questionEmpty: $('questionEmpty'),
// student modal
studentModal: $('studentModal'),
modalTitle: $('modalTitle'),
modalClose: $('modalClose'),
modalCancel: $('modalCancel'),
modalSave: $('modalSave'),
formName: $('formName'),
formNo: $('formNo'),
formGroup: $('formGroup'),
groupList: $('groupList'),
formNote: $('formNote'),
avatarPreview: $('avatarPreview'),
avatarOptions: $('avatarOptions'),
// question modal
questionModal: $('questionModal'),
qModalTitle: $('qModalTitle'),
qModalClose: $('qModalClose'),
qModalCancel: $('qModalCancel'),
qModalSave: $('qModalSave'),
qFormSubject: $('qFormSubject'),
qFormContent: $('qFormContent'),
difficultyPicker: $('difficultyPicker'),
subjectList: $('subjectList'),
// 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 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 === 'manage') renderManage();
if (name === 'question') renderQuestion();
}
/* ============ 渲染:学生管理 ============ */
function renderManage() {
const kw = (els.searchInput.value || '').trim().toLowerCase();
let list = Store.students;
if (kw) list = list.filter(s => s.name.toLowerCase().includes(kw) || (s.no || '').toLowerCase().includes(kw) || (s.group || '').toLowerCase().includes(kw));
els.groupList.innerHTML = Store.getGroups().map(g => `<option value="${escapeHtml(g)}">`).join('');
if (Store.students.length === 0) {
els.studentTbody.innerHTML = '';
els.manageEmpty.hidden = false;
return;
}
els.manageEmpty.hidden = true;
els.studentTbody.innerHTML = list.map(s => `
<tr data-id="${s.id}">
<td><div class="row-avatar">${makeAvatar(s.name, s.avatar || 0)}</div></td>
<td><strong>${escapeHtml(s.name)}</strong></td>
<td>${escapeHtml(s.no || '—')}</td>
<td>${s.group ? `<span class="group-tag">${escapeHtml(s.group)}</span>` : '—'}</td>
<td>${escapeHtml(s.note || '—')}</td>
<td><div class="row-actions">
<button class="icon-btn" data-act="edit" title="编辑">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z"/></svg>
</button>
<button class="icon-btn danger" data-act="del" title="删除">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
</button>
</div></td>
</tr>`).join('');
}
/* ============ 渲染:题目管理 ============ */
function renderQuestion() {
const kw = (els.qSearchInput.value || '').trim().toLowerCase();
const subj = els.qSubjectFilter.value;
let list = Store.questions;
if (subj) list = list.filter(q => q.subject === subj);
if (kw) list = list.filter(q => q.content.toLowerCase().includes(kw) || q.subject.toLowerCase().includes(kw));
if (Store.questions.length === 0) {
els.questionTbody.innerHTML = '';
els.questionEmpty.hidden = false;
return;
}
els.questionEmpty.hidden = true;
els.questionTbody.innerHTML = list.map(q => `
<tr data-id="${q.id}">
<td><span class="subject-tag" style="background:${subjectColor(q.subject)}">${escapeHtml(q.subject || '未分类')}</span></td>
<td>${escapeHtml(q.content)}</td>
<td><span class="diff-tag ${escapeHtml(q.difficulty || '中等')}">${escapeHtml(q.difficulty || '中等')}</span></td>
<td><div class="row-actions">
<button class="icon-btn" data-act="edit" title="编辑">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z"/></svg>
</button>
<button class="icon-btn danger" data-act="del" title="删除">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
</button>
</div></td>
</tr>`).join('');
}
/* ============ 渲染:学科下拉 ============ */
function renderSubjectSelects() {
const subjects = Store.getSubjects();
const opts = '<option value="">全部学科</option>' +
subjects.map(s => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
els.qSubjectFilter.innerHTML = opts;
els.subjectList.innerHTML = subjects.map(s => `<option value="${escapeHtml(s)}">`).join('');
}
/* ============ 弹窗:添加/编辑学生 ============ */
let editingId = null;
let selectedAvatar = 0;
function openModal(id) {
editingId = id || null;
els.modalTitle.textContent = id ? '编辑学生' : '添加学生';
els.groupList.innerHTML = Store.getGroups().map(g => `<option value="${escapeHtml(g)}">`).join('');
if (id) {
const s = Store.students.find(x => x.id === id);
els.formName.value = s.name;
els.formNo.value = s.no || '';
els.formGroup.value = s.group || '';
els.formNote.value = s.note || '';
selectedAvatar = s.avatar || 0;
} else {
els.formName.value = '';
els.formNo.value = '';
els.formGroup.value = '';
els.formNote.value = '';
selectedAvatar = Math.floor(Math.random() * 12);
}
renderAvatarPicker();
els.studentModal.hidden = false;
setTimeout(() => els.formName.focus(), 100);
}
function closeModal() {
els.studentModal.hidden = true;
editingId = null;
}
function renderAvatarPicker() {
els.avatarPreview.innerHTML = makeAvatar(els.formName.value || '同', selectedAvatar);
els.avatarOptions.innerHTML = '';
for (let i = 0; i < 12; i++) {
const opt = document.createElement('div');
opt.className = 'avatar-opt' + (i === selectedAvatar ? ' selected' : '');
opt.innerHTML = makeAvatar(els.formName.value || '同', i);
opt.onclick = () => { selectedAvatar = i; renderAvatarPicker(); };
els.avatarOptions.appendChild(opt);
}
}
function saveStudent() {
const name = els.formName.value.trim();
if (!name) { toast('请输入姓名', 'error'); return; }
const data = {
name,
no: els.formNo.value.trim(),
group: els.formGroup.value.trim(),
note: els.formNote.value.trim(),
avatar: selectedAvatar,
};
if (editingId) {
Store.updateStudent(editingId, data);
toast('已更新', 'success');
} else {
Store.addStudent(data);
toast('已添加', 'success');
}
closeModal();
renderManage();
}
/* ============ 弹窗:添加/编辑题目 ============ */
let editingQId = null;
let selectedDifficulty = '中等';
function openQuestionModal(id) {
editingQId = id || null;
els.qModalTitle.textContent = id ? '编辑题目' : '添加题目';
els.subjectList.innerHTML = Store.getSubjects().map(s => `<option value="${escapeHtml(s)}">`).join('');
if (id) {
const q = Store.questions.find(x => x.id === id);
els.qFormSubject.value = q.subject || '';
els.qFormContent.value = q.content || '';
selectedDifficulty = q.difficulty || '中等';
} else {
els.qFormSubject.value = '';
els.qFormContent.value = '';
selectedDifficulty = '中等';
}
updateDifficultyUI();
els.questionModal.hidden = false;
setTimeout(() => els.qFormSubject.focus(), 100);
}
function closeQuestionModal() {
els.questionModal.hidden = true;
editingQId = null;
}
function updateDifficultyUI() {
els.difficultyPicker.querySelectorAll('.diff-btn').forEach(b => {
b.classList.toggle('active', b.dataset.diff === selectedDifficulty);
});
}
function saveQuestion() {
const subject = els.qFormSubject.value.trim();
const content = els.qFormContent.value.trim();
if (!subject) { toast('请输入学科', 'error'); return; }
if (!content) { toast('请输入题目内容', 'error'); return; }
const data = { subject, content, difficulty: selectedDifficulty };
if (editingQId) {
Store.updateQuestion(editingQId, data);
toast('已更新', 'success');
} else {
Store.addQuestion(data);
toast('已添加', 'success');
}
closeQuestionModal();
renderQuestion();
renderSubjectSelects();
}
/* ============ 导入导出 ============ */
function exportJSON() {
if (Store.students.length === 0 && Store.questions.length === 0) { toast('暂无数据可导出', 'error'); return; }
const data = { students: Store.students, questions: Store.questions, history: Store.history, exportedAt: new Date().toISOString() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '同学点名数据_' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
toast('已导出', 'success');
}
function importJSON(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (!Array.isArray(data.students)) throw new Error('格式错误');
Store.students = data.students.map(s => ({
id: s.id || uid(),
name: String(s.name || ''),
no: String(s.no || ''),
group: String(s.group || ''),
note: String(s.note || ''),
avatar: Number(s.avatar) || 0,
}));
Store.questions = Array.isArray(data.questions) ? data.questions.map(q => ({
id: q.id || uid(),
subject: String(q.subject || ''),
content: String(q.content || ''),
difficulty: String(q.difficulty || '中等'),
})) : [];
Store.history = Array.isArray(data.history) ? data.history : [];
Store.save();
renderManage();
renderQuestion();
renderSubjectSelects();
toast('导入成功,共 ' + Store.students.length + ' 人,' + Store.questions.length + ' 题', 'success');
} catch (err) {
toast('导入失败:' + err.message, 'error');
}
};
reader.readAsText(file);
}
function exportQuestions() {
if (Store.questions.length === 0) { toast('暂无题目可导出', 'error'); return; }
const data = { questions: Store.questions, exportedAt: new Date().toISOString() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '题库_' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
toast('已导出题库', 'success');
}
function importQuestions(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
const qs = Array.isArray(data.questions) ? data.questions : (Array.isArray(data) ? data : null);
if (!qs) throw new Error('格式错误');
Store.questions = qs.map(q => ({
id: q.id || uid(),
subject: String(q.subject || ''),
content: String(q.content || ''),
difficulty: String(q.difficulty || '中等'),
}));
Store.save();
renderQuestion();
renderSubjectSelects();
toast('导入成功,共 ' + Store.questions.length + ' 题', 'success');
} catch (err) {
toast('导入失败:' + err.message, 'error');
}
};
reader.readAsText(file);
}
/* ============ 事件绑定 ============ */
function bindEvents() {
els.tabs.forEach(t => t.addEventListener('click', () => switchView(t.dataset.view)));
// 学生管理
els.addStudentBtn.addEventListener('click', () => openModal());
els.exportBtn.addEventListener('click', exportJSON);
els.importBtn.addEventListener('click', () => els.importInput.click());
els.importInput.addEventListener('change', (e) => {
if (e.target.files[0]) importJSON(e.target.files[0]);
e.target.value = '';
});
els.searchInput.addEventListener('input', renderManage);
els.studentTbody.addEventListener('click', (e) => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const tr = btn.closest('tr');
const id = tr.dataset.id;
if (btn.dataset.act === 'edit') openModal(id);
else if (btn.dataset.act === 'del') {
if (confirm('确定删除该学生?')) {
Store.removeStudent(id);
renderManage();
toast('已删除', 'success');
}
}
});
// 题目管理
els.addQuestionBtn.addEventListener('click', () => openQuestionModal());
els.exportQBtn.addEventListener('click', exportQuestions);
els.importQBtn.addEventListener('click', () => els.importQInput.click());
els.importQInput.addEventListener('change', (e) => {
if (e.target.files[0]) importQuestions(e.target.files[0]);
e.target.value = '';
});
els.qSearchInput.addEventListener('input', renderQuestion);
els.qSubjectFilter.addEventListener('change', renderQuestion);
els.questionTbody.addEventListener('click', (e) => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const tr = btn.closest('tr');
const id = tr.dataset.id;
if (btn.dataset.act === 'edit') openQuestionModal(id);
else if (btn.dataset.act === 'del') {
if (confirm('确定删除该题目?')) {
Store.removeQuestion(id);
renderQuestion();
renderSubjectSelects();
toast('已删除', 'success');
}
}
});
// 题目弹窗
els.qModalClose.addEventListener('click', closeQuestionModal);
els.qModalCancel.addEventListener('click', closeQuestionModal);
els.qModalSave.addEventListener('click', saveQuestion);
els.questionModal.addEventListener('click', (e) => { if (e.target === els.questionModal) closeQuestionModal(); });
els.difficultyPicker.addEventListener('click', (e) => {
const btn = e.target.closest('.diff-btn');
if (!btn) return;
selectedDifficulty = btn.dataset.diff;
updateDifficultyUI();
});
// 学生弹窗
els.modalClose.addEventListener('click', closeModal);
els.modalCancel.addEventListener('click', closeModal);
els.modalSave.addEventListener('click', saveStudent);
els.studentModal.addEventListener('click', (e) => { if (e.target === els.studentModal) closeModal(); });
els.formName.addEventListener('input', renderAvatarPicker);
}
/* ============ 初始化 ============ */
function init() {
// 数据改为后端 API 加载(失败回退本地缓存),加载完成后再渲染
Store.load().then(() => {
bindEvents();
renderManage();
renderQuestion();
renderSubjectSelects();
});
}
document.addEventListener('DOMContentLoaded', init);
})();
</script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
[.ShellClassInfo]
LocalizedResourceName=@同学点名,0
+253
View File
@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>课堂随机点名 · 同学点名</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@600;700;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- 顶部导航 -->
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28">
<rect x="3" y="6" width="26" height="20" rx="3" fill="#e8a838"/>
<rect x="3" y="6" width="26" height="6" rx="3" fill="#1a2238"/>
<circle cx="11" cy="19" r="3" fill="#f5f0e6"/>
<rect x="16" y="17" width="9" height="2" rx="1" fill="#1a2238"/>
<rect x="16" y="21" width="7" height="2" rx="1" fill="#1a2238"/>
</svg>
</span>
<h1 class="brand-title">同学点名</h1>
<span class="brand-sub">课堂随机抽取系统</span>
</div>
<nav class="tabs" role="tablist">
<button class="tab active" data-view="roll" role="tab">点名</button>
<button class="tab" data-view="settings" role="tab">设置</button>
</nav>
<div class="topbar-right">
<button class="btn btn-ghost btn-sm" id="refreshBtn" title="从服务器刷新学生名单与题库">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-3px;margin-right:4px;"><path d="M3 12a9 9 0 1 0 2.6-6.4"/><polyline points="3 4 3 10 9 10"/></svg>
刷新
</button>
<span class="count-badge" id="countBadge">0 人</span>
</div>
</header>
<main class="app">
<!-- ============ 点名视图 ============ -->
<section class="view view-roll active" id="view-roll">
<div class="roll-stage">
<!-- 抽取动画区 -->
<div class="roller" id="roller">
<div class="roller-card" id="rollerCard">
<div class="roller-avatar" id="rollerAvatar"></div>
<div class="roller-name" id="rollerName">准备就绪</div>
<div class="roller-tag" id="rollerTag">点击下方按钮开始点名</div>
</div>
</div>
<!-- 粒子层 -->
<canvas class="confetti" id="confetti" aria-hidden="true"></canvas>
<!-- 结果浮层 -->
<div class="result-toast" id="resultToast" hidden>
<div class="result-toast-inner">
<span class="result-label">本次抽中</span>
<span class="result-name" id="resultName"></span>
</div>
</div>
</div>
<!-- 多人抽取结果展示区(持久化) -->
<div class="multi-results" id="multiResults" hidden>
<div class="multi-results-head">
<span class="multi-results-title">本次抽取结果</span>
<span class="multi-results-count" id="multiResultsCount"></span>
</div>
<div class="multi-results-grid" id="multiResultsGrid"></div>
</div>
<!-- 题目结果展示区 -->
<div class="question-result" id="questionResult" hidden>
<div class="question-result-head">
<span class="qr-title">本次抽题</span>
</div>
<div class="question-display" id="questionDisplay">
<span class="question-placeholder">点击下方按钮开始抽题</span>
</div>
</div>
<!-- 模式开关 -->
<div class="mode-toggles">
<button class="mode-toggle active" data-toggle="person">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
抽人
</button>
<button class="mode-toggle" data-toggle="question">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
抽题
</button>
</div>
<!-- 控制栏 -->
<div class="controls">
<div class="person-controls" id="personControls">
<div class="mode-switch" id="modeSwitch">
<button class="mode-btn active" data-mode="single">单人抽取</button>
<button class="mode-btn" data-mode="multi">多人抽取</button>
<button class="mode-btn" data-mode="norepeat">不重复抽取</button>
</div>
<div class="group-filter">
<label class="group-label">分组</label>
<select class="group-select" id="groupSelect">
<option value="">全部学生</option>
</select>
</div>
</div>
<div class="question-controls" id="questionControls" hidden>
<select class="subject-select" id="subjectSelect">
<option value="">全部学科</option>
</select>
<label class="check-inline">
<input type="checkbox" id="questionNoRepeat" />
<span>不重复</span>
</label>
</div>
<button class="btn btn-primary btn-lg" id="rollBtn">
<span class="btn-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>
</span>
<span class="btn-text">开始</span>
</button>
<button class="btn btn-ghost" id="resetBtn" title="重置已抽中记录">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 4 3 10 9 10"/></svg>
重置
</button>
</div>
<!-- 多人抽取数量 -->
<div class="multi-config" id="multiConfig" hidden>
<label>抽取人数</label>
<div class="stepper">
<button class="step-btn" id="stepDown">−</button>
<span class="step-val" id="stepVal">3</span>
<button class="step-btn" id="stepUp">+</button>
</div>
</div>
<!-- 学生卡片墙 -->
<div class="wall-section">
<div class="section-head">
<h2 class="section-title">学生名单</h2>
<div class="wall-filter">
<button class="filter-btn active" data-filter="all">全部 <span id="cntAll">0</span></button>
<button class="filter-btn" data-filter="pending">待抽 <span id="cntPending">0</span></button>
<button class="filter-btn" data-filter="picked">已抽 <span id="cntPicked">0</span></button>
</div>
</div>
<div class="wall" id="wall"></div>
<div class="empty-state" id="wallEmpty" hidden>
<p>暂无学生数据</p>
<button class="btn btn-ghost" id="loadDemoBtn">加载示例数据</button>
</div>
</div>
<!-- 历史记录 -->
<div class="history-section" id="historySection">
<div class="section-head">
<h2 class="section-title">本次课堂记录</h2>
<button class="btn btn-ghost btn-sm" id="clearHistoryBtn">清空</button>
</div>
<div class="history-list" id="historyList"></div>
<div class="history-empty" id="historyEmpty">尚未抽取</div>
</div>
</section>
<!-- ============ 设置视图 ============ -->
<section class="view view-settings" id="view-settings">
<h2 class="section-title">设置</h2>
<div class="settings-card">
<h3 class="settings-group-title">抽取行为</h3>
<div class="setting-row">
<div class="setting-label">
<span class="setting-name">动画速度</span>
<span class="setting-desc">控制抽取动画的快慢</span>
</div>
<div class="setting-control">
<input type="range" id="speedRange" min="1" max="5" value="3" class="slider" />
<span class="setting-val" id="speedVal">标准</span>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-name">音效</span>
<span class="setting-desc">抽取时播放提示音</span>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" id="soundToggle" />
<span class="switch-track"><span class="switch-thumb"></span></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-name">自动记录历史</span>
<span class="setting-desc">将每次抽取结果记入课堂记录</span>
</div>
<div class="setting-control">
<label class="switch">
<input type="checkbox" id="historyToggle" checked />
<span class="switch-track"><span class="switch-thumb"></span></span>
</label>
</div>
</div>
</div>
<div class="settings-card">
<h3 class="settings-group-title">数据管理</h3>
<div class="setting-row">
<div class="setting-label">
<span class="setting-name">管理控制台</span>
<span class="setting-desc">学生与题目管理已迁移至管理员端</span>
</div>
<div class="setting-control">
<a class="btn btn-ghost btn-sm" href="./admin/" target="_blank" rel="noopener">前往管理端</a>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-name">示例数据</span>
<span class="setting-desc">加载一组示例学生用于体验</span>
</div>
<div class="setting-control">
<button class="btn btn-ghost btn-sm" id="loadDemoBtn2">加载示例</button>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<span class="setting-name">清空所有数据</span>
<span class="setting-desc">删除全部学生与课堂记录(不可恢复)</span>
</div>
<div class="setting-control">
<button class="btn btn-danger btn-sm" id="clearAllBtn">清空</button>
</div>
</div>
</div>
<p class="settings-foot">数据保存在浏览器本地 (localStorage),不会上传服务器。</p>
</section>
</main>
<!-- Toast 提示 -->
<div class="toast" id="toast" hidden></div>
<script src="script.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+32
View File
@@ -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"
}
+10
View File
@@ -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'
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>银河锋锐(GVSDS)</title>
<link rel="icon" href="https://hb.cdn.gvsds.com/assets/icon/ICON.png">
<script type="module" crossorigin src="/GVSite/index-jT0rbwPN.js"></script>
<link rel="stylesheet" crossorigin href="/GVSite/index-ZK75x5rL.css">
<script type="module" crossorigin src="/GVSite/index-BwXBcr1p.js"></script>
<link rel="stylesheet" crossorigin href="/GVSite/index-BJ4Iq5BO.css">
</head>
<body>
<div id="app"></div>
+7
View File
@@ -0,0 +1,7 @@
"""RollCall 应用——课堂随机点名数据持久化后端。
纯前端点名应用(TempLate/rollcall)由 localStorage 升级为后端 API 持久化:
学生/题目/课堂记录以 JSON 整体存储于 RollCallData 单表(DataKey 数据槽,默认 main)。
"""
default_app_config = 'gvsds.apps.rollcall.apps.RollCallConfig'
+8
View File
@@ -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'
+35
View File
@@ -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
@@ -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',
},
),
]
Whitespace-only changes.
+27
View File
@@ -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}]'
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path('Data', views.RollCallDataView.as_view()),
]
+49
View File
@@ -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})
+1
View File
@@ -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',
+3
View File
@@ -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 += [