From bf215c4b9f3bf2a80929935a9a22f5df43add16b Mon Sep 17 00:00:00 2001
From: Harry <139662128+dison0331@users.noreply.github.com>
Date: Sat, 25 Apr 2026 10:09:16 +0800
Subject: [PATCH] First
---
README.md | 244 ++++++++++++++++++++++++++++
admin.html | 86 ++++++++++
admin.js | 264 ++++++++++++++++++++++++++++++
idea.md | 26 +++
index.html | 92 +++++++++++
index.js | 228 ++++++++++++++++++++++++++
package.json | 21 +++
qrcode.js | 182 +++++++++++++++++++++
server.js | 314 +++++++++++++++++++++++++++++++++++
storage.js | 287 ++++++++++++++++++++++++++++++++
styles.css | 424 ++++++++++++++++++++++++++++++++++++++++++++++++
test.html | 74 +++++++++
启动服务器.bat | 22 +++
启动说明.md | 86 ++++++++++
快速使用指南.md | 132 +++++++++++++++
15 files changed, 2482 insertions(+)
create mode 100644 README.md
create mode 100644 admin.html
create mode 100644 admin.js
create mode 100644 idea.md
create mode 100644 index.html
create mode 100644 index.js
create mode 100644 package.json
create mode 100644 qrcode.js
create mode 100644 server.js
create mode 100644 storage.js
create mode 100644 styles.css
create mode 100644 test.html
create mode 100644 启动服务器.bat
create mode 100644 启动说明.md
create mode 100644 快速使用指南.md
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` 点名
+
+**无需任何配置,开箱即用!**