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:
1 parent
9cb0d8e186
commit
f8d356f3ff
24 files changed
+3462
-6
No files matched your search
+14
-1
@@ -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/
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# 成员邮箱和 gitea 与 gerp 同步
|
||||
像阿里云一样的百科,解释 GERP 的各个机制
|
||||
一套配色系统(主题色和配色方案)
|
||||
持有产品
|
||||
代码,业绩审计
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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 种配色方案)
|
||||
- 居中显示学生姓名首字
|
||||
- 可选装饰:星星、书本、铅笔、帽子等教育元素
|
||||
@@ -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)
|
||||
@@ -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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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>
|
||||
@@ -0,0 +1,2 @@
|
||||
[.ShellClassInfo]
|
||||
LocalizedResourceName=@同学点名,0
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""RollCall 应用——课堂随机点名数据持久化后端。
|
||||
|
||||
纯前端点名应用(TempLate/rollcall)由 localStorage 升级为后端 API 持久化:
|
||||
学生/题目/课堂记录以 JSON 整体存储于 RollCallData 单表(DataKey 数据槽,默认 main)。
|
||||
"""
|
||||
|
||||
default_app_config = 'gvsds.apps.rollcall.apps.RollCallConfig'
|
||||
@@ -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'
|
||||
@@ -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.
@@ -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}]'
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('Data', views.RollCallDataView.as_view()),
|
||||
]
|
||||
@@ -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})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 += [
|
||||
|
||||
Reference in new issue
Block a user