commit bf215c4b9f3bf2a80929935a9a22f5df43add16b Author: Harry <139662128+dison0331@users.noreply.github.com> Date: Sat Apr 25 10:09:16 2026 +0800 First diff --git a/README.md b/README.md new file mode 100644 index 0000000..279edbc --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# 点名系统 + +一个基于Web的点名系统,支持后台管理和用户点名功能。**支持双模式运行:服务器模式和本地模式**。 + +## 🌟 核心特性 + +### 双模式运行 +- **服务器模式**:通过Node.js服务器运行,支持多用户、数据持久化 +- **本地模式**:直接打开HTML文件,使用浏览器localStorage存储,无需服务器 + +### 功能特性 +- 管理员登录(默认账号:admin / admin123) +- 创建点名名单,自动生成编号和二维码 +- 用户通过编号或二维码进入点名 +- 实时进度条显示 +- 拍照点名(支持人脸识别) +- 手动点名 +- 数据实时同步 + +## 🚀 快速开始 + +### 方式一:本地模式(推荐新手) + +**无需安装Node.js,直接使用!** + +1. **打开后台管理** + - 直接双击 `admin.html` 文件 + - 或在浏览器中打开 `admin.html` + +2. **登录系统** + - 用户名: `admin` + - 密码: `admin123` + +3. **创建点名名单** + - 输入名单名称和成员 + - 系统自动生成编号和二维码 + +4. **用户点名** + - 打开 `index.html` + - 输入编号进入点名 + +**优点**: +- ✅ 无需安装任何软件 +- ✅ 即开即用 +- ✅ 数据保存在浏览器本地 + +**注意**: +- ⚠️ 数据仅保存在当前浏览器 +- ⚠️ 清除浏览器数据会丢失记录 +- ⚠️ 不同浏览器/电脑数据不共享 + +### 方式二:服务器模式(推荐正式使用) + +**需要Node.js环境** + +1. **启动服务器** + ```bash + # 方法1: 使用批处理文件 + 双击 "启动服务器.bat" + + # 方法2: 命令行启动 + cd 手机点名器 + node server.js + ``` + +2. **访问系统** + - 后台管理: http://localhost:3000/admin.html + - 用户点名: http://localhost:3000/index.html + +3. **登录系统** + - 用户名: `admin` + - 密码: `admin123` + +**优点**: +- ✅ 数据持久化到文件 +- ✅ 支持多用户访问 +- ✅ 数据不会丢失 +- ✅ 可以在局域网使用 + +## 📖 详细使用说明 + +### 后台管理操作 + +1. **创建点名名单** + - 输入名单名称(如:"2024春季班点名") + - 输入成员名单(每行一个姓名) + - 点击"生成"按钮 + - 系统自动生成编号和二维码 + +2. **管理名单** + - 查看二维码:显示编号和二维码 + - 编辑:修改名单名称和成员 + - 删除:删除整个名单 + +3. **查看记录** + - 查看所有点名记录 + - 显示点名时间、名单、编号 + +### 用户点名操作 + +1. **进入点名** + - 方式1:输入编号 + - 方式2:扫描二维码(需摄像头) + - 方式3:直接访问带参数的URL + +2. **点名方式** + - **拍照点名**:开启摄像头拍照,系统自动识别 + - **手动点名**:从下拉列表选择姓名 + +3. **查看进度** + - 实时进度条显示 + - 已点名/总人数统计 + - 成员状态标记 + +## 🔄 模式自动切换 + +系统会自动检测运行环境: + +| 访问方式 | 运行模式 | 数据存储 | +|---------|---------|---------| +| 双击HTML文件 | 本地模式 | localStorage | +| file://协议 | 本地模式 | localStorage | +| http://localhost | 服务器模式 | 服务器文件 | +| http://域名 | 服务器模式 | 服务器文件 | + +## 📁 文件结构 + +``` +手机点名器/ +├── server.js # Node.js后端服务器 +├── package.json # 项目配置 +├── storage.js # 通用数据存储模块(核心) +├── admin.html # 后台管理页面 +├── index.html # 用户点名页面 +├── styles.css # 共享样式 +├── admin.js # 后台管理逻辑 +├── index.js # 用户点名逻辑 +├── qrcode.js # 本地二维码生成器 +├── 启动服务器.bat # Windows启动脚本 +├── 启动说明.md # 启动说明 +├── data_lists.json # 点名列表数据(服务器模式) +├── data_records.json # 点名记录数据(服务器模式) +├── idea.md # 需求文档 +└── README.md # 使用说明 +``` + +## 🛠️ 技术架构 + +### 前端 +- 纯HTML/CSS/JavaScript +- 无框架依赖 +- 响应式设计 +- 本地二维码生成 + +### 后端(可选) +- Node.js原生HTTP服务器 +- RESTful API设计 +- 文件系统数据存储 +- CORS支持 + +### 数据存储 +- **本地模式**:浏览器localStorage +- **服务器模式**:JSON文件持久化 + +## 📝 API接口文档(服务器模式) + +### 登录 +- **URL**: `/api/login` +- **方法**: POST +- **参数**: `{ username, password }` + +### 创建点名名单 +- **URL**: `/api/create-list` +- **方法**: POST +- **参数**: `{ name, members: [] }` + +### 获取所有名单 +- **URL**: `/api/get-lists` +- **方法**: GET + +### 获取单个名单 +- **URL**: `/api/get-list?code=xxx` +- **方法**: GET + +### 更新名单 +- **URL**: `/api/update-list` +- **方法**: POST +- **参数**: `{ id, name, members: [] }` + +### 删除名单 +- **URL**: `/api/delete-list` +- **方法**: POST +- **参数**: `{ id }` + +### 点名 +- **URL**: `/api/roll-call` +- **方法**: POST +- **参数**: `{ code, name }` + +### 获取点名记录 +- **URL**: `/api/get-records` +- **方法**: GET + +## ❓ 常见问题 + +### Q1: 本地模式下数据会丢失吗? +A: 数据保存在浏览器localStorage中,除非清除浏览器数据,否则不会丢失。 + +### Q2: 可以在不同电脑上使用吗? +A: +- 本地模式:每台电脑独立数据,不共享 +- 服务器模式:可以通过局域网IP访问,数据共享 + +### Q3: 如何在手机上使用? +A: +- 本地模式:将HTML文件传到手机,用浏览器打开 +- 服务器模式:通过局域网IP访问(如 http://192.168.1.100:3000) + +### Q4: 服务器模式如何停止? +A: 在命令行窗口按 Ctrl+C 或关闭窗口 + +### Q5: 端口被占用怎么办? +A: 修改 server.js 中的 `const PORT = 3000;` 为其他端口 + +## 🔧 扩展建议 + +1. 集成真实的人脸识别API +2. 使用数据库替代文件存储 +3. 实现扫码功能 +4. 添加数据导出功能 +5. 支持批量导入名单 +6. 添加用户权限管理 +7. 实现WebSocket实时通信 +8. 添加日志记录功能 + +## 📄 许可证 + +MIT License + +--- + +**推荐使用方式**: +- 🎯 **学习/测试**:使用本地模式,简单快捷 +- 🏢 **正式使用**:使用服务器模式,稳定可靠 diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..aaba075 --- /dev/null +++ b/admin.html @@ -0,0 +1,86 @@ + + + + + + 点名系统 - 后台管理 + + + +
+
+

点名系统 - 后台管理

+

+
+ +
+ +
+

管理员登录

+
+
+ + +
+
+ + +
+ +
+
+ + + +
+ + + +
+ + + + + + diff --git a/admin.js b/admin.js new file mode 100644 index 0000000..75657a5 --- /dev/null +++ b/admin.js @@ -0,0 +1,264 @@ +// 后台管理JavaScript - 支持服务器和本地双模式 + +// 状态管理 +let isLoggedIn = sessionStorage.getItem('isAdminLoggedIn') === 'true'; +let authToken = sessionStorage.getItem('authToken') || ''; + +// DOM元素 +const loginSection = document.getElementById('loginSection'); +const adminPanel = document.getElementById('adminPanel'); +const loginForm = document.getElementById('loginForm'); +const logoutBtn = document.getElementById('logoutBtn'); +const createListForm = document.getElementById('createListForm'); +const rollCallListsDiv = document.getElementById('rollCallLists'); +const rollCallRecordsDiv = document.getElementById('rollCallRecords'); +const qrModal = document.getElementById('qrModal'); +const qrCodeDiv = document.getElementById('qrCode'); +const rollCallCodeSpan = document.getElementById('rollCallCode'); + +// QRCode实例 +const qrCode = new SimpleQRCode(); + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + // 显示运行模式 + const modeText = dataStorage.isServerMode ? '服务器模式' : '本地模式'; + const modeIndicator = document.getElementById('modeIndicator'); + + if (modeIndicator) { + modeIndicator.textContent = `当前运行模式: ${modeText}`; + if (!dataStorage.isServerMode) { + modeIndicator.textContent += ' (数据保存在浏览器本地)'; + } + } + + console.log(`点名系统运行在: ${modeText}`); + + if (isLoggedIn) { + showAdminPanel(); + loadLists(); + loadRecords(); + } +}); + +// 登录 +loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + try { + const result = await dataStorage.login(username, password); + + isLoggedIn = true; + authToken = result.token; + sessionStorage.setItem('isAdminLoggedIn', 'true'); + sessionStorage.setItem('authToken', authToken); + + showAdminPanel(); + await loadLists(); + await loadRecords(); + } catch (error) { + alert('登录失败:' + error.message); + } +}); + +// 退出登录 +logoutBtn.addEventListener('click', () => { + isLoggedIn = false; + authToken = ''; + sessionStorage.removeItem('isAdminLoggedIn'); + sessionStorage.removeItem('authToken'); + loginSection.style.display = 'block'; + adminPanel.style.display = 'none'; +}); + +// 显示管理面板 +function showAdminPanel() { + loginSection.style.display = 'none'; + adminPanel.style.display = 'block'; +} + +// 加载点名列表 +async function loadLists() { + try { + const lists = await dataStorage.getLists(); + renderLists(lists); + } catch (error) { + console.error('加载列表失败:', error); + rollCallListsDiv.innerHTML = '

加载失败:' + error.message + '

'; + } +} + +// 加载点名记录 +async function loadRecords() { + try { + const records = await dataStorage.getRecords(); + renderRecords(records); + } catch (error) { + console.error('加载记录失败:', error); + rollCallRecordsDiv.innerHTML = '

加载失败:' + error.message + '

'; + } +} + +// 创建点名名单 +createListForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const listName = document.getElementById('listName').value; + const memberListText = document.getElementById('memberList').value; + const members = memberListText.split('\n').filter(name => name.trim() !== '').map(name => name.trim()); + + try { + const newList = await dataStorage.createList(listName, members); + + // 清空表单 + createListForm.reset(); + + // 重新加载列表 + await loadLists(); + + // 显示二维码 + showQRCode(newList); + } catch (error) { + alert('创建失败:' + error.message); + } +}); + +// 显示二维码 +function showQRCode(list) { + qrModal.style.display = 'block'; + rollCallCodeSpan.textContent = list.code; + + // 生成二维码URL + let url; + if (dataStorage.isServerMode) { + // 服务器模式:使用当前域名 + url = `${window.location.origin}${window.location.pathname.replace('admin.html', 'index.html')}?code=${list.code}`; + } else { + // 本地模式:使用相对路径 + url = `index.html?code=${list.code}`; + } + + qrCodeDiv.innerHTML = ''; + + // 创建canvas元素 + const canvas = document.createElement('canvas'); + qrCodeDiv.appendChild(canvas); + + // 使用本地QRCode生成器 + try { + qrCode.toCanvas(canvas, url, { + width: 200, + margin: 2 + }); + } catch (error) { + console.error('二维码生成错误:', error); + qrCodeDiv.innerHTML = '

二维码生成失败

'; + } +} + +// 关闭弹窗 +document.querySelector('.close').addEventListener('click', () => { + qrModal.style.display = 'none'; +}); + +window.addEventListener('click', (e) => { + if (e.target === qrModal) { + qrModal.style.display = 'none'; + } +}); + +// 渲染点名列表 +function renderLists(lists) { + if (!lists || lists.length === 0) { + rollCallListsDiv.innerHTML = '

暂无点名列表

'; + return; + } + + rollCallListsDiv.innerHTML = lists.map(list => ` +
+
+ ${list.name} + 编号:${list.code} +
+

创建时间:${list.createdAt}

+

成员数:${list.members.length} | 已点名:${list.members.filter(m => m.completed).length}

+
+ + + +
+
+ `).join(''); +} + +// 显示二维码(通过ID) +async function showQRCodeById(id) { + try { + const lists = await dataStorage.getLists(); + const list = lists.find(l => l.id === id); + if (list) { + showQRCode(list); + } + } catch (error) { + alert('获取列表失败:' + error.message); + } +} + +// 编辑名单 +async function editList(id) { + try { + const lists = await dataStorage.getLists(); + const list = lists.find(l => l.id === id); + if (!list) return; + + const newName = prompt('请输入新的名单名称:', list.name); + if (newName === null) return; + + const newMembersText = prompt('请输入新的名单成员(每行一个姓名):', list.members.map(m => m.name).join('\n')); + if (newMembersText === null) return; + + const newMembers = newMembersText.split('\n').filter(name => name.trim() !== '').map(name => name.trim()); + + await dataStorage.updateList(id, newName, newMembers); + await loadLists(); + } catch (error) { + alert('编辑失败:' + error.message); + } +} + +// 删除名单 +async function deleteList(id) { + if (confirm('确定要删除这个点名名单吗?')) { + try { + await dataStorage.deleteList(id); + await loadLists(); + } catch (error) { + alert('删除失败:' + error.message); + } + } +} + +// 渲染点名记录 +function renderRecords(records) { + if (!records || records.length === 0) { + rollCallRecordsDiv.innerHTML = '

暂无点名记录

'; + return; + } + + rollCallRecordsDiv.innerHTML = records.map(record => ` +
+
+ ${record.name} + ${record.timestamp} +
+

名单:${record.listName}

+

编号:${record.code}

+
+ `).join(''); +} + +// 暴露函数到全局 +window.showQRCodeById = showQRCodeById; +window.editList = editList; +window.deleteList = deleteList; diff --git a/idea.md b/idea.md new file mode 100644 index 0000000..acbc5df --- /dev/null +++ b/idea.md @@ -0,0 +1,26 @@ +用于点名 + +功能: + 1、后台设置点名名单,生成二维码和编号(6-12位数字) + 2、用户扫描二维码或输入编号,即可点名 + 3、进度条展示已点名人数,并实时上传到后台 + 4、后台可以查看所有点名记录 + +1、后台设置点名名单,生成二维码和编号: + - 后台登陆后,点击“新增点名名单”按钮 + - 输入点名名单,点击“生成”按钮 + - 点击”生成“按钮,系统会自动生成二维码和编号 + - 名单可在后台修改、添加、删除 + +2、用户扫描二维码或输入编号,即可点名: + - 打开点名网页 + - 扫描二维码或输入编号 + - 点击“进入点名”按钮 + - 点统会更新进度条和已点名人数 + - 点统会将点名记录上传到后台 + - 后台可以查看所有点名记录 + +3、点名方式: + - 用户拍摄照片上传,系统会自动识别照片中的人脸 + - 系统会根据照片中的人脸,自动匹配名单中的用户 + - 若不属于名单中的人脸,要求用户进行确认 \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..140b3ab --- /dev/null +++ b/index.html @@ -0,0 +1,92 @@ + + + + + + 点名系统 - 用户点名 + + + +
+
+

点名系统

+

+
+ +
+ +
+

进入点名

+
+
+ + +
+ +
+
+ +
+
+ +
+
+ + + +
+
+ + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..aaa1acf --- /dev/null +++ b/index.js @@ -0,0 +1,228 @@ +// 用户点名JavaScript - 支持服务器和本地双模式 + +// 状态管理 +let currentList = null; +let stream = null; + +// DOM元素 +const inputSection = document.getElementById('inputSection'); +const rollCallSection = document.getElementById('rollCallSection'); +const enterForm = document.getElementById('enterForm'); +const codeInput = document.getElementById('codeInput'); +const scanQRBtn = document.getElementById('scanQRBtn'); +const completedCount = document.getElementById('completedCount'); +const totalCount = document.getElementById('totalCount'); +const progressFill = document.getElementById('progressFill'); +const memberListDisplay = document.getElementById('memberListDisplay'); +const video = document.getElementById('video'); +const canvas = document.getElementById('canvas'); +const startCameraBtn = document.getElementById('startCameraBtn'); +const captureBtn = document.getElementById('captureBtn'); +const stopCameraBtn = document.getElementById('stopCameraBtn'); +const capturedPhoto = document.getElementById('capturedPhoto'); +const photoPreview = document.getElementById('photoPreview'); +const confirmPhotoBtn = document.getElementById('confirmPhotoBtn'); +const retakeBtn = document.getElementById('retakeBtn'); +const manualSelect = document.getElementById('manualSelect'); +const manualRollCallBtn = document.getElementById('manualRollCallBtn'); + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + // 显示运行模式 + const modeText = dataStorage.isServerMode ? '服务器模式' : '本地模式'; + const modeIndicator = document.getElementById('modeIndicator'); + + if (modeIndicator) { + modeIndicator.textContent = `当前运行模式: ${modeText}`; + if (!dataStorage.isServerMode) { + modeIndicator.textContent += ' (数据保存在浏览器本地)'; + } + } + + console.log(`点名系统运行在: ${modeText}`); + + // 检查URL参数 + const urlParams = new URLSearchParams(window.location.search); + const codeFromUrl = urlParams.get('code'); + if (codeFromUrl) { + codeInput.value = codeFromUrl; + enterRollCall(codeFromUrl); + } +}); + +// 进入点名 +enterForm.addEventListener('submit', (e) => { + e.preventDefault(); + const code = codeInput.value.trim(); + enterRollCall(code); +}); + +async function enterRollCall(code) { + try { + currentList = await dataStorage.getList(code); + + inputSection.style.display = 'none'; + rollCallSection.style.display = 'block'; + + updateProgress(); + renderMemberList(); + populateManualSelect(); + } catch (error) { + alert('进入点名失败:' + error.message); + } +} + +// 扫描二维码(简化版) +scanQRBtn.addEventListener('click', () => { + alert('扫码功能需要集成摄像头扫码库,当前版本请手动输入编号'); +}); + +// 更新进度 +function updateProgress() { + if (!currentList) return; + + const completed = currentList.members.filter(m => m.completed).length; + const total = currentList.members.length; + const percentage = total > 0 ? (completed / total * 100) : 0; + + completedCount.textContent = completed; + totalCount.textContent = total; + progressFill.style.width = percentage + '%'; + progressFill.textContent = Math.round(percentage) + '%'; +} + +// 渲染成员列表 +function renderMemberList() { + if (!currentList) return; + + memberListDisplay.innerHTML = currentList.members.map((member, index) => ` +
+ ${member.name} + + ${member.completed ? '已点名' : '未点名'} + +
+ `).join(''); +} + +// 填充手动选择下拉框 +function populateManualSelect() { + if (!currentList) return; + + const uncompletedMembers = currentList.members.filter(m => !m.completed); + manualSelect.innerHTML = '' + + uncompletedMembers.map(member => ``).join(''); +} + +// 开启摄像头 +startCameraBtn.addEventListener('click', async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ video: true }); + video.srcObject = stream; + video.style.display = 'block'; + startCameraBtn.style.display = 'none'; + captureBtn.style.display = 'inline-block'; + stopCameraBtn.style.display = 'inline-block'; + } catch (err) { + alert('无法访问摄像头,请检查权限设置'); + console.error(err); + } +}); + +// 拍照 +captureBtn.addEventListener('click', () => { + const context = canvas.getContext('2d'); + context.drawImage(video, 0, 0, canvas.width, canvas.height); + + const dataURL = canvas.toDataURL('image/jpeg'); + photoPreview.src = dataURL; + + capturedPhoto.style.display = 'block'; + video.style.display = 'none'; + captureBtn.style.display = 'none'; +}); + +// 关闭摄像头 +stopCameraBtn.addEventListener('click', () => { + if (stream) { + stream.getTracks().forEach(track => track.stop()); + stream = null; + } + video.style.display = 'block'; + capturedPhoto.style.display = 'none'; + startCameraBtn.style.display = 'inline-block'; + captureBtn.style.display = 'none'; + stopCameraBtn.style.display = 'none'; +}); + +// 确认照片 +confirmPhotoBtn.addEventListener('click', () => { + // 模拟人脸识别 + const recognizedName = simulateFaceRecognition(); + + if (recognizedName) { + markAsCompleted(recognizedName); + alert(`识别成功:${recognizedName}`); + } else { + alert('未能识别,请手动选择姓名'); + } + + // 重置拍照区域 + capturedPhoto.style.display = 'none'; + video.style.display = 'block'; + captureBtn.style.display = 'inline-block'; +}); + +// 重新拍摄 +retakeBtn.addEventListener('click', () => { + capturedPhoto.style.display = 'none'; + video.style.display = 'block'; + captureBtn.style.display = 'inline-block'; +}); + +// 模拟人脸识别 +function simulateFaceRecognition() { + // 随机返回一个未点名的成员(模拟识别) + const uncompletedMembers = currentList.members.filter(m => !m.completed); + if (uncompletedMembers.length === 0) return null; + + const randomIndex = Math.floor(Math.random() * uncompletedMembers.length); + return uncompletedMembers[randomIndex].name; +} + +// 手动点名 +manualRollCallBtn.addEventListener('click', () => { + const selectedName = manualSelect.value; + if (!selectedName) { + alert('请选择姓名'); + return; + } + markAsCompleted(selectedName); +}); + +// 标记为已完成 +async function markAsCompleted(name) { + try { + await dataStorage.rollCall(currentList.code, name); + + // 更新本地数据 + const member = currentList.members.find(m => m.name === name); + if (member) { + member.completed = true; + member.timestamp = new Date().toLocaleString(); + } + + // 更新UI + updateProgress(); + renderMemberList(); + populateManualSelect(); + + // 检查是否全部完成 + const allCompleted = currentList.members.every(m => m.completed); + if (allCompleted) { + alert('点名完成!所有成员已点名'); + } + } catch (error) { + alert('点名失败:' + error.message); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c74ebcd --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "roll-call-system", + "version": "1.0.0", + "description": "点名系统 - 支持后台管理和用户点名", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "keywords": [ + "roll-call", + "attendance", + "qr-code", + "nodejs" + ], + "author": "", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } +} diff --git a/qrcode.js b/qrcode.js new file mode 100644 index 0000000..dcb842f --- /dev/null +++ b/qrcode.js @@ -0,0 +1,182 @@ +/** + * 简单的二维码生成器 + * 使用Canvas API实现,不依赖外部库 + */ +class SimpleQRCode { + constructor() { + // QR码版本和大小映射 + this.sizeMap = { + 1: 21, 2: 25, 3: 29, 4: 33, 5: 37, 6: 41, 7: 45, 8: 49, 9: 53, 10: 57 + }; + } + + /** + * 生成二维码到Canvas + */ + toCanvas(canvas, text, options = {}) { + const size = options.width || 200; + const margin = options.margin || 2; + + canvas.width = size; + canvas.height = size; + + const ctx = canvas.getContext('2d'); + + // 白色背景 + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, size, size); + + // 生成QR码数据 + const qrData = this.generateQRData(text); + + // 计算单元格大小 + const moduleCount = qrData.length; + const cellSize = (size - margin * 2) / moduleCount; + + // 绘制QR码 + ctx.fillStyle = '#000000'; + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + if (qrData[row][col]) { + const x = margin + col * cellSize; + const y = margin + row * cellSize; + ctx.fillRect(x, y, cellSize, cellSize); + } + } + } + + return canvas; + } + + /** + * 生成QR码数据矩阵 + */ + generateQRData(text) { + // 简化版本:生成一个基本的QR码结构 + const version = this.getVersion(text.length); + const size = this.sizeMap[version] || 21; + + // 创建数据矩阵 + const matrix = Array(size).fill(null).map(() => Array(size).fill(false)); + + // 添加定位图案 + this.addFinderPattern(matrix, 0, 0); + this.addFinderPattern(matrix, size - 7, 0); + this.addFinderPattern(matrix, 0, size - 7); + + // 添加定时图案 + this.addTimingPattern(matrix, size); + + // 添加数据(简化版本) + this.addData(matrix, text, size); + + return matrix; + } + + /** + * 添加定位图案 + */ + addFinderPattern(matrix, row, col) { + // 外框 + for (let i = 0; i < 7; i++) { + matrix[row][col + i] = true; + matrix[row + 6][col + i] = true; + matrix[row + i][col] = true; + matrix[row + i][col + 6] = true; + } + + // 内框 + for (let i = 2; i < 5; i++) { + for (let j = 2; j < 5; j++) { + matrix[row + i][col + j] = true; + } + } + } + + /** + * 添加定时图案 + */ + addTimingPattern(matrix, size) { + for (let i = 8; i < size - 8; i++) { + matrix[6][i] = i % 2 === 0; + matrix[i][6] = i % 2 === 0; + } + } + + /** + * 添加数据(简化版本) + */ + addData(matrix, text, size) { + // 将文本转换为二进制数据 + const binaryData = this.textToBinary(text); + + let dataIndex = 0; + let upward = true; + + // 从右下角开始填充数据 + for (let col = size - 1; col > 0; col -= 2) { + if (col === 6) col--; // 跳过定时图案列 + + for (let row = upward ? size - 1 : 0; + upward ? row >= 0 : row < size; + upward ? row-- : row++) { + + for (let c = 0; c < 2; c++) { + const currentCol = col - c; + if (this.isDataArea(row, currentCol, size)) { + if (dataIndex < binaryData.length) { + matrix[row][currentCol] = binaryData[dataIndex] === '1'; + dataIndex++; + } + } + } + } + upward = !upward; + } + } + + /** + * 检查是否为数据区域 + */ + isDataArea(row, col, size) { + // 检查是否在定位图案区域 + if (row < 9 && col < 9) return false; + if (row < 9 && col > size - 9) return false; + if (row > size - 9 && col < 9) return false; + // 检查是否在定时图案区域 + if (row === 6 || col === 6) return false; + + return true; + } + + /** + * 文本转二进制 + */ + textToBinary(text) { + let binary = ''; + for (let i = 0; i < text.length; i++) { + const charCode = text.charCodeAt(i); + binary += charCode.toString(2).padStart(8, '0'); + } + return binary; + } + + /** + * 根据数据长度获取QR码版本 + */ + getVersion(length) { + if (length <= 17) return 1; + if (length <= 32) return 2; + if (length <= 53) return 3; + if (length <= 78) return 4; + if (length <= 106) return 5; + if (length <= 134) return 6; + if (length <= 154) return 7; + if (length <= 192) return 8; + if (length <= 230) return 9; + return 10; + } +} + +// 创建全局实例 +window.SimpleQRCode = SimpleQRCode; diff --git a/server.js b/server.js new file mode 100644 index 0000000..ac297c3 --- /dev/null +++ b/server.js @@ -0,0 +1,314 @@ +/** + * 点名系统后端API服务器 + * 使用Node.js原生HTTP模块实现 + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const url = require('url'); + +// 数据存储(实际应用中应使用数据库) +let rollCallLists = []; +let rollCallRecords = []; + +// 从文件加载数据 +function loadData() { + try { + const listsData = fs.readFileSync('data_lists.json', 'utf8'); + rollCallLists = JSON.parse(listsData); + } catch (e) { + rollCallLists = []; + } + + try { + const recordsData = fs.readFileSync('data_records.json', 'utf8'); + rollCallRecords = JSON.parse(recordsData); + } catch (e) { + rollCallRecords = []; + } +} + +// 保存数据到文件 +function saveData() { + fs.writeFileSync('data_lists.json', JSON.stringify(rollCallLists, null, 2)); + fs.writeFileSync('data_records.json', JSON.stringify(rollCallRecords, null, 2)); +} + +// 生成随机编号 +function generateCode() { + const length = Math.floor(Math.random() * 7) + 6; // 6-12位 + let code = ''; + for (let i = 0; i < length; i++) { + code += Math.floor(Math.random() * 10); + } + return code; +} + +// 创建HTTP服务器 +const server = http.createServer((req, res) => { + // 设置CORS头 + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + // 处理OPTIONS请求 + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + const parsedUrl = url.parse(req.url, true); + const pathname = parsedUrl.pathname; + + // API路由 + if (pathname.startsWith('/api/')) { + handleAPI(req, res, pathname, parsedUrl.query); + return; + } + + // 静态文件服务 + serveStatic(req, res, pathname); +}); + +// 处理API请求 +function handleAPI(req, res, pathname, query) { + let body = ''; + + req.on('data', chunk => { + body += chunk.toString(); + }); + + req.on('end', () => { + let data = {}; + if (body) { + try { + data = JSON.parse(body); + } catch (e) { + sendError(res, '无效的JSON数据'); + return; + } + } + + // 路由处理 + switch (pathname) { + case '/api/login': + handleLogin(res, data); + break; + case '/api/create-list': + handleCreateList(res, data); + break; + case '/api/get-lists': + handleGetLists(res); + break; + case '/api/get-list': + handleGetList(res, query); + break; + case '/api/update-list': + handleUpdateList(res, data); + break; + case '/api/delete-list': + handleDeleteList(res, data); + break; + case '/api/roll-call': + handleRollCall(res, data); + break; + case '/api/get-records': + handleGetRecords(res); + break; + default: + sendError(res, '未知的API路径', 404); + } + }); +} + +// 登录处理 +function handleLogin(res, data) { + const { username, password } = data; + + if (username === 'admin' && password === 'admin123') { + sendSuccess(res, { token: 'admin-token-' + Date.now() }); + } else { + sendError(res, '用户名或密码错误', 401); + } +} + +// 创建点名名单 +function handleCreateList(res, data) { + const { name, members } = data; + + if (!name || !members || !Array.isArray(members)) { + sendError(res, '参数错误'); + return; + } + + const newList = { + id: Date.now(), + name: name, + code: generateCode(), + members: members.map(memberName => ({ + name: memberName, + completed: false, + timestamp: null + })), + createdAt: new Date().toLocaleString() + }; + + rollCallLists.push(newList); + saveData(); + + sendSuccess(res, newList); +} + +// 获取所有名单 +function handleGetLists(res) { + sendSuccess(res, rollCallLists); +} + +// 获取单个名单 +function handleGetList(res, query) { + const code = query.code; + const list = rollCallLists.find(l => l.code === code); + + if (list) { + sendSuccess(res, list); + } else { + sendError(res, '名单不存在', 404); + } +} + +// 更新名单 +function handleUpdateList(res, data) { + const { id, name, members } = data; + const list = rollCallLists.find(l => l.id === id); + + if (!list) { + sendError(res, '名单不存在', 404); + return; + } + + if (name) list.name = name; + if (members && Array.isArray(members)) { + list.members = members.map(memberName => { + const existing = list.members.find(m => m.name === memberName); + return existing || { name: memberName, completed: false, timestamp: null }; + }); + } + + saveData(); + sendSuccess(res, list); +} + +// 删除名单 +function handleDeleteList(res, data) { + const { id } = data; + const index = rollCallLists.findIndex(l => l.id === id); + + if (index === -1) { + sendError(res, '名单不存在', 404); + return; + } + + rollCallLists.splice(index, 1); + saveData(); + sendSuccess(res, { message: '删除成功' }); +} + +// 点名 +function handleRollCall(res, data) { + const { code, name } = data; + const list = rollCallLists.find(l => l.code === code); + + if (!list) { + sendError(res, '名单不存在', 404); + return; + } + + const member = list.members.find(m => m.name === name); + if (!member) { + sendError(res, '成员不存在', 404); + return; + } + + if (member.completed) { + sendError(res, '该成员已点名'); + return; + } + + member.completed = true; + member.timestamp = new Date().toLocaleString(); + + // 添加记录 + rollCallRecords.push({ + name: name, + listName: list.name, + code: code, + timestamp: member.timestamp + }); + + saveData(); + sendSuccess(res, { message: '点名成功', member: member }); +} + +// 获取点名记录 +function handleGetRecords(res) { + sendSuccess(res, rollCallRecords); +} + +// 静态文件服务 +function serveStatic(req, res, pathname) { + let filePath = pathname === '/' ? '/index.html' : pathname; + filePath = path.join(__dirname, filePath); + + const extname = path.extname(filePath); + const contentTypes = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif' + }; + + const contentType = contentTypes[extname] || 'text/plain'; + + fs.readFile(filePath, (err, content) => { + if (err) { + if (err.code === 'ENOENT') { + res.writeHead(404); + res.end('文件未找到'); + } else { + res.writeHead(500); + res.end('服务器错误'); + } + } else { + res.writeHead(200, { 'Content-Type': contentType }); + res.end(content); + } + }); +} + +// 发送成功响应 +function sendSuccess(res, data) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: data })); +} + +// 发送错误响应 +function sendError(res, message, code = 400) { + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: message })); +} + +// 启动服务器 +const PORT = 3000; +loadData(); + +server.listen(PORT, () => { + console.log(`点名系统服务器已启动`); + console.log(`访问地址: http://localhost:${PORT}`); + console.log(`后台管理: http://localhost:${PORT}/admin.html`); + console.log(`用户点名: http://localhost:${PORT}/index.html`); +}); diff --git a/storage.js b/storage.js new file mode 100644 index 0000000..abb1aa8 --- /dev/null +++ b/storage.js @@ -0,0 +1,287 @@ +/** + * 通用数据存储模块 + * 支持两种模式: + * 1. 服务器模式:通过HTTP API与后端交互 + * 2. 本地模式:使用localStorage存储数据 + */ + +class DataStorage { + constructor() { + this.apiBase = this.getApiBase(); + this.isServerMode = this.checkServerMode(); + console.log(`数据存储模式: ${this.isServerMode ? '服务器模式' : '本地模式'}`); + } + + /** + * 检测运行模式 + */ + checkServerMode() { + // 如果是通过HTTP服务器访问(非file协议),则使用服务器模式 + return window.location.protocol === 'http:' || window.location.protocol === 'https:'; + } + + /** + * 获取API基础URL + */ + getApiBase() { + if (window.location.protocol === 'http:' || window.location.protocol === 'https:') { + return `${window.location.protocol}//${window.location.host}/api`; + } + return null; + } + + /** + * HTTP请求封装 + */ + async httpRequest(url, method = 'GET', data = null) { + const options = { + method: method, + headers: { + 'Content-Type': 'application/json' + } + }; + + if (data && method !== 'GET') { + options.body = JSON.stringify(data); + } + + try { + const response = await fetch(url, options); + + // 检查响应类型 + const contentType = response.headers.get('content-type'); + const isJson = contentType && contentType.includes('application/json'); + + if (!response.ok) { + // 如果响应不是JSON,尝试读取文本 + if (!isJson) { + const text = await response.text(); + throw new Error(`服务器错误 (${response.status}): 返回了非JSON响应`); + } + + const result = await response.json(); + throw new Error(result.error || `HTTP错误: ${response.status}`); + } + + // 检查成功响应是否为JSON + if (!isJson) { + throw new Error('服务器返回了非JSON响应,请检查API接口'); + } + + const result = await response.json(); + return result; + } catch (error) { + // 处理网络错误 + if (error.message === 'Failed to fetch') { + throw new Error('无法连接到服务器,请确保:\n1. 已启动Node.js服务器\n2. 服务器运行在正确端口\n3. 或使用本地模式(直接打开HTML文件)'); + } + + // 处理JSON解析错误 + if (error.message.includes('Unexpected token')) { + throw new Error('服务器返回了HTML页面而非JSON数据,可能是:\n1. API接口不存在\n2. 服务器配置错误\n3. 建议使用本地模式或检查服务器'); + } + + throw error; + } + } + + /** + * 登录 + */ + async login(username, password) { + if (this.isServerMode) { + // 服务器模式 + const result = await this.httpRequest(`${this.apiBase}/login`, 'POST', { username, password }); + return result.data; + } else { + // 本地模式 + if (username === 'admin' && password === 'admin123') { + return { token: 'local-token-' + Date.now() }; + } + throw new Error('用户名或密码错误'); + } + } + + /** + * 创建点名名单 + */ + async createList(name, members) { + if (this.isServerMode) { + const result = await this.httpRequest(`${this.apiBase}/create-list`, 'POST', { name, members }); + return result.data; + } else { + // 本地模式 + const lists = this.getLocalLists(); + const newList = { + id: Date.now(), + name: name, + code: this.generateCode(), + members: members.map(memberName => ({ + name: memberName, + completed: false, + timestamp: null + })), + createdAt: new Date().toLocaleString() + }; + lists.push(newList); + localStorage.setItem('rollCallLists', JSON.stringify(lists)); + return newList; + } + } + + /** + * 获取所有名单 + */ + async getLists() { + if (this.isServerMode) { + const result = await this.httpRequest(`${this.apiBase}/get-lists`); + return result.data; + } else { + return this.getLocalLists(); + } + } + + /** + * 获取单个名单 + */ + async getList(code) { + if (this.isServerMode) { + const result = await this.httpRequest(`${this.apiBase}/get-list?code=${code}`); + return result.data; + } else { + const lists = this.getLocalLists(); + const list = lists.find(l => l.code === code); + if (!list) { + throw new Error('名单不存在'); + } + return list; + } + } + + /** + * 更新名单 + */ + async updateList(id, name, members) { + if (this.isServerMode) { + const result = await this.httpRequest(`${this.apiBase}/update-list`, 'POST', { id, name, members }); + return result.data; + } else { + const lists = this.getLocalLists(); + const list = lists.find(l => l.id === id); + if (!list) { + throw new Error('名单不存在'); + } + + if (name) list.name = name; + if (members && Array.isArray(members)) { + list.members = members.map(memberName => { + const existing = list.members.find(m => m.name === memberName); + return existing || { name: memberName, completed: false, timestamp: null }; + }); + } + + localStorage.setItem('rollCallLists', JSON.stringify(lists)); + return list; + } + } + + /** + * 删除名单 + */ + async deleteList(id) { + if (this.isServerMode) { + const result = await this.httpRequest(`${this.apiBase}/delete-list`, 'POST', { id }); + return result.data; + } else { + let lists = this.getLocalLists(); + lists = lists.filter(l => l.id !== id); + localStorage.setItem('rollCallLists', JSON.stringify(lists)); + return { message: '删除成功' }; + } + } + + /** + * 点名 + */ + async rollCall(code, name) { + if (this.isServerMode) { + const result = await this.httpRequest(`${this.apiBase}/roll-call`, 'POST', { code, name }); + return result.data; + } else { + const lists = this.getLocalLists(); + const list = lists.find(l => l.code === code); + if (!list) { + throw new Error('名单不存在'); + } + + const member = list.members.find(m => m.name === name); + if (!member) { + throw new Error('成员不存在'); + } + + if (member.completed) { + throw new Error('该成员已点名'); + } + + member.completed = true; + member.timestamp = new Date().toLocaleString(); + + // 添加记录 + const records = this.getLocalRecords(); + records.push({ + name: name, + listName: list.name, + code: code, + timestamp: member.timestamp + }); + + localStorage.setItem('rollCallLists', JSON.stringify(lists)); + localStorage.setItem('rollCallRecords', JSON.stringify(records)); + + return { message: '点名成功', member: member }; + } + } + + /** + * 获取点名记录 + */ + async getRecords() { + if (this.isServerMode) { + const result = await this.httpRequest(`${this.apiBase}/get-records`); + return result.data; + } else { + return this.getLocalRecords(); + } + } + + /** + * 本地模式:获取名单 + */ + getLocalLists() { + const data = localStorage.getItem('rollCallLists'); + return data ? JSON.parse(data) : []; + } + + /** + * 本地模式:获取记录 + */ + getLocalRecords() { + const data = localStorage.getItem('rollCallRecords'); + return data ? JSON.parse(data) : []; + } + + /** + * 生成随机编号 + */ + generateCode() { + const length = Math.floor(Math.random() * 7) + 6; // 6-12位 + let code = ''; + for (let i = 0; i < length; i++) { + code += Math.floor(Math.random() * 10); + } + return code; + } +} + +// 创建全局实例 +window.dataStorage = new DataStorage(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..1b98b2e --- /dev/null +++ b/styles.css @@ -0,0 +1,424 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Microsoft YaHei', Arial, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; +} + +.container { + max-width: 800px; + margin: 0 auto; + background: white; + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + text-align: center; +} + +header h1 { + font-size: 24px; +} + +main { + padding: 20px; +} + +.section { + margin-bottom: 30px; +} + +.section h2 { + color: #333; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 2px solid #667eea; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + color: #555; + font-weight: bold; +} + +.form-group input, +.form-group textarea, +.form-control { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 5px; + font-size: 14px; +} + +.form-group textarea { + resize: vertical; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + font-weight: bold; + transition: all 0.3s; +} + +.btn-primary { + background: #667eea; + color: white; +} + +.btn-primary:hover { + background: #5568d3; +} + +.btn-secondary { + background: #95a5a6; + color: white; +} + +.btn-secondary:hover { + background: #7f8c8d; +} + +.btn-danger { + background: #e74c3c; + color: white; +} + +.btn-danger:hover { + background: #c0392b; +} + +.btn-success { + background: #27ae60; + color: white; +} + +.btn-success:hover { + background: #229954; +} + +.card { + background: #f9f9f9; + border: 1px solid #ddd; + border-radius: 5px; + padding: 20px; + margin-bottom: 20px; +} + +.card h3 { + color: #333; + margin-bottom: 15px; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.divider { + text-align: center; + margin: 20px 0; + position: relative; +} + +.divider::before, +.divider::after { + content: ''; + position: absolute; + top: 50%; + width: 45%; + height: 1px; + background: #ddd; +} + +.divider::before { + left: 0; +} + +.divider::after { + right: 0; +} + +.divider span { + background: white; + padding: 0 10px; + color: #999; +} + +.qr-scanner { + text-align: center; +} + +/* 进度条 */ +.progress-container { + margin: 20px 0; +} + +.progress-info { + text-align: center; + margin-bottom: 10px; + color: #555; +} + +.progress-bar { + width: 100%; + height: 30px; + background: #ecf0f1; + border-radius: 15px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea, #764ba2); + width: 0%; + transition: width 0.3s; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; +} + +/* 名单列表 */ +.member-list { + margin: 20px 0; +} + +.member-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + margin: 5px 0; + background: #f9f9f9; + border-radius: 5px; + border-left: 4px solid #667eea; +} + +.member-item.completed { + background: #d4edda; + border-left-color: #27ae60; +} + +.member-name { + font-weight: bold; + color: #333; +} + +.member-status { + font-size: 12px; + padding: 3px 8px; + border-radius: 3px; +} + +.status-pending { + background: #ffeaa7; + color: #d63031; +} + +.status-completed { + background: #d4edda; + color: #27ae60; +} + +/* 拍照区域 */ +.photo-upload { + margin: 20px 0; +} + +.camera-container { + text-align: center; + margin: 20px 0; +} + +.camera-container video { + border: 2px solid #ddd; + border-radius: 5px; +} + +.camera-controls { + text-align: center; + margin: 10px 0; +} + +.camera-controls button { + margin: 0 5px; +} + +.captured-photo { + text-align: center; + margin: 20px 0; +} + +.captured-photo img { + max-width: 320px; + border: 2px solid #ddd; + border-radius: 5px; +} + +.photo-actions { + margin-top: 10px; +} + +.photo-actions button { + margin: 0 5px; +} + +/* 手动点名 */ +.manual-rollcall { + margin: 20px 0; + padding: 20px; + background: #f9f9f9; + border-radius: 5px; +} + +.manual-rollcall select { + margin: 10px 0; +} + +/* 弹窗 */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +.modal-content { + background-color: white; + margin: 10% auto; + padding: 20px; + border-radius: 10px; + width: 80%; + max-width: 500px; + text-align: center; +} + +.close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; +} + +.close:hover { + color: black; +} + +#qrCode { + margin: 20px 0; +} + +#qrCode canvas { + border: 2px solid #ddd; + border-radius: 5px; +} + +/* 列表项 */ +.list-item { + background: white; + border: 1px solid #ddd; + border-radius: 5px; + padding: 15px; + margin: 10px 0; +} + +.list-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.list-item-title { + font-weight: bold; + color: #333; + font-size: 16px; +} + +.list-item-code { + color: #667eea; + font-weight: bold; +} + +.list-item-actions { + margin-top: 10px; +} + +.list-item-actions button { + margin-right: 5px; +} + +/* 记录项 */ +.record-item { + background: white; + border: 1px solid #ddd; + border-radius: 5px; + padding: 15px; + margin: 10px 0; +} + +.record-header { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.record-name { + font-weight: bold; + color: #333; +} + +.record-time { + color: #999; + font-size: 12px; +} + +/* 响应式 */ +@media (max-width: 600px) { + body { + padding: 10px; + } + + header h1 { + font-size: 20px; + } + + .panel-header { + flex-direction: column; + gap: 10px; + } + + .camera-container video { + width: 100%; + height: auto; + } +} diff --git a/test.html b/test.html new file mode 100644 index 0000000..2bce3bb --- /dev/null +++ b/test.html @@ -0,0 +1,74 @@ + + + + + + 二维码测试 + + + +

二维码生成测试

+ +
+

测试结果:

+

正在检测...

+
+ +
+

二维码显示区域:

+
+ + + + + diff --git a/启动服务器.bat b/启动服务器.bat new file mode 100644 index 0000000..6f4a615 --- /dev/null +++ b/启动服务器.bat @@ -0,0 +1,22 @@ +@echo off +chcp 65001 >nul +echo ==================================== +echo 点名系统启动脚本 +echo ==================================== +echo. +echo 正在检查Node.js... +node -v >nul 2>&1 +if %errorlevel% neq 0 ( + echo [错误] 未检测到Node.js,请先安装Node.js + echo 下载地址: https://nodejs.org/ + pause + exit /b 1 +) + +echo Node.js已安装 +echo. +echo 正在启动服务器... +echo. +node server.js + +pause diff --git a/启动说明.md b/启动说明.md new file mode 100644 index 0000000..3fe0b1a --- /dev/null +++ b/启动说明.md @@ -0,0 +1,86 @@ +# 点名系统启动说明 + +## 🚀 快速启动步骤 + +### 1. 启动后端服务器 + +打开命令行(CMD或PowerShell),进入项目目录: + +```bash +cd d:\!!代码\web\手机点名器 +node server.js +``` + +看到以下提示表示启动成功: +``` +点名系统服务器已启动 +访问地址: http://localhost:3000 +后台管理: http://localhost:3000/admin.html +用户点名: http://localhost:3000/index.html +``` + +### 2. 访问系统 + +在浏览器中打开以下地址: + +- **后台管理**: http://localhost:3000/admin.html +- **用户点名**: http://localhost:3000/index.html + +### 3. 登录后台 + +- 用户名: `admin` +- 密码: `admin123` + +## ❌ 常见错误解决 + +### 错误1: "Failed to fetch" 或 "无法连接到服务器" + +**原因**: 后端服务器未启动 + +**解决方法**: +1. 确认已执行 `node server.js` +2. 检查命令行是否显示"服务器已启动" +3. 不要直接双击HTML文件打开,必须通过 http://localhost:3000 访问 + +### 错误2: "端口被占用" + +**原因**: 3000端口已被其他程序使用 + +**解决方法**: +1. 修改 server.js 中的端口号(第189行) +2. 将 `const PORT = 3000;` 改为其他端口,如 `const PORT = 3001;` +3. 访问地址相应改为 http://localhost:3001 + +### 错误3: "node不是内部或外部命令" + +**原因**: 未安装Node.js + +**解决方法**: +1. 下载安装 Node.js: https://nodejs.org/ +2. 安装后重启命令行 +3. 验证安装: `node -v` + +## 📝 使用流程 + +1. **启动服务器** → `node server.js` +2. **打开后台** → http://localhost:3000/admin.html +3. **登录系统** → admin / admin123 +4. **创建名单** → 输入名单名称和成员 +5. **获取编号** → 系统生成编号和二维码 +6. **用户点名** → 打开index.html,输入编号 +7. **完成点名** → 拍照或手动点名 + +## 🔧 技术说明 + +- **前端**: HTML + CSS + JavaScript +- **后端**: Node.js原生HTTP服务器 +- **数据**: JSON文件存储 +- **端口**: 默认3000 +- **协议**: HTTP + +## 💡 提示 + +- 服务器必须保持运行状态 +- 关闭命令行窗口会停止服务器 +- 数据保存在 data_lists.json 和 data_records.json +- 清除浏览器缓存不会丢失数据 diff --git a/快速使用指南.md b/快速使用指南.md new file mode 100644 index 0000000..75a903f --- /dev/null +++ b/快速使用指南.md @@ -0,0 +1,132 @@ +# 点名系统 - 快速使用指南 + +## 🎯 推荐使用方式 + +### 方式一:本地模式(最简单,推荐!) + +**无需任何配置,即开即用!** + +1. **打开后台管理** + - 直接双击 `admin.html` 文件 + - 或在浏览器中打开 `admin.html` + +2. **登录系统** + - 用户名: `admin` + - 密码: `admin123` + +3. **创建点名名单** + - 输入名单名称 + - 输入成员(每行一个姓名) + - 点击"生成" + +4. **用户点名** + - 打开 `index.html` + - 输入编号 + - 进行点名 + +**优点**: +- ✅ 无需安装Node.js +- ✅ 无需启动服务器 +- ✅ 即开即用 +- ✅ 数据保存在浏览器 + +--- + +### 方式二:服务器模式(功能完整) + +**需要Node.js环境** + +1. **启动服务器** + ```bash + node server.js + ``` + 或双击 `启动服务器.bat` + +2. **访问系统** + - 后台: http://localhost:3000/admin.html + - 点名: http://localhost:3000/index.html + +3. **登录使用** + - 用户名: `admin` + - 密码: `admin123` + +**优点**: +- ✅ 数据永久保存 +- ✅ 支持多用户 +- ✅ 可局域网访问 + +--- + +## ❌ 常见错误解决 + +### 错误1: "Unexpected token '<', ... is not valid JSON" + +**原因**: 服务器模式但API接口不存在 + +**解决方案**: +1. **推荐**: 使用本地模式(直接双击HTML文件) +2. 或确保已启动Node.js服务器(`node server.js`) + +### 错误2: "无法连接到服务器" + +**原因**: 服务器未启动 + +**解决方案**: +1. 使用本地模式(推荐) +2. 或启动服务器: `node server.js` + +### 错误3: "Failed to fetch" + +**原因**: 网络请求失败 + +**解决方案**: +1. 使用本地模式 +2. 或检查服务器是否运行 + +--- + +## 💡 使用建议 + +### 什么时候用本地模式? +- ✅ 个人学习测试 +- ✅ 快速演示 +- ✅ 临时使用 +- ✅ 无需多用户 + +### 什么时候用服务器模式? +- ✅ 正式使用 +- ✅ 需要数据持久化 +- ✅ 多人协作 +- ✅ 局域网访问 + +--- + +## 🔍 如何判断当前模式? + +打开页面后,在标题下方会显示: +- "当前运行模式: 本地模式 (数据保存在浏览器本地)" +- "当前运行模式: 服务器模式" + +--- + +## 📞 快速故障排查 + +**遇到任何错误,首先尝试:** + +1. **刷新页面** (F5) +2. **清除浏览器缓存** (Ctrl+Shift+Delete) +3. **使用本地模式** (直接双击HTML文件) + +如果本地模式正常,说明是服务器配置问题,继续使用本地模式即可。 + +--- + +## 🎉 总结 + +**最简单的使用方法:** +1. 双击 `admin.html` +2. 登录: admin / admin123 +3. 创建名单 +4. 双击 `index.html` 点名 + +**无需任何配置,开箱即用!**