First
This commit is contained in:
244
README.md
Normal file
244
README.md
Normal 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
86
admin.html
Normal 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="张三 李四 王五"></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">×</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
264
admin.js
Normal 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
26
idea.md
Normal file
@@ -0,0 +1,26 @@
|
||||
用于点名
|
||||
|
||||
功能:
|
||||
1、后台设置点名名单,生成二维码和编号(6-12位数字)
|
||||
2、用户扫描二维码或输入编号,即可点名
|
||||
3、进度条展示已点名人数,并实时上传到后台
|
||||
4、后台可以查看所有点名记录
|
||||
|
||||
1、后台设置点名名单,生成二维码和编号:
|
||||
- 后台登陆后,点击“新增点名名单”按钮
|
||||
- 输入点名名单,点击“生成”按钮
|
||||
- 点击”生成“按钮,系统会自动生成二维码和编号
|
||||
- 名单可在后台修改、添加、删除
|
||||
|
||||
2、用户扫描二维码或输入编号,即可点名:
|
||||
- 打开点名网页
|
||||
- 扫描二维码或输入编号
|
||||
- 点击“进入点名”按钮
|
||||
- 点统会更新进度条和已点名人数
|
||||
- 点统会将点名记录上传到后台
|
||||
- 后台可以查看所有点名记录
|
||||
|
||||
3、点名方式:
|
||||
- 用户拍摄照片上传,系统会自动识别照片中的人脸
|
||||
- 系统会根据照片中的人脸,自动匹配名单中的用户
|
||||
- 若不属于名单中的人脸,要求用户进行确认
|
||||
92
index.html
Normal file
92
index.html
Normal 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
228
index.js
Normal 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
21
package.json
Normal 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
182
qrcode.js
Normal 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
314
server.js
Normal 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
287
storage.js
Normal 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
424
styles.css
Normal 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
74
test.html
Normal 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
22
启动服务器.bat
Normal 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
86
启动说明.md
Normal 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
132
快速使用指南.md
Normal 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` 点名
|
||||
|
||||
**无需任何配置,开箱即用!**
|
||||
Reference in New Issue
Block a user