This commit is contained in:
Harry
2026-04-25 10:09:16 +08:00
committed by GitHub
commit bf215c4b9f
15 changed files with 2482 additions and 0 deletions

244
README.md Normal file
View File

@@ -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
---
**推荐使用方式**
- 🎯 **学习/测试**:使用本地模式,简单快捷
- 🏢 **正式使用**:使用服务器模式,稳定可靠

86
admin.html Normal file
View File

@@ -0,0 +1,86 @@
<!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="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>点名系统 - 后台管理</h1>
<p id="modeIndicator" style="font-size: 12px; margin-top: 5px; opacity: 0.8;"></p>
</header>
<main>
<!-- 登录区域 -->
<section id="loginSection" class="section">
<h2>管理员登录</h2>
<form id="loginForm">
<div class="form-group">
<label for="username">用户名:</label>
<input type="text" id="username" required>
</div>
<div class="form-group">
<label for="password">密码:</label>
<input type="password" id="password" required>
</div>
<button type="submit" class="btn btn-primary">登录</button>
</form>
</section>
<!-- 管理面板 -->
<section id="adminPanel" class="section" style="display: none;">
<div class="panel-header">
<h2>管理面板</h2>
<button id="logoutBtn" class="btn btn-secondary">退出登录</button>
</div>
<!-- 新增点名名单 -->
<div class="card">
<h3>新增点名名单</h3>
<form id="createListForm">
<div class="form-group">
<label for="listName">名单名称:</label>
<input type="text" id="listName" required>
</div>
<div class="form-group">
<label for="memberList">名单成员(每行一个姓名):</label>
<textarea id="memberList" rows="10" required placeholder="张三&#10;李四&#10;王五"></textarea>
</div>
<button type="submit" class="btn btn-primary">生成</button>
</form>
</div>
<!-- 点名列表 -->
<div class="card">
<h3>点名列表</h3>
<div id="rollCallLists"></div>
</div>
<!-- 点名记录 -->
<div class="card">
<h3>点名记录</h3>
<div id="rollCallRecords"></div>
</div>
</section>
</main>
<!-- 二维码弹窗 -->
<div id="qrModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>点名信息</h2>
<div id="qrCode"></div>
<p>编号:<strong id="rollCallCode"></strong></p>
<p>请扫描二维码或输入编号进行点名</p>
</div>
</div>
</div>
<script src="qrcode.js"></script>
<script src="storage.js"></script>
<script src="admin.js"></script>
</body>
</html>

264
admin.js Normal file
View File

@@ -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 = '<p style="color: red;">加载失败:' + error.message + '</p>';
}
}
// 加载点名记录
async function loadRecords() {
try {
const records = await dataStorage.getRecords();
renderRecords(records);
} catch (error) {
console.error('加载记录失败:', error);
rollCallRecordsDiv.innerHTML = '<p style="color: red;">加载失败:' + error.message + '</p>';
}
}
// 创建点名名单
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 = '<p style="color: red;">二维码生成失败</p>';
}
}
// 关闭弹窗
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 = '<p>暂无点名列表</p>';
return;
}
rollCallListsDiv.innerHTML = lists.map(list => `
<div class="list-item">
<div class="list-item-header">
<span class="list-item-title">${list.name}</span>
<span class="list-item-code">编号:${list.code}</span>
</div>
<p>创建时间:${list.createdAt}</p>
<p>成员数:${list.members.length} | 已点名:${list.members.filter(m => m.completed).length}</p>
<div class="list-item-actions">
<button class="btn btn-primary" onclick="showQRCodeById(${list.id})">查看二维码</button>
<button class="btn btn-secondary" onclick="editList(${list.id})">编辑</button>
<button class="btn btn-danger" onclick="deleteList(${list.id})">删除</button>
</div>
</div>
`).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 = '<p>暂无点名记录</p>';
return;
}
rollCallRecordsDiv.innerHTML = records.map(record => `
<div class="record-item">
<div class="record-header">
<span class="record-name">${record.name}</span>
<span class="record-time">${record.timestamp}</span>
</div>
<p>名单:${record.listName}</p>
<p>编号:${record.code}</p>
</div>
`).join('');
}
// 暴露函数到全局
window.showQRCodeById = showQRCodeById;
window.editList = editList;
window.deleteList = deleteList;

26
idea.md Normal file
View File

@@ -0,0 +1,26 @@
用于点名
功能:
1、后台设置点名名单生成二维码和编号6-12位数字
2、用户扫描二维码或输入编号即可点名
3、进度条展示已点名人数并实时上传到后台
4、后台可以查看所有点名记录
1、后台设置点名名单生成二维码和编号
- 后台登陆后,点击“新增点名名单”按钮
- 输入点名名单,点击“生成”按钮
- 点击”生成“按钮,系统会自动生成二维码和编号
- 名单可在后台修改、添加、删除
2、用户扫描二维码或输入编号即可点名
- 打开点名网页
- 扫描二维码或输入编号
- 点击“进入点名”按钮
- 点统会更新进度条和已点名人数
- 点统会将点名记录上传到后台
- 后台可以查看所有点名记录
3、点名方式
- 用户拍摄照片上传,系统会自动识别照片中的人脸
- 系统会根据照片中的人脸,自动匹配名单中的用户
- 若不属于名单中的人脸,要求用户进行确认

92
index.html Normal file
View File

@@ -0,0 +1,92 @@
<!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="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>点名系统</h1>
<p id="modeIndicator" style="font-size: 12px; margin-top: 5px; opacity: 0.8;"></p>
</header>
<main>
<!-- 输入编号区域 -->
<section id="inputSection" class="section">
<h2>进入点名</h2>
<form id="enterForm">
<div class="form-group">
<label for="codeInput">请输入编号:</label>
<input type="text" id="codeInput" placeholder="6-12位数字" required pattern="\d{6,12}">
</div>
<button type="submit" class="btn btn-primary">进入点名</button>
</form>
<div class="divider">
<span></span>
</div>
<div class="qr-scanner">
<button id="scanQRBtn" class="btn btn-secondary">扫描二维码</button>
</div>
</section>
<!-- 点名区域 -->
<section id="rollCallSection" class="section" style="display: none;">
<h2>点名</h2>
<!-- 进度条 -->
<div class="progress-container">
<div class="progress-info">
<span>已点名:<strong id="completedCount">0</strong> / <strong id="totalCount">0</strong></span>
</div>
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
</div>
<!-- 名单列表 -->
<div class="member-list">
<h3>点名名单</h3>
<div id="memberListDisplay"></div>
</div>
<!-- 拍照点名 -->
<div class="photo-upload">
<h3>拍照点名</h3>
<div class="camera-container">
<video id="video" width="320" height="240" autoplay></video>
<canvas id="canvas" width="320" height="240" style="display: none;"></canvas>
</div>
<div class="camera-controls">
<button id="startCameraBtn" class="btn btn-primary">开启摄像头</button>
<button id="captureBtn" class="btn btn-primary" style="display: none;">拍照点名</button>
<button id="stopCameraBtn" class="btn btn-secondary" style="display: none;">关闭摄像头</button>
</div>
<div id="capturedPhoto" class="captured-photo" style="display: none;">
<img id="photoPreview" alt="拍摄的照片">
<div class="photo-actions">
<button id="confirmPhotoBtn" class="btn btn-primary">确认点名</button>
<button id="retakeBtn" class="btn btn-secondary">重新拍摄</button>
</div>
</div>
</div>
<!-- 手动点名 -->
<div class="manual-rollcall">
<h3>手动点名</h3>
<p>如果人脸识别失败,可以手动选择姓名进行点名:</p>
<select id="manualSelect" class="form-control">
<option value="">请选择姓名</option>
</select>
<button id="manualRollCallBtn" class="btn btn-primary">确认点名</button>
</div>
</section>
</main>
</div>
<script src="storage.js"></script>
<script src="index.js"></script>
</body>
</html>

228
index.js Normal file
View File

@@ -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) => `
<div class="member-item ${member.completed ? 'completed' : ''}">
<span class="member-name">${member.name}</span>
<span class="member-status ${member.completed ? 'status-completed' : 'status-pending'}">
${member.completed ? '已点名' : '未点名'}
</span>
</div>
`).join('');
}
// 填充手动选择下拉框
function populateManualSelect() {
if (!currentList) return;
const uncompletedMembers = currentList.members.filter(m => !m.completed);
manualSelect.innerHTML = '<option value="">请选择姓名</option>' +
uncompletedMembers.map(member => `<option value="${member.name}">${member.name}</option>`).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);
}
}

21
package.json Normal file
View File

@@ -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"
}
}

182
qrcode.js Normal file
View File

@@ -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;

314
server.js Normal file
View File

@@ -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`);
});

287
storage.js Normal file
View File

@@ -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();

424
styles.css Normal file
View File

@@ -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;
}
}

74
test.html Normal file
View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二维码测试</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 600px;
margin: 0 auto;
}
.test-result {
margin: 20px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
#qrcode {
margin: 20px 0;
}
canvas {
border: 2px solid #333;
}
</style>
</head>
<body>
<h1>二维码生成测试</h1>
<div class="test-result">
<h3>测试结果:</h3>
<p id="status">正在检测...</p>
</div>
<div id="qrcode">
<h3>二维码显示区域:</h3>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script>
window.addEventListener('load', function() {
const status = document.getElementById('status');
const qrcodeDiv = document.getElementById('qrcode');
if (typeof QRCode !== 'undefined') {
status.innerHTML = '<span style="color: green;">✓ QRCode库加载成功</span>';
// 创建canvas
const canvas = document.createElement('canvas');
qrcodeDiv.appendChild(canvas);
// 生成测试二维码
const testUrl = 'http://example.com?code=123456';
QRCode.toCanvas(canvas, testUrl, {
width: 200,
margin: 2
}, function(error) {
if (error) {
status.innerHTML += '<br><span style="color: red;">✗ 二维码生成失败: ' + error + '</span>';
} else {
status.innerHTML += '<br><span style="color: green;">✓ 二维码生成成功!</span>';
status.innerHTML += '<br>测试URL: ' + testUrl;
}
});
} else {
status.innerHTML = '<span style="color: red;">✗ QRCode库加载失败</span>';
status.innerHTML += '<br>可能原因网络连接问题或CDN被屏蔽';
status.innerHTML += '<br>建议:使用备用方案或手动输入编号';
}
});
</script>
</body>
</html>

22
启动服务器.bat Normal file
View File

@@ -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

86
启动说明.md Normal file
View File

@@ -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
- 清除浏览器缓存不会丢失数据

132
快速使用指南.md Normal file
View File

@@ -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` 点名
**无需任何配置,开箱即用!**