commit bf215c4b9f3bf2a80929935a9a22f5df43add16b
Author: Harry <139662128+dison0331@users.noreply.github.com>
Date: Sat Apr 25 10:09:16 2026 +0800
First
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..279edbc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,244 @@
+# 点名系统
+
+一个基于Web的点名系统,支持后台管理和用户点名功能。**支持双模式运行:服务器模式和本地模式**。
+
+## 🌟 核心特性
+
+### 双模式运行
+- **服务器模式**:通过Node.js服务器运行,支持多用户、数据持久化
+- **本地模式**:直接打开HTML文件,使用浏览器localStorage存储,无需服务器
+
+### 功能特性
+- 管理员登录(默认账号:admin / admin123)
+- 创建点名名单,自动生成编号和二维码
+- 用户通过编号或二维码进入点名
+- 实时进度条显示
+- 拍照点名(支持人脸识别)
+- 手动点名
+- 数据实时同步
+
+## 🚀 快速开始
+
+### 方式一:本地模式(推荐新手)
+
+**无需安装Node.js,直接使用!**
+
+1. **打开后台管理**
+ - 直接双击 `admin.html` 文件
+ - 或在浏览器中打开 `admin.html`
+
+2. **登录系统**
+ - 用户名: `admin`
+ - 密码: `admin123`
+
+3. **创建点名名单**
+ - 输入名单名称和成员
+ - 系统自动生成编号和二维码
+
+4. **用户点名**
+ - 打开 `index.html`
+ - 输入编号进入点名
+
+**优点**:
+- ✅ 无需安装任何软件
+- ✅ 即开即用
+- ✅ 数据保存在浏览器本地
+
+**注意**:
+- ⚠️ 数据仅保存在当前浏览器
+- ⚠️ 清除浏览器数据会丢失记录
+- ⚠️ 不同浏览器/电脑数据不共享
+
+### 方式二:服务器模式(推荐正式使用)
+
+**需要Node.js环境**
+
+1. **启动服务器**
+ ```bash
+ # 方法1: 使用批处理文件
+ 双击 "启动服务器.bat"
+
+ # 方法2: 命令行启动
+ cd 手机点名器
+ node server.js
+ ```
+
+2. **访问系统**
+ - 后台管理: http://localhost:3000/admin.html
+ - 用户点名: http://localhost:3000/index.html
+
+3. **登录系统**
+ - 用户名: `admin`
+ - 密码: `admin123`
+
+**优点**:
+- ✅ 数据持久化到文件
+- ✅ 支持多用户访问
+- ✅ 数据不会丢失
+- ✅ 可以在局域网使用
+
+## 📖 详细使用说明
+
+### 后台管理操作
+
+1. **创建点名名单**
+ - 输入名单名称(如:"2024春季班点名")
+ - 输入成员名单(每行一个姓名)
+ - 点击"生成"按钮
+ - 系统自动生成编号和二维码
+
+2. **管理名单**
+ - 查看二维码:显示编号和二维码
+ - 编辑:修改名单名称和成员
+ - 删除:删除整个名单
+
+3. **查看记录**
+ - 查看所有点名记录
+ - 显示点名时间、名单、编号
+
+### 用户点名操作
+
+1. **进入点名**
+ - 方式1:输入编号
+ - 方式2:扫描二维码(需摄像头)
+ - 方式3:直接访问带参数的URL
+
+2. **点名方式**
+ - **拍照点名**:开启摄像头拍照,系统自动识别
+ - **手动点名**:从下拉列表选择姓名
+
+3. **查看进度**
+ - 实时进度条显示
+ - 已点名/总人数统计
+ - 成员状态标记
+
+## 🔄 模式自动切换
+
+系统会自动检测运行环境:
+
+| 访问方式 | 运行模式 | 数据存储 |
+|---------|---------|---------|
+| 双击HTML文件 | 本地模式 | localStorage |
+| file://协议 | 本地模式 | localStorage |
+| http://localhost | 服务器模式 | 服务器文件 |
+| http://域名 | 服务器模式 | 服务器文件 |
+
+## 📁 文件结构
+
+```
+手机点名器/
+├── server.js # Node.js后端服务器
+├── package.json # 项目配置
+├── storage.js # 通用数据存储模块(核心)
+├── admin.html # 后台管理页面
+├── index.html # 用户点名页面
+├── styles.css # 共享样式
+├── admin.js # 后台管理逻辑
+├── index.js # 用户点名逻辑
+├── qrcode.js # 本地二维码生成器
+├── 启动服务器.bat # Windows启动脚本
+├── 启动说明.md # 启动说明
+├── data_lists.json # 点名列表数据(服务器模式)
+├── data_records.json # 点名记录数据(服务器模式)
+├── idea.md # 需求文档
+└── README.md # 使用说明
+```
+
+## 🛠️ 技术架构
+
+### 前端
+- 纯HTML/CSS/JavaScript
+- 无框架依赖
+- 响应式设计
+- 本地二维码生成
+
+### 后端(可选)
+- Node.js原生HTTP服务器
+- RESTful API设计
+- 文件系统数据存储
+- CORS支持
+
+### 数据存储
+- **本地模式**:浏览器localStorage
+- **服务器模式**:JSON文件持久化
+
+## 📝 API接口文档(服务器模式)
+
+### 登录
+- **URL**: `/api/login`
+- **方法**: POST
+- **参数**: `{ username, password }`
+
+### 创建点名名单
+- **URL**: `/api/create-list`
+- **方法**: POST
+- **参数**: `{ name, members: [] }`
+
+### 获取所有名单
+- **URL**: `/api/get-lists`
+- **方法**: GET
+
+### 获取单个名单
+- **URL**: `/api/get-list?code=xxx`
+- **方法**: GET
+
+### 更新名单
+- **URL**: `/api/update-list`
+- **方法**: POST
+- **参数**: `{ id, name, members: [] }`
+
+### 删除名单
+- **URL**: `/api/delete-list`
+- **方法**: POST
+- **参数**: `{ id }`
+
+### 点名
+- **URL**: `/api/roll-call`
+- **方法**: POST
+- **参数**: `{ code, name }`
+
+### 获取点名记录
+- **URL**: `/api/get-records`
+- **方法**: GET
+
+## ❓ 常见问题
+
+### Q1: 本地模式下数据会丢失吗?
+A: 数据保存在浏览器localStorage中,除非清除浏览器数据,否则不会丢失。
+
+### Q2: 可以在不同电脑上使用吗?
+A:
+- 本地模式:每台电脑独立数据,不共享
+- 服务器模式:可以通过局域网IP访问,数据共享
+
+### Q3: 如何在手机上使用?
+A:
+- 本地模式:将HTML文件传到手机,用浏览器打开
+- 服务器模式:通过局域网IP访问(如 http://192.168.1.100:3000)
+
+### Q4: 服务器模式如何停止?
+A: 在命令行窗口按 Ctrl+C 或关闭窗口
+
+### Q5: 端口被占用怎么办?
+A: 修改 server.js 中的 `const PORT = 3000;` 为其他端口
+
+## 🔧 扩展建议
+
+1. 集成真实的人脸识别API
+2. 使用数据库替代文件存储
+3. 实现扫码功能
+4. 添加数据导出功能
+5. 支持批量导入名单
+6. 添加用户权限管理
+7. 实现WebSocket实时通信
+8. 添加日志记录功能
+
+## 📄 许可证
+
+MIT License
+
+---
+
+**推荐使用方式**:
+- 🎯 **学习/测试**:使用本地模式,简单快捷
+- 🏢 **正式使用**:使用服务器模式,稳定可靠
diff --git a/admin.html b/admin.html
new file mode 100644
index 0000000..aaba075
--- /dev/null
+++ b/admin.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ 点名系统 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
×
+
点名信息
+
+
编号:
+
请扫描二维码或输入编号进行点名
+
+
+
+
+
+
+
+
+
diff --git a/admin.js b/admin.js
new file mode 100644
index 0000000..75657a5
--- /dev/null
+++ b/admin.js
@@ -0,0 +1,264 @@
+// 后台管理JavaScript - 支持服务器和本地双模式
+
+// 状态管理
+let isLoggedIn = sessionStorage.getItem('isAdminLoggedIn') === 'true';
+let authToken = sessionStorage.getItem('authToken') || '';
+
+// DOM元素
+const loginSection = document.getElementById('loginSection');
+const adminPanel = document.getElementById('adminPanel');
+const loginForm = document.getElementById('loginForm');
+const logoutBtn = document.getElementById('logoutBtn');
+const createListForm = document.getElementById('createListForm');
+const rollCallListsDiv = document.getElementById('rollCallLists');
+const rollCallRecordsDiv = document.getElementById('rollCallRecords');
+const qrModal = document.getElementById('qrModal');
+const qrCodeDiv = document.getElementById('qrCode');
+const rollCallCodeSpan = document.getElementById('rollCallCode');
+
+// QRCode实例
+const qrCode = new SimpleQRCode();
+
+// 初始化
+document.addEventListener('DOMContentLoaded', () => {
+ // 显示运行模式
+ const modeText = dataStorage.isServerMode ? '服务器模式' : '本地模式';
+ const modeIndicator = document.getElementById('modeIndicator');
+
+ if (modeIndicator) {
+ modeIndicator.textContent = `当前运行模式: ${modeText}`;
+ if (!dataStorage.isServerMode) {
+ modeIndicator.textContent += ' (数据保存在浏览器本地)';
+ }
+ }
+
+ console.log(`点名系统运行在: ${modeText}`);
+
+ if (isLoggedIn) {
+ showAdminPanel();
+ loadLists();
+ loadRecords();
+ }
+});
+
+// 登录
+loginForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const username = document.getElementById('username').value;
+ const password = document.getElementById('password').value;
+
+ try {
+ const result = await dataStorage.login(username, password);
+
+ isLoggedIn = true;
+ authToken = result.token;
+ sessionStorage.setItem('isAdminLoggedIn', 'true');
+ sessionStorage.setItem('authToken', authToken);
+
+ showAdminPanel();
+ await loadLists();
+ await loadRecords();
+ } catch (error) {
+ alert('登录失败:' + error.message);
+ }
+});
+
+// 退出登录
+logoutBtn.addEventListener('click', () => {
+ isLoggedIn = false;
+ authToken = '';
+ sessionStorage.removeItem('isAdminLoggedIn');
+ sessionStorage.removeItem('authToken');
+ loginSection.style.display = 'block';
+ adminPanel.style.display = 'none';
+});
+
+// 显示管理面板
+function showAdminPanel() {
+ loginSection.style.display = 'none';
+ adminPanel.style.display = 'block';
+}
+
+// 加载点名列表
+async function loadLists() {
+ try {
+ const lists = await dataStorage.getLists();
+ renderLists(lists);
+ } catch (error) {
+ console.error('加载列表失败:', error);
+ rollCallListsDiv.innerHTML = '加载失败:' + error.message + '
';
+ }
+}
+
+// 加载点名记录
+async function loadRecords() {
+ try {
+ const records = await dataStorage.getRecords();
+ renderRecords(records);
+ } catch (error) {
+ console.error('加载记录失败:', error);
+ rollCallRecordsDiv.innerHTML = '加载失败:' + error.message + '
';
+ }
+}
+
+// 创建点名名单
+createListForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const listName = document.getElementById('listName').value;
+ const memberListText = document.getElementById('memberList').value;
+ const members = memberListText.split('\n').filter(name => name.trim() !== '').map(name => name.trim());
+
+ try {
+ const newList = await dataStorage.createList(listName, members);
+
+ // 清空表单
+ createListForm.reset();
+
+ // 重新加载列表
+ await loadLists();
+
+ // 显示二维码
+ showQRCode(newList);
+ } catch (error) {
+ alert('创建失败:' + error.message);
+ }
+});
+
+// 显示二维码
+function showQRCode(list) {
+ qrModal.style.display = 'block';
+ rollCallCodeSpan.textContent = list.code;
+
+ // 生成二维码URL
+ let url;
+ if (dataStorage.isServerMode) {
+ // 服务器模式:使用当前域名
+ url = `${window.location.origin}${window.location.pathname.replace('admin.html', 'index.html')}?code=${list.code}`;
+ } else {
+ // 本地模式:使用相对路径
+ url = `index.html?code=${list.code}`;
+ }
+
+ qrCodeDiv.innerHTML = '';
+
+ // 创建canvas元素
+ const canvas = document.createElement('canvas');
+ qrCodeDiv.appendChild(canvas);
+
+ // 使用本地QRCode生成器
+ try {
+ qrCode.toCanvas(canvas, url, {
+ width: 200,
+ margin: 2
+ });
+ } catch (error) {
+ console.error('二维码生成错误:', error);
+ qrCodeDiv.innerHTML = '二维码生成失败
';
+ }
+}
+
+// 关闭弹窗
+document.querySelector('.close').addEventListener('click', () => {
+ qrModal.style.display = 'none';
+});
+
+window.addEventListener('click', (e) => {
+ if (e.target === qrModal) {
+ qrModal.style.display = 'none';
+ }
+});
+
+// 渲染点名列表
+function renderLists(lists) {
+ if (!lists || lists.length === 0) {
+ rollCallListsDiv.innerHTML = '暂无点名列表
';
+ return;
+ }
+
+ rollCallListsDiv.innerHTML = lists.map(list => `
+
+
+
创建时间:${list.createdAt}
+
成员数:${list.members.length} | 已点名:${list.members.filter(m => m.completed).length}
+
+
+
+
+
+
+ `).join('');
+}
+
+// 显示二维码(通过ID)
+async function showQRCodeById(id) {
+ try {
+ const lists = await dataStorage.getLists();
+ const list = lists.find(l => l.id === id);
+ if (list) {
+ showQRCode(list);
+ }
+ } catch (error) {
+ alert('获取列表失败:' + error.message);
+ }
+}
+
+// 编辑名单
+async function editList(id) {
+ try {
+ const lists = await dataStorage.getLists();
+ const list = lists.find(l => l.id === id);
+ if (!list) return;
+
+ const newName = prompt('请输入新的名单名称:', list.name);
+ if (newName === null) return;
+
+ const newMembersText = prompt('请输入新的名单成员(每行一个姓名):', list.members.map(m => m.name).join('\n'));
+ if (newMembersText === null) return;
+
+ const newMembers = newMembersText.split('\n').filter(name => name.trim() !== '').map(name => name.trim());
+
+ await dataStorage.updateList(id, newName, newMembers);
+ await loadLists();
+ } catch (error) {
+ alert('编辑失败:' + error.message);
+ }
+}
+
+// 删除名单
+async function deleteList(id) {
+ if (confirm('确定要删除这个点名名单吗?')) {
+ try {
+ await dataStorage.deleteList(id);
+ await loadLists();
+ } catch (error) {
+ alert('删除失败:' + error.message);
+ }
+ }
+}
+
+// 渲染点名记录
+function renderRecords(records) {
+ if (!records || records.length === 0) {
+ rollCallRecordsDiv.innerHTML = '暂无点名记录
';
+ return;
+ }
+
+ rollCallRecordsDiv.innerHTML = records.map(record => `
+
+
+
名单:${record.listName}
+
编号:${record.code}
+
+ `).join('');
+}
+
+// 暴露函数到全局
+window.showQRCodeById = showQRCodeById;
+window.editList = editList;
+window.deleteList = deleteList;
diff --git a/idea.md b/idea.md
new file mode 100644
index 0000000..acbc5df
--- /dev/null
+++ b/idea.md
@@ -0,0 +1,26 @@
+用于点名
+
+功能:
+ 1、后台设置点名名单,生成二维码和编号(6-12位数字)
+ 2、用户扫描二维码或输入编号,即可点名
+ 3、进度条展示已点名人数,并实时上传到后台
+ 4、后台可以查看所有点名记录
+
+1、后台设置点名名单,生成二维码和编号:
+ - 后台登陆后,点击“新增点名名单”按钮
+ - 输入点名名单,点击“生成”按钮
+ - 点击”生成“按钮,系统会自动生成二维码和编号
+ - 名单可在后台修改、添加、删除
+
+2、用户扫描二维码或输入编号,即可点名:
+ - 打开点名网页
+ - 扫描二维码或输入编号
+ - 点击“进入点名”按钮
+ - 点统会更新进度条和已点名人数
+ - 点统会将点名记录上传到后台
+ - 后台可以查看所有点名记录
+
+3、点名方式:
+ - 用户拍摄照片上传,系统会自动识别照片中的人脸
+ - 系统会根据照片中的人脸,自动匹配名单中的用户
+ - 若不属于名单中的人脸,要求用户进行确认
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..140b3ab
--- /dev/null
+++ b/index.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+ 点名系统 - 用户点名
+
+
+
+
+
+
+
+
+
+
+
+
+ 点名
+
+
+
+
+
+
+
+
+
+
拍照点名
+
+
+
+
+
+
+
+
+
+
+
![拍摄的照片]()
+
+
+
+
+
+
+
+
+
+
手动点名
+
如果人脸识别失败,可以手动选择姓名进行点名:
+
+
+
+
+
+
+
+
+
+
+
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..aaa1acf
--- /dev/null
+++ b/index.js
@@ -0,0 +1,228 @@
+// 用户点名JavaScript - 支持服务器和本地双模式
+
+// 状态管理
+let currentList = null;
+let stream = null;
+
+// DOM元素
+const inputSection = document.getElementById('inputSection');
+const rollCallSection = document.getElementById('rollCallSection');
+const enterForm = document.getElementById('enterForm');
+const codeInput = document.getElementById('codeInput');
+const scanQRBtn = document.getElementById('scanQRBtn');
+const completedCount = document.getElementById('completedCount');
+const totalCount = document.getElementById('totalCount');
+const progressFill = document.getElementById('progressFill');
+const memberListDisplay = document.getElementById('memberListDisplay');
+const video = document.getElementById('video');
+const canvas = document.getElementById('canvas');
+const startCameraBtn = document.getElementById('startCameraBtn');
+const captureBtn = document.getElementById('captureBtn');
+const stopCameraBtn = document.getElementById('stopCameraBtn');
+const capturedPhoto = document.getElementById('capturedPhoto');
+const photoPreview = document.getElementById('photoPreview');
+const confirmPhotoBtn = document.getElementById('confirmPhotoBtn');
+const retakeBtn = document.getElementById('retakeBtn');
+const manualSelect = document.getElementById('manualSelect');
+const manualRollCallBtn = document.getElementById('manualRollCallBtn');
+
+// 初始化
+document.addEventListener('DOMContentLoaded', () => {
+ // 显示运行模式
+ const modeText = dataStorage.isServerMode ? '服务器模式' : '本地模式';
+ const modeIndicator = document.getElementById('modeIndicator');
+
+ if (modeIndicator) {
+ modeIndicator.textContent = `当前运行模式: ${modeText}`;
+ if (!dataStorage.isServerMode) {
+ modeIndicator.textContent += ' (数据保存在浏览器本地)';
+ }
+ }
+
+ console.log(`点名系统运行在: ${modeText}`);
+
+ // 检查URL参数
+ const urlParams = new URLSearchParams(window.location.search);
+ const codeFromUrl = urlParams.get('code');
+ if (codeFromUrl) {
+ codeInput.value = codeFromUrl;
+ enterRollCall(codeFromUrl);
+ }
+});
+
+// 进入点名
+enterForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const code = codeInput.value.trim();
+ enterRollCall(code);
+});
+
+async function enterRollCall(code) {
+ try {
+ currentList = await dataStorage.getList(code);
+
+ inputSection.style.display = 'none';
+ rollCallSection.style.display = 'block';
+
+ updateProgress();
+ renderMemberList();
+ populateManualSelect();
+ } catch (error) {
+ alert('进入点名失败:' + error.message);
+ }
+}
+
+// 扫描二维码(简化版)
+scanQRBtn.addEventListener('click', () => {
+ alert('扫码功能需要集成摄像头扫码库,当前版本请手动输入编号');
+});
+
+// 更新进度
+function updateProgress() {
+ if (!currentList) return;
+
+ const completed = currentList.members.filter(m => m.completed).length;
+ const total = currentList.members.length;
+ const percentage = total > 0 ? (completed / total * 100) : 0;
+
+ completedCount.textContent = completed;
+ totalCount.textContent = total;
+ progressFill.style.width = percentage + '%';
+ progressFill.textContent = Math.round(percentage) + '%';
+}
+
+// 渲染成员列表
+function renderMemberList() {
+ if (!currentList) return;
+
+ memberListDisplay.innerHTML = currentList.members.map((member, index) => `
+
+ ${member.name}
+
+ ${member.completed ? '已点名' : '未点名'}
+
+
+ `).join('');
+}
+
+// 填充手动选择下拉框
+function populateManualSelect() {
+ if (!currentList) return;
+
+ const uncompletedMembers = currentList.members.filter(m => !m.completed);
+ manualSelect.innerHTML = '' +
+ uncompletedMembers.map(member => ``).join('');
+}
+
+// 开启摄像头
+startCameraBtn.addEventListener('click', async () => {
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({ video: true });
+ video.srcObject = stream;
+ video.style.display = 'block';
+ startCameraBtn.style.display = 'none';
+ captureBtn.style.display = 'inline-block';
+ stopCameraBtn.style.display = 'inline-block';
+ } catch (err) {
+ alert('无法访问摄像头,请检查权限设置');
+ console.error(err);
+ }
+});
+
+// 拍照
+captureBtn.addEventListener('click', () => {
+ const context = canvas.getContext('2d');
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
+
+ const dataURL = canvas.toDataURL('image/jpeg');
+ photoPreview.src = dataURL;
+
+ capturedPhoto.style.display = 'block';
+ video.style.display = 'none';
+ captureBtn.style.display = 'none';
+});
+
+// 关闭摄像头
+stopCameraBtn.addEventListener('click', () => {
+ if (stream) {
+ stream.getTracks().forEach(track => track.stop());
+ stream = null;
+ }
+ video.style.display = 'block';
+ capturedPhoto.style.display = 'none';
+ startCameraBtn.style.display = 'inline-block';
+ captureBtn.style.display = 'none';
+ stopCameraBtn.style.display = 'none';
+});
+
+// 确认照片
+confirmPhotoBtn.addEventListener('click', () => {
+ // 模拟人脸识别
+ const recognizedName = simulateFaceRecognition();
+
+ if (recognizedName) {
+ markAsCompleted(recognizedName);
+ alert(`识别成功:${recognizedName}`);
+ } else {
+ alert('未能识别,请手动选择姓名');
+ }
+
+ // 重置拍照区域
+ capturedPhoto.style.display = 'none';
+ video.style.display = 'block';
+ captureBtn.style.display = 'inline-block';
+});
+
+// 重新拍摄
+retakeBtn.addEventListener('click', () => {
+ capturedPhoto.style.display = 'none';
+ video.style.display = 'block';
+ captureBtn.style.display = 'inline-block';
+});
+
+// 模拟人脸识别
+function simulateFaceRecognition() {
+ // 随机返回一个未点名的成员(模拟识别)
+ const uncompletedMembers = currentList.members.filter(m => !m.completed);
+ if (uncompletedMembers.length === 0) return null;
+
+ const randomIndex = Math.floor(Math.random() * uncompletedMembers.length);
+ return uncompletedMembers[randomIndex].name;
+}
+
+// 手动点名
+manualRollCallBtn.addEventListener('click', () => {
+ const selectedName = manualSelect.value;
+ if (!selectedName) {
+ alert('请选择姓名');
+ return;
+ }
+ markAsCompleted(selectedName);
+});
+
+// 标记为已完成
+async function markAsCompleted(name) {
+ try {
+ await dataStorage.rollCall(currentList.code, name);
+
+ // 更新本地数据
+ const member = currentList.members.find(m => m.name === name);
+ if (member) {
+ member.completed = true;
+ member.timestamp = new Date().toLocaleString();
+ }
+
+ // 更新UI
+ updateProgress();
+ renderMemberList();
+ populateManualSelect();
+
+ // 检查是否全部完成
+ const allCompleted = currentList.members.every(m => m.completed);
+ if (allCompleted) {
+ alert('点名完成!所有成员已点名');
+ }
+ } catch (error) {
+ alert('点名失败:' + error.message);
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c74ebcd
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "roll-call-system",
+ "version": "1.0.0",
+ "description": "点名系统 - 支持后台管理和用户点名",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node server.js"
+ },
+ "keywords": [
+ "roll-call",
+ "attendance",
+ "qr-code",
+ "nodejs"
+ ],
+ "author": "",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+}
diff --git a/qrcode.js b/qrcode.js
new file mode 100644
index 0000000..dcb842f
--- /dev/null
+++ b/qrcode.js
@@ -0,0 +1,182 @@
+/**
+ * 简单的二维码生成器
+ * 使用Canvas API实现,不依赖外部库
+ */
+class SimpleQRCode {
+ constructor() {
+ // QR码版本和大小映射
+ this.sizeMap = {
+ 1: 21, 2: 25, 3: 29, 4: 33, 5: 37, 6: 41, 7: 45, 8: 49, 9: 53, 10: 57
+ };
+ }
+
+ /**
+ * 生成二维码到Canvas
+ */
+ toCanvas(canvas, text, options = {}) {
+ const size = options.width || 200;
+ const margin = options.margin || 2;
+
+ canvas.width = size;
+ canvas.height = size;
+
+ const ctx = canvas.getContext('2d');
+
+ // 白色背景
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, size, size);
+
+ // 生成QR码数据
+ const qrData = this.generateQRData(text);
+
+ // 计算单元格大小
+ const moduleCount = qrData.length;
+ const cellSize = (size - margin * 2) / moduleCount;
+
+ // 绘制QR码
+ ctx.fillStyle = '#000000';
+ for (let row = 0; row < moduleCount; row++) {
+ for (let col = 0; col < moduleCount; col++) {
+ if (qrData[row][col]) {
+ const x = margin + col * cellSize;
+ const y = margin + row * cellSize;
+ ctx.fillRect(x, y, cellSize, cellSize);
+ }
+ }
+ }
+
+ return canvas;
+ }
+
+ /**
+ * 生成QR码数据矩阵
+ */
+ generateQRData(text) {
+ // 简化版本:生成一个基本的QR码结构
+ const version = this.getVersion(text.length);
+ const size = this.sizeMap[version] || 21;
+
+ // 创建数据矩阵
+ const matrix = Array(size).fill(null).map(() => Array(size).fill(false));
+
+ // 添加定位图案
+ this.addFinderPattern(matrix, 0, 0);
+ this.addFinderPattern(matrix, size - 7, 0);
+ this.addFinderPattern(matrix, 0, size - 7);
+
+ // 添加定时图案
+ this.addTimingPattern(matrix, size);
+
+ // 添加数据(简化版本)
+ this.addData(matrix, text, size);
+
+ return matrix;
+ }
+
+ /**
+ * 添加定位图案
+ */
+ addFinderPattern(matrix, row, col) {
+ // 外框
+ for (let i = 0; i < 7; i++) {
+ matrix[row][col + i] = true;
+ matrix[row + 6][col + i] = true;
+ matrix[row + i][col] = true;
+ matrix[row + i][col + 6] = true;
+ }
+
+ // 内框
+ for (let i = 2; i < 5; i++) {
+ for (let j = 2; j < 5; j++) {
+ matrix[row + i][col + j] = true;
+ }
+ }
+ }
+
+ /**
+ * 添加定时图案
+ */
+ addTimingPattern(matrix, size) {
+ for (let i = 8; i < size - 8; i++) {
+ matrix[6][i] = i % 2 === 0;
+ matrix[i][6] = i % 2 === 0;
+ }
+ }
+
+ /**
+ * 添加数据(简化版本)
+ */
+ addData(matrix, text, size) {
+ // 将文本转换为二进制数据
+ const binaryData = this.textToBinary(text);
+
+ let dataIndex = 0;
+ let upward = true;
+
+ // 从右下角开始填充数据
+ for (let col = size - 1; col > 0; col -= 2) {
+ if (col === 6) col--; // 跳过定时图案列
+
+ for (let row = upward ? size - 1 : 0;
+ upward ? row >= 0 : row < size;
+ upward ? row-- : row++) {
+
+ for (let c = 0; c < 2; c++) {
+ const currentCol = col - c;
+ if (this.isDataArea(row, currentCol, size)) {
+ if (dataIndex < binaryData.length) {
+ matrix[row][currentCol] = binaryData[dataIndex] === '1';
+ dataIndex++;
+ }
+ }
+ }
+ }
+ upward = !upward;
+ }
+ }
+
+ /**
+ * 检查是否为数据区域
+ */
+ isDataArea(row, col, size) {
+ // 检查是否在定位图案区域
+ if (row < 9 && col < 9) return false;
+ if (row < 9 && col > size - 9) return false;
+ if (row > size - 9 && col < 9) return false;
+ // 检查是否在定时图案区域
+ if (row === 6 || col === 6) return false;
+
+ return true;
+ }
+
+ /**
+ * 文本转二进制
+ */
+ textToBinary(text) {
+ let binary = '';
+ for (let i = 0; i < text.length; i++) {
+ const charCode = text.charCodeAt(i);
+ binary += charCode.toString(2).padStart(8, '0');
+ }
+ return binary;
+ }
+
+ /**
+ * 根据数据长度获取QR码版本
+ */
+ getVersion(length) {
+ if (length <= 17) return 1;
+ if (length <= 32) return 2;
+ if (length <= 53) return 3;
+ if (length <= 78) return 4;
+ if (length <= 106) return 5;
+ if (length <= 134) return 6;
+ if (length <= 154) return 7;
+ if (length <= 192) return 8;
+ if (length <= 230) return 9;
+ return 10;
+ }
+}
+
+// 创建全局实例
+window.SimpleQRCode = SimpleQRCode;
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..ac297c3
--- /dev/null
+++ b/server.js
@@ -0,0 +1,314 @@
+/**
+ * 点名系统后端API服务器
+ * 使用Node.js原生HTTP模块实现
+ */
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const url = require('url');
+
+// 数据存储(实际应用中应使用数据库)
+let rollCallLists = [];
+let rollCallRecords = [];
+
+// 从文件加载数据
+function loadData() {
+ try {
+ const listsData = fs.readFileSync('data_lists.json', 'utf8');
+ rollCallLists = JSON.parse(listsData);
+ } catch (e) {
+ rollCallLists = [];
+ }
+
+ try {
+ const recordsData = fs.readFileSync('data_records.json', 'utf8');
+ rollCallRecords = JSON.parse(recordsData);
+ } catch (e) {
+ rollCallRecords = [];
+ }
+}
+
+// 保存数据到文件
+function saveData() {
+ fs.writeFileSync('data_lists.json', JSON.stringify(rollCallLists, null, 2));
+ fs.writeFileSync('data_records.json', JSON.stringify(rollCallRecords, null, 2));
+}
+
+// 生成随机编号
+function generateCode() {
+ const length = Math.floor(Math.random() * 7) + 6; // 6-12位
+ let code = '';
+ for (let i = 0; i < length; i++) {
+ code += Math.floor(Math.random() * 10);
+ }
+ return code;
+}
+
+// 创建HTTP服务器
+const server = http.createServer((req, res) => {
+ // 设置CORS头
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+
+ // 处理OPTIONS请求
+ if (req.method === 'OPTIONS') {
+ res.writeHead(200);
+ res.end();
+ return;
+ }
+
+ const parsedUrl = url.parse(req.url, true);
+ const pathname = parsedUrl.pathname;
+
+ // API路由
+ if (pathname.startsWith('/api/')) {
+ handleAPI(req, res, pathname, parsedUrl.query);
+ return;
+ }
+
+ // 静态文件服务
+ serveStatic(req, res, pathname);
+});
+
+// 处理API请求
+function handleAPI(req, res, pathname, query) {
+ let body = '';
+
+ req.on('data', chunk => {
+ body += chunk.toString();
+ });
+
+ req.on('end', () => {
+ let data = {};
+ if (body) {
+ try {
+ data = JSON.parse(body);
+ } catch (e) {
+ sendError(res, '无效的JSON数据');
+ return;
+ }
+ }
+
+ // 路由处理
+ switch (pathname) {
+ case '/api/login':
+ handleLogin(res, data);
+ break;
+ case '/api/create-list':
+ handleCreateList(res, data);
+ break;
+ case '/api/get-lists':
+ handleGetLists(res);
+ break;
+ case '/api/get-list':
+ handleGetList(res, query);
+ break;
+ case '/api/update-list':
+ handleUpdateList(res, data);
+ break;
+ case '/api/delete-list':
+ handleDeleteList(res, data);
+ break;
+ case '/api/roll-call':
+ handleRollCall(res, data);
+ break;
+ case '/api/get-records':
+ handleGetRecords(res);
+ break;
+ default:
+ sendError(res, '未知的API路径', 404);
+ }
+ });
+}
+
+// 登录处理
+function handleLogin(res, data) {
+ const { username, password } = data;
+
+ if (username === 'admin' && password === 'admin123') {
+ sendSuccess(res, { token: 'admin-token-' + Date.now() });
+ } else {
+ sendError(res, '用户名或密码错误', 401);
+ }
+}
+
+// 创建点名名单
+function handleCreateList(res, data) {
+ const { name, members } = data;
+
+ if (!name || !members || !Array.isArray(members)) {
+ sendError(res, '参数错误');
+ return;
+ }
+
+ const newList = {
+ id: Date.now(),
+ name: name,
+ code: generateCode(),
+ members: members.map(memberName => ({
+ name: memberName,
+ completed: false,
+ timestamp: null
+ })),
+ createdAt: new Date().toLocaleString()
+ };
+
+ rollCallLists.push(newList);
+ saveData();
+
+ sendSuccess(res, newList);
+}
+
+// 获取所有名单
+function handleGetLists(res) {
+ sendSuccess(res, rollCallLists);
+}
+
+// 获取单个名单
+function handleGetList(res, query) {
+ const code = query.code;
+ const list = rollCallLists.find(l => l.code === code);
+
+ if (list) {
+ sendSuccess(res, list);
+ } else {
+ sendError(res, '名单不存在', 404);
+ }
+}
+
+// 更新名单
+function handleUpdateList(res, data) {
+ const { id, name, members } = data;
+ const list = rollCallLists.find(l => l.id === id);
+
+ if (!list) {
+ sendError(res, '名单不存在', 404);
+ return;
+ }
+
+ if (name) list.name = name;
+ if (members && Array.isArray(members)) {
+ list.members = members.map(memberName => {
+ const existing = list.members.find(m => m.name === memberName);
+ return existing || { name: memberName, completed: false, timestamp: null };
+ });
+ }
+
+ saveData();
+ sendSuccess(res, list);
+}
+
+// 删除名单
+function handleDeleteList(res, data) {
+ const { id } = data;
+ const index = rollCallLists.findIndex(l => l.id === id);
+
+ if (index === -1) {
+ sendError(res, '名单不存在', 404);
+ return;
+ }
+
+ rollCallLists.splice(index, 1);
+ saveData();
+ sendSuccess(res, { message: '删除成功' });
+}
+
+// 点名
+function handleRollCall(res, data) {
+ const { code, name } = data;
+ const list = rollCallLists.find(l => l.code === code);
+
+ if (!list) {
+ sendError(res, '名单不存在', 404);
+ return;
+ }
+
+ const member = list.members.find(m => m.name === name);
+ if (!member) {
+ sendError(res, '成员不存在', 404);
+ return;
+ }
+
+ if (member.completed) {
+ sendError(res, '该成员已点名');
+ return;
+ }
+
+ member.completed = true;
+ member.timestamp = new Date().toLocaleString();
+
+ // 添加记录
+ rollCallRecords.push({
+ name: name,
+ listName: list.name,
+ code: code,
+ timestamp: member.timestamp
+ });
+
+ saveData();
+ sendSuccess(res, { message: '点名成功', member: member });
+}
+
+// 获取点名记录
+function handleGetRecords(res) {
+ sendSuccess(res, rollCallRecords);
+}
+
+// 静态文件服务
+function serveStatic(req, res, pathname) {
+ let filePath = pathname === '/' ? '/index.html' : pathname;
+ filePath = path.join(__dirname, filePath);
+
+ const extname = path.extname(filePath);
+ const contentTypes = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.gif': 'image/gif'
+ };
+
+ const contentType = contentTypes[extname] || 'text/plain';
+
+ fs.readFile(filePath, (err, content) => {
+ if (err) {
+ if (err.code === 'ENOENT') {
+ res.writeHead(404);
+ res.end('文件未找到');
+ } else {
+ res.writeHead(500);
+ res.end('服务器错误');
+ }
+ } else {
+ res.writeHead(200, { 'Content-Type': contentType });
+ res.end(content);
+ }
+ });
+}
+
+// 发送成功响应
+function sendSuccess(res, data) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: true, data: data }));
+}
+
+// 发送错误响应
+function sendError(res, message, code = 400) {
+ res.writeHead(code, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: false, error: message }));
+}
+
+// 启动服务器
+const PORT = 3000;
+loadData();
+
+server.listen(PORT, () => {
+ console.log(`点名系统服务器已启动`);
+ console.log(`访问地址: http://localhost:${PORT}`);
+ console.log(`后台管理: http://localhost:${PORT}/admin.html`);
+ console.log(`用户点名: http://localhost:${PORT}/index.html`);
+});
diff --git a/storage.js b/storage.js
new file mode 100644
index 0000000..abb1aa8
--- /dev/null
+++ b/storage.js
@@ -0,0 +1,287 @@
+/**
+ * 通用数据存储模块
+ * 支持两种模式:
+ * 1. 服务器模式:通过HTTP API与后端交互
+ * 2. 本地模式:使用localStorage存储数据
+ */
+
+class DataStorage {
+ constructor() {
+ this.apiBase = this.getApiBase();
+ this.isServerMode = this.checkServerMode();
+ console.log(`数据存储模式: ${this.isServerMode ? '服务器模式' : '本地模式'}`);
+ }
+
+ /**
+ * 检测运行模式
+ */
+ checkServerMode() {
+ // 如果是通过HTTP服务器访问(非file协议),则使用服务器模式
+ return window.location.protocol === 'http:' || window.location.protocol === 'https:';
+ }
+
+ /**
+ * 获取API基础URL
+ */
+ getApiBase() {
+ if (window.location.protocol === 'http:' || window.location.protocol === 'https:') {
+ return `${window.location.protocol}//${window.location.host}/api`;
+ }
+ return null;
+ }
+
+ /**
+ * HTTP请求封装
+ */
+ async httpRequest(url, method = 'GET', data = null) {
+ const options = {
+ method: method,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ };
+
+ if (data && method !== 'GET') {
+ options.body = JSON.stringify(data);
+ }
+
+ try {
+ const response = await fetch(url, options);
+
+ // 检查响应类型
+ const contentType = response.headers.get('content-type');
+ const isJson = contentType && contentType.includes('application/json');
+
+ if (!response.ok) {
+ // 如果响应不是JSON,尝试读取文本
+ if (!isJson) {
+ const text = await response.text();
+ throw new Error(`服务器错误 (${response.status}): 返回了非JSON响应`);
+ }
+
+ const result = await response.json();
+ throw new Error(result.error || `HTTP错误: ${response.status}`);
+ }
+
+ // 检查成功响应是否为JSON
+ if (!isJson) {
+ throw new Error('服务器返回了非JSON响应,请检查API接口');
+ }
+
+ const result = await response.json();
+ return result;
+ } catch (error) {
+ // 处理网络错误
+ if (error.message === 'Failed to fetch') {
+ throw new Error('无法连接到服务器,请确保:\n1. 已启动Node.js服务器\n2. 服务器运行在正确端口\n3. 或使用本地模式(直接打开HTML文件)');
+ }
+
+ // 处理JSON解析错误
+ if (error.message.includes('Unexpected token')) {
+ throw new Error('服务器返回了HTML页面而非JSON数据,可能是:\n1. API接口不存在\n2. 服务器配置错误\n3. 建议使用本地模式或检查服务器');
+ }
+
+ throw error;
+ }
+ }
+
+ /**
+ * 登录
+ */
+ async login(username, password) {
+ if (this.isServerMode) {
+ // 服务器模式
+ const result = await this.httpRequest(`${this.apiBase}/login`, 'POST', { username, password });
+ return result.data;
+ } else {
+ // 本地模式
+ if (username === 'admin' && password === 'admin123') {
+ return { token: 'local-token-' + Date.now() };
+ }
+ throw new Error('用户名或密码错误');
+ }
+ }
+
+ /**
+ * 创建点名名单
+ */
+ async createList(name, members) {
+ if (this.isServerMode) {
+ const result = await this.httpRequest(`${this.apiBase}/create-list`, 'POST', { name, members });
+ return result.data;
+ } else {
+ // 本地模式
+ const lists = this.getLocalLists();
+ const newList = {
+ id: Date.now(),
+ name: name,
+ code: this.generateCode(),
+ members: members.map(memberName => ({
+ name: memberName,
+ completed: false,
+ timestamp: null
+ })),
+ createdAt: new Date().toLocaleString()
+ };
+ lists.push(newList);
+ localStorage.setItem('rollCallLists', JSON.stringify(lists));
+ return newList;
+ }
+ }
+
+ /**
+ * 获取所有名单
+ */
+ async getLists() {
+ if (this.isServerMode) {
+ const result = await this.httpRequest(`${this.apiBase}/get-lists`);
+ return result.data;
+ } else {
+ return this.getLocalLists();
+ }
+ }
+
+ /**
+ * 获取单个名单
+ */
+ async getList(code) {
+ if (this.isServerMode) {
+ const result = await this.httpRequest(`${this.apiBase}/get-list?code=${code}`);
+ return result.data;
+ } else {
+ const lists = this.getLocalLists();
+ const list = lists.find(l => l.code === code);
+ if (!list) {
+ throw new Error('名单不存在');
+ }
+ return list;
+ }
+ }
+
+ /**
+ * 更新名单
+ */
+ async updateList(id, name, members) {
+ if (this.isServerMode) {
+ const result = await this.httpRequest(`${this.apiBase}/update-list`, 'POST', { id, name, members });
+ return result.data;
+ } else {
+ const lists = this.getLocalLists();
+ const list = lists.find(l => l.id === id);
+ if (!list) {
+ throw new Error('名单不存在');
+ }
+
+ if (name) list.name = name;
+ if (members && Array.isArray(members)) {
+ list.members = members.map(memberName => {
+ const existing = list.members.find(m => m.name === memberName);
+ return existing || { name: memberName, completed: false, timestamp: null };
+ });
+ }
+
+ localStorage.setItem('rollCallLists', JSON.stringify(lists));
+ return list;
+ }
+ }
+
+ /**
+ * 删除名单
+ */
+ async deleteList(id) {
+ if (this.isServerMode) {
+ const result = await this.httpRequest(`${this.apiBase}/delete-list`, 'POST', { id });
+ return result.data;
+ } else {
+ let lists = this.getLocalLists();
+ lists = lists.filter(l => l.id !== id);
+ localStorage.setItem('rollCallLists', JSON.stringify(lists));
+ return { message: '删除成功' };
+ }
+ }
+
+ /**
+ * 点名
+ */
+ async rollCall(code, name) {
+ if (this.isServerMode) {
+ const result = await this.httpRequest(`${this.apiBase}/roll-call`, 'POST', { code, name });
+ return result.data;
+ } else {
+ const lists = this.getLocalLists();
+ const list = lists.find(l => l.code === code);
+ if (!list) {
+ throw new Error('名单不存在');
+ }
+
+ const member = list.members.find(m => m.name === name);
+ if (!member) {
+ throw new Error('成员不存在');
+ }
+
+ if (member.completed) {
+ throw new Error('该成员已点名');
+ }
+
+ member.completed = true;
+ member.timestamp = new Date().toLocaleString();
+
+ // 添加记录
+ const records = this.getLocalRecords();
+ records.push({
+ name: name,
+ listName: list.name,
+ code: code,
+ timestamp: member.timestamp
+ });
+
+ localStorage.setItem('rollCallLists', JSON.stringify(lists));
+ localStorage.setItem('rollCallRecords', JSON.stringify(records));
+
+ return { message: '点名成功', member: member };
+ }
+ }
+
+ /**
+ * 获取点名记录
+ */
+ async getRecords() {
+ if (this.isServerMode) {
+ const result = await this.httpRequest(`${this.apiBase}/get-records`);
+ return result.data;
+ } else {
+ return this.getLocalRecords();
+ }
+ }
+
+ /**
+ * 本地模式:获取名单
+ */
+ getLocalLists() {
+ const data = localStorage.getItem('rollCallLists');
+ return data ? JSON.parse(data) : [];
+ }
+
+ /**
+ * 本地模式:获取记录
+ */
+ getLocalRecords() {
+ const data = localStorage.getItem('rollCallRecords');
+ return data ? JSON.parse(data) : [];
+ }
+
+ /**
+ * 生成随机编号
+ */
+ generateCode() {
+ const length = Math.floor(Math.random() * 7) + 6; // 6-12位
+ let code = '';
+ for (let i = 0; i < length; i++) {
+ code += Math.floor(Math.random() * 10);
+ }
+ return code;
+ }
+}
+
+// 创建全局实例
+window.dataStorage = new DataStorage();
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..1b98b2e
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,424 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Microsoft YaHei', Arial, sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ padding: 20px;
+}
+
+.container {
+ max-width: 800px;
+ margin: 0 auto;
+ background: white;
+ border-radius: 10px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+}
+
+header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 20px;
+ text-align: center;
+}
+
+header h1 {
+ font-size: 24px;
+}
+
+main {
+ padding: 20px;
+}
+
+.section {
+ margin-bottom: 30px;
+}
+
+.section h2 {
+ color: #333;
+ margin-bottom: 20px;
+ padding-bottom: 10px;
+ border-bottom: 2px solid #667eea;
+}
+
+.form-group {
+ margin-bottom: 15px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 5px;
+ color: #555;
+ font-weight: bold;
+}
+
+.form-group input,
+.form-group textarea,
+.form-control {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ font-size: 14px;
+}
+
+.form-group textarea {
+ resize: vertical;
+}
+
+.btn {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: bold;
+ transition: all 0.3s;
+}
+
+.btn-primary {
+ background: #667eea;
+ color: white;
+}
+
+.btn-primary:hover {
+ background: #5568d3;
+}
+
+.btn-secondary {
+ background: #95a5a6;
+ color: white;
+}
+
+.btn-secondary:hover {
+ background: #7f8c8d;
+}
+
+.btn-danger {
+ background: #e74c3c;
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #c0392b;
+}
+
+.btn-success {
+ background: #27ae60;
+ color: white;
+}
+
+.btn-success:hover {
+ background: #229954;
+}
+
+.card {
+ background: #f9f9f9;
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.card h3 {
+ color: #333;
+ margin-bottom: 15px;
+}
+
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.divider {
+ text-align: center;
+ margin: 20px 0;
+ position: relative;
+}
+
+.divider::before,
+.divider::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ width: 45%;
+ height: 1px;
+ background: #ddd;
+}
+
+.divider::before {
+ left: 0;
+}
+
+.divider::after {
+ right: 0;
+}
+
+.divider span {
+ background: white;
+ padding: 0 10px;
+ color: #999;
+}
+
+.qr-scanner {
+ text-align: center;
+}
+
+/* 进度条 */
+.progress-container {
+ margin: 20px 0;
+}
+
+.progress-info {
+ text-align: center;
+ margin-bottom: 10px;
+ color: #555;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 30px;
+ background: #ecf0f1;
+ border-radius: 15px;
+ overflow: hidden;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #667eea, #764ba2);
+ width: 0%;
+ transition: width 0.3s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: bold;
+}
+
+/* 名单列表 */
+.member-list {
+ margin: 20px 0;
+}
+
+.member-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px;
+ margin: 5px 0;
+ background: #f9f9f9;
+ border-radius: 5px;
+ border-left: 4px solid #667eea;
+}
+
+.member-item.completed {
+ background: #d4edda;
+ border-left-color: #27ae60;
+}
+
+.member-name {
+ font-weight: bold;
+ color: #333;
+}
+
+.member-status {
+ font-size: 12px;
+ padding: 3px 8px;
+ border-radius: 3px;
+}
+
+.status-pending {
+ background: #ffeaa7;
+ color: #d63031;
+}
+
+.status-completed {
+ background: #d4edda;
+ color: #27ae60;
+}
+
+/* 拍照区域 */
+.photo-upload {
+ margin: 20px 0;
+}
+
+.camera-container {
+ text-align: center;
+ margin: 20px 0;
+}
+
+.camera-container video {
+ border: 2px solid #ddd;
+ border-radius: 5px;
+}
+
+.camera-controls {
+ text-align: center;
+ margin: 10px 0;
+}
+
+.camera-controls button {
+ margin: 0 5px;
+}
+
+.captured-photo {
+ text-align: center;
+ margin: 20px 0;
+}
+
+.captured-photo img {
+ max-width: 320px;
+ border: 2px solid #ddd;
+ border-radius: 5px;
+}
+
+.photo-actions {
+ margin-top: 10px;
+}
+
+.photo-actions button {
+ margin: 0 5px;
+}
+
+/* 手动点名 */
+.manual-rollcall {
+ margin: 20px 0;
+ padding: 20px;
+ background: #f9f9f9;
+ border-radius: 5px;
+}
+
+.manual-rollcall select {
+ margin: 10px 0;
+}
+
+/* 弹窗 */
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+}
+
+.modal-content {
+ background-color: white;
+ margin: 10% auto;
+ padding: 20px;
+ border-radius: 10px;
+ width: 80%;
+ max-width: 500px;
+ text-align: center;
+}
+
+.close {
+ color: #aaa;
+ float: right;
+ font-size: 28px;
+ font-weight: bold;
+ cursor: pointer;
+}
+
+.close:hover {
+ color: black;
+}
+
+#qrCode {
+ margin: 20px 0;
+}
+
+#qrCode canvas {
+ border: 2px solid #ddd;
+ border-radius: 5px;
+}
+
+/* 列表项 */
+.list-item {
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ padding: 15px;
+ margin: 10px 0;
+}
+
+.list-item-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.list-item-title {
+ font-weight: bold;
+ color: #333;
+ font-size: 16px;
+}
+
+.list-item-code {
+ color: #667eea;
+ font-weight: bold;
+}
+
+.list-item-actions {
+ margin-top: 10px;
+}
+
+.list-item-actions button {
+ margin-right: 5px;
+}
+
+/* 记录项 */
+.record-item {
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ padding: 15px;
+ margin: 10px 0;
+}
+
+.record-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.record-name {
+ font-weight: bold;
+ color: #333;
+}
+
+.record-time {
+ color: #999;
+ font-size: 12px;
+}
+
+/* 响应式 */
+@media (max-width: 600px) {
+ body {
+ padding: 10px;
+ }
+
+ header h1 {
+ font-size: 20px;
+ }
+
+ .panel-header {
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .camera-container video {
+ width: 100%;
+ height: auto;
+ }
+}
diff --git a/test.html b/test.html
new file mode 100644
index 0000000..2bce3bb
--- /dev/null
+++ b/test.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+ 二维码测试
+
+
+
+ 二维码生成测试
+
+
+
+
+
二维码显示区域:
+
+
+
+
+
+
diff --git a/启动服务器.bat b/启动服务器.bat
new file mode 100644
index 0000000..6f4a615
--- /dev/null
+++ b/启动服务器.bat
@@ -0,0 +1,22 @@
+@echo off
+chcp 65001 >nul
+echo ====================================
+echo 点名系统启动脚本
+echo ====================================
+echo.
+echo 正在检查Node.js...
+node -v >nul 2>&1
+if %errorlevel% neq 0 (
+ echo [错误] 未检测到Node.js,请先安装Node.js
+ echo 下载地址: https://nodejs.org/
+ pause
+ exit /b 1
+)
+
+echo Node.js已安装
+echo.
+echo 正在启动服务器...
+echo.
+node server.js
+
+pause
diff --git a/启动说明.md b/启动说明.md
new file mode 100644
index 0000000..3fe0b1a
--- /dev/null
+++ b/启动说明.md
@@ -0,0 +1,86 @@
+# 点名系统启动说明
+
+## 🚀 快速启动步骤
+
+### 1. 启动后端服务器
+
+打开命令行(CMD或PowerShell),进入项目目录:
+
+```bash
+cd d:\!!代码\web\手机点名器
+node server.js
+```
+
+看到以下提示表示启动成功:
+```
+点名系统服务器已启动
+访问地址: http://localhost:3000
+后台管理: http://localhost:3000/admin.html
+用户点名: http://localhost:3000/index.html
+```
+
+### 2. 访问系统
+
+在浏览器中打开以下地址:
+
+- **后台管理**: http://localhost:3000/admin.html
+- **用户点名**: http://localhost:3000/index.html
+
+### 3. 登录后台
+
+- 用户名: `admin`
+- 密码: `admin123`
+
+## ❌ 常见错误解决
+
+### 错误1: "Failed to fetch" 或 "无法连接到服务器"
+
+**原因**: 后端服务器未启动
+
+**解决方法**:
+1. 确认已执行 `node server.js`
+2. 检查命令行是否显示"服务器已启动"
+3. 不要直接双击HTML文件打开,必须通过 http://localhost:3000 访问
+
+### 错误2: "端口被占用"
+
+**原因**: 3000端口已被其他程序使用
+
+**解决方法**:
+1. 修改 server.js 中的端口号(第189行)
+2. 将 `const PORT = 3000;` 改为其他端口,如 `const PORT = 3001;`
+3. 访问地址相应改为 http://localhost:3001
+
+### 错误3: "node不是内部或外部命令"
+
+**原因**: 未安装Node.js
+
+**解决方法**:
+1. 下载安装 Node.js: https://nodejs.org/
+2. 安装后重启命令行
+3. 验证安装: `node -v`
+
+## 📝 使用流程
+
+1. **启动服务器** → `node server.js`
+2. **打开后台** → http://localhost:3000/admin.html
+3. **登录系统** → admin / admin123
+4. **创建名单** → 输入名单名称和成员
+5. **获取编号** → 系统生成编号和二维码
+6. **用户点名** → 打开index.html,输入编号
+7. **完成点名** → 拍照或手动点名
+
+## 🔧 技术说明
+
+- **前端**: HTML + CSS + JavaScript
+- **后端**: Node.js原生HTTP服务器
+- **数据**: JSON文件存储
+- **端口**: 默认3000
+- **协议**: HTTP
+
+## 💡 提示
+
+- 服务器必须保持运行状态
+- 关闭命令行窗口会停止服务器
+- 数据保存在 data_lists.json 和 data_records.json
+- 清除浏览器缓存不会丢失数据
diff --git a/快速使用指南.md b/快速使用指南.md
new file mode 100644
index 0000000..75a903f
--- /dev/null
+++ b/快速使用指南.md
@@ -0,0 +1,132 @@
+# 点名系统 - 快速使用指南
+
+## 🎯 推荐使用方式
+
+### 方式一:本地模式(最简单,推荐!)
+
+**无需任何配置,即开即用!**
+
+1. **打开后台管理**
+ - 直接双击 `admin.html` 文件
+ - 或在浏览器中打开 `admin.html`
+
+2. **登录系统**
+ - 用户名: `admin`
+ - 密码: `admin123`
+
+3. **创建点名名单**
+ - 输入名单名称
+ - 输入成员(每行一个姓名)
+ - 点击"生成"
+
+4. **用户点名**
+ - 打开 `index.html`
+ - 输入编号
+ - 进行点名
+
+**优点**:
+- ✅ 无需安装Node.js
+- ✅ 无需启动服务器
+- ✅ 即开即用
+- ✅ 数据保存在浏览器
+
+---
+
+### 方式二:服务器模式(功能完整)
+
+**需要Node.js环境**
+
+1. **启动服务器**
+ ```bash
+ node server.js
+ ```
+ 或双击 `启动服务器.bat`
+
+2. **访问系统**
+ - 后台: http://localhost:3000/admin.html
+ - 点名: http://localhost:3000/index.html
+
+3. **登录使用**
+ - 用户名: `admin`
+ - 密码: `admin123`
+
+**优点**:
+- ✅ 数据永久保存
+- ✅ 支持多用户
+- ✅ 可局域网访问
+
+---
+
+## ❌ 常见错误解决
+
+### 错误1: "Unexpected token '<', ... is not valid JSON"
+
+**原因**: 服务器模式但API接口不存在
+
+**解决方案**:
+1. **推荐**: 使用本地模式(直接双击HTML文件)
+2. 或确保已启动Node.js服务器(`node server.js`)
+
+### 错误2: "无法连接到服务器"
+
+**原因**: 服务器未启动
+
+**解决方案**:
+1. 使用本地模式(推荐)
+2. 或启动服务器: `node server.js`
+
+### 错误3: "Failed to fetch"
+
+**原因**: 网络请求失败
+
+**解决方案**:
+1. 使用本地模式
+2. 或检查服务器是否运行
+
+---
+
+## 💡 使用建议
+
+### 什么时候用本地模式?
+- ✅ 个人学习测试
+- ✅ 快速演示
+- ✅ 临时使用
+- ✅ 无需多用户
+
+### 什么时候用服务器模式?
+- ✅ 正式使用
+- ✅ 需要数据持久化
+- ✅ 多人协作
+- ✅ 局域网访问
+
+---
+
+## 🔍 如何判断当前模式?
+
+打开页面后,在标题下方会显示:
+- "当前运行模式: 本地模式 (数据保存在浏览器本地)"
+- "当前运行模式: 服务器模式"
+
+---
+
+## 📞 快速故障排查
+
+**遇到任何错误,首先尝试:**
+
+1. **刷新页面** (F5)
+2. **清除浏览器缓存** (Ctrl+Shift+Delete)
+3. **使用本地模式** (直接双击HTML文件)
+
+如果本地模式正常,说明是服务器配置问题,继续使用本地模式即可。
+
+---
+
+## 🎉 总结
+
+**最简单的使用方法:**
+1. 双击 `admin.html`
+2. 登录: admin / admin123
+3. 创建名单
+4. 双击 `index.html` 点名
+
+**无需任何配置,开箱即用!**