cursor done.

This commit is contained in:
2025-11-11 14:36:09 +08:00
parent 7a5fb889c5
commit 9b1eb6cafd
27 changed files with 4748 additions and 552 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.DS_Store
node_modules/
.git/
.gitignore
*.md

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.DS_Store
node_modules/
data.db

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:25-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -1,44 +1,81 @@
# 任务日历系统 # 任务日历系统
一个简洁美观的任务日历系统,可以标记每一天的任务完成状态 一个可以给每天打勾/打圈的极简任务日历,支持多服务器、多终端同步,还自带 Docker 部署方案
## 功能特性 ## ✨ 特色功能
- **三态标记**:未标记 / 已完成(✓)/ 部分完成(○),一键轮换
- **多服务器同步**:同一套前端,想连哪台后端就连哪台
- **自动保存**:修改 0.5 秒后自动写入数据库,再也不怕刷新丢数据
- **多端布局**:桌面、平板、手机都能舒舒服服地操作
- **模块化前后端**:逻辑拆分清晰,方便二次开发
- 📅 **日历视图**:清晰的月份日历展示 ## 🚀 快速开始
-**任务标记**:支持三种状态
- ✓ 任务已完成(绿色)
- ○ 任务部分未完成(橙色)
- 未标记(灰色)
- 💾 **数据保存**:使用浏览器 localStorage 自动保存标记数据
- 🎨 **简洁界面**:现代化的 UI 设计,响应式布局
- 🖱️ **便捷操作**:点击日期即可切换标记状态
## 使用方法 ### 方式一Docker
```bash
docker-compose up -d
```
然后访问 `http://localhost:3000`
1. 直接在浏览器中打开 `index.html` 文件 ### 方式二:本地运行
2. 点击日历上的日期来切换标记状态: ```bash
- 第一次点击:标记为"已完成"(✓) npm install
- 第二次点击:标记为"部分完成"(○) npm start
- 第三次点击:清除标记 ```
3. 使用左右箭头按钮切换月份 同样访问 `http://localhost:3000`
4. 点击"今天"按钮快速回到当前月份
## 文件结构 > 如需自定义端口,运行前设定 `PORT=xxxx` 即可。
## 🧭 使用指南
1. **添加服务器**:页面底部输入想连接的后端地址,点击「添加」,并从下拉框选择它
2. **注册 / 登录**:按提示填写信息,完成后即可自动加载个人日历
3. **打勾打圈**:点击某天即可循环切换状态;右上角的 Toast 会给出操作反馈
4. **自动保存**:系统会在短暂延迟后后台保存,提示「数据已保存」即完成
5. **随时切换**:切换到其他服务器/账号时,会自动同步对应数据
## 🏗 项目结构
``` ```
timeline/ timeline/
├── index.html # 主页面 ├── server.js # 后端入口
├── style.css # 样式文件 ├── server/
├── app.js # 应用逻辑 │ ├── config.js # 基础配置
└── README.md # 说明文档 │ ├── database.js # SQLite 连接与初始化
│ ├── middleware/
│ │ └── auth.js # JWT 鉴权
│ ├── routes/
│ │ ├── auth.js # 登录 / 注册 / 当前用户
│ │ └── calendar.js # 日历数据读写
│ └── utils/
│ └── shutdown.js # 优雅关闭处理
├── public/
│ ├── index.html
│ ├── style.css
│ └── js/
│ ├── main.js # 前端入口
│ ├── auth.js # 登录注册逻辑
│ ├── calendar.js # 日历渲染与交互
│ ├── api.js # 与后端通信
│ ├── storage.js # 本地存储封装
│ ├── state.js # 全局状态
│ └── toast.js # 提示组件
├── Dockerfile
├── docker-compose.yml
├── package.json
└── README.md
``` ```
## 技术说明 ## ⚙️ 配置项
- `PORT`:后端监听端口,默认 `3000`
- `JWT_SECRET`JWT 密钥,默认简单字符串,生产环境务必更换
- 纯 HTML/CSS/JavaScript 实现,无需构建工具 ## 💡 小贴士
- 使用 localStorage 保存数据,刷新页面后数据不会丢失 - 服务器列表保存在浏览器 LocalStorage可随时增删
- 响应式设计,支持移动端和桌面端 - 支持多个浏览器/设备同时登录同一账号,数据实时同步
- 若要重置数据,直接删除根目录下的 `data.db` 即可(注意备份)
## 浏览器兼容性 ## 🧱 技术栈速览
- **后端**Node.js、Express 5、SQLite、JWT、bcryptjs
- **前端**:原生 HTML/CSS/JS + 模块化组织
- **部署**Docker / Docker Compose 一键启动
支持所有现代浏览器Chrome、Firefox、Safari、Edge 等) Enjoy hacking! 🎉

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3.8'
services:
timeline-api:
build: .
ports:
- "3000:3000"
environment:
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-change-this-secret-key-in-production}
volumes:
- ./data.db:/app/data.db
- ./public:/app/public
restart: unless-stopped

2629
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "timeline-calendar",
"version": "2.0.0",
"description": "任务日历系统 - 带用户认证和多服务器支持",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [
"calendar",
"task",
"timeline"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^5.1.0",
"cors": "^2.8.5",
"sqlite3": "^5.1.7",
"bcryptjs": "^3.0.3",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

101
public/index.html Normal file
View File

@@ -0,0 +1,101 @@
<!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="style.css">
</head>
<body>
<!-- 登录/注册模态框 -->
<div id="authModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="authTitle">登录</h2>
<button class="modal-close" id="closeAuthModal">&times;</button>
</div>
<div class="modal-body">
<div id="authForm">
<div class="form-group">
<label for="authServer">服务器地址:</label>
<input type="text" id="authServer" placeholder="例如http://localhost:3000" />
</div>
<div class="form-group">
<label for="authUsername">用户名:</label>
<input type="text" id="authUsername" placeholder="至少3个字符" />
</div>
<div class="form-group">
<label for="authPassword">密码:</label>
<input type="password" id="authPassword" placeholder="至少6个字符" />
</div>
<div id="authMessage" class="auth-message"></div>
<div class="form-actions">
<button id="loginBtn" class="btn-primary">登录</button>
<button id="registerBtn" class="btn-secondary">注册</button>
</div>
<div class="auth-switch">
<span id="switchToRegister">还没有账号?点击注册</span>
<span id="switchToLogin" style="display: none;">已有账号?点击登录</span>
</div>
</div>
</div>
</div>
</div>
<!-- 主应用容器 -->
<div class="container" id="mainContainer" style="display: none;">
<header>
<div class="user-info">
<span id="userDisplay">未登录</span>
<button id="logoutBtn" class="btn-logout">退出</button>
</div>
<h1>任务日历</h1>
<div class="controls">
<button id="prevMonth" class="btn-nav"></button>
<span id="currentMonth" class="month-display"></span>
<button id="nextMonth" class="btn-nav"></button>
<button id="todayBtn" class="btn-today">今天</button>
</div>
</header>
<div class="legend">
<div class="legend-item">
<span class="mark completed"></span>
<span>任务已完成</span>
</div>
<div class="legend-item">
<span class="mark partial"></span>
<span>部分未完成</span>
</div>
<div class="legend-item">
<span class="mark none"></span>
<span>未标记</span>
</div>
</div>
<div class="calendar" id="calendar"></div>
<div class="instructions">
<p>💡 点击日期可切换标记状态:未标记 → 已完成 → 部分完成 → 未标记</p>
<p>📌 选择服务器后,数据会自动加载和保存</p>
</div>
<div class="server-section">
<label for="serverInput">API服务器地址</label>
<div class="server-input-group">
<input type="text" id="serverInput" placeholder="例如http://localhost:3000" />
<button id="addServerBtn" class="btn-add">添加</button>
<select id="serverSelect" class="server-select">
<option value="">选择服务器</option>
</select>
<button id="removeServerBtn" class="btn-remove">删除</button>
</div>
</div>
</div>
<!-- 右上角提示消息 -->
<div id="statusMessage" class="status-message-toast"></div>
<script type="module" src="js/main.js"></script>
</body>
</html>

66
public/js/api.js Normal file
View File

@@ -0,0 +1,66 @@
export class ApiError extends Error {
constructor(message, status) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function request(server, path, { method = 'GET', body, token } = {}) {
try {
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${server}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const message = data.error || data.message || response.statusText;
throw new ApiError(message, response.status);
}
return data;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError('无法连接到服务器,请确认服务已启动', 0);
}
}
export async function register(server, username, password) {
return request(server, '/api/auth/register', {
method: 'POST',
body: { username, password }
});
}
export async function login(server, username, password) {
return request(server, '/api/auth/login', {
method: 'POST',
body: { username, password }
});
}
export async function fetchCurrentUser(server, token) {
return request(server, '/api/auth/me', { token });
}
export async function fetchCalendar(server, token) {
return request(server, '/api/calendar', { token });
}
export async function saveCalendar(server, token, markedDates) {
return request(server, '/api/calendar', {
method: 'POST',
token,
body: { markedDates }
});
}

343
public/js/auth.js Normal file
View File

@@ -0,0 +1,343 @@
import state from './state.js';
import {
getToken,
setToken,
getUser,
setUser,
getCurrentServer,
setCurrentServer as persistCurrentServer,
clearAuth
} from './storage.js';
import { showToast } from './toast.js';
import { login, register, ApiError } from './api.js';
export default class AuthManager {
constructor(calendarView, serverManager) {
this.calendarView = calendarView;
this.serverManager = serverManager;
this.authModal = document.getElementById('authModal');
this.mainContainer = document.getElementById('mainContainer');
this.authTitle = document.getElementById('authTitle');
this.authUsername = document.getElementById('authUsername');
this.authPassword = document.getElementById('authPassword');
this.authServer = document.getElementById('authServer');
this.authMessage = document.getElementById('authMessage');
this.loginBtn = document.getElementById('loginBtn');
this.registerBtn = document.getElementById('registerBtn');
this.switchToRegister = document.getElementById('switchToRegister');
this.switchToLogin = document.getElementById('switchToLogin');
this.closeModalBtn = document.getElementById('closeAuthModal');
this.logoutBtn = document.getElementById('logoutBtn');
this.userDisplay = document.getElementById('userDisplay');
}
init() {
this.bindEvents();
this.updateAuthUI();
}
bindEvents() {
if (this.loginBtn) {
this.loginBtn.addEventListener('click', () => this.handleLogin());
}
if (this.registerBtn) {
this.registerBtn.addEventListener('click', () => this.handleRegister());
}
if (this.switchToRegister) {
this.switchToRegister.addEventListener('click', () => {
state.isLoginMode = false;
this.updateAuthUI();
});
}
if (this.switchToLogin) {
this.switchToLogin.addEventListener('click', () => {
state.isLoginMode = true;
this.updateAuthUI();
});
}
if (this.closeModalBtn) {
this.closeModalBtn.addEventListener('click', () => {
if (!state.token) {
showToast('请先登录后再关闭', 'error');
return;
}
this.hideAuthModal();
});
}
if (this.logoutBtn) {
this.logoutBtn.addEventListener('click', () => this.logout());
}
if (this.authPassword) {
this.authPassword.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
state.isLoginMode ? this.handleLogin() : this.handleRegister();
}
});
}
}
async handleLogin() {
const server = this.getServerInput();
const username = (this.authUsername?.value || '').trim();
const password = this.authPassword?.value || '';
if (!server || !username || !password) {
this.showAuthMessage('请输入服务器、用户名和密码');
return;
}
try {
this.showAuthMessage('正在登录...', 'info');
const result = await login(server, username, password);
this.afterAuthSuccess(server, result.token, {
username: result.username,
userId: result.userId
});
showToast('登录成功', 'success');
} catch (error) {
this.handleAuthError(error);
}
}
async handleRegister() {
const server = this.getServerInput();
const username = (this.authUsername?.value || '').trim();
const password = this.authPassword?.value || '';
if (!server || !username || !password) {
this.showAuthMessage('请输入服务器、用户名和密码');
return;
}
if (username.length < 3) {
this.showAuthMessage('用户名至少需要3个字符');
return;
}
if (password.length < 6) {
this.showAuthMessage('密码至少需要6个字符');
return;
}
try {
this.showAuthMessage('正在注册...', 'info');
const result = await register(server, username, password);
this.afterAuthSuccess(server, result.token, {
username: result.username,
userId: result.userId
});
showToast('注册成功', 'success');
} catch (error) {
this.handleAuthError(error);
}
}
getServerInput() {
const value = this.authServer?.value.trim();
if (!value) {
this.showAuthMessage('请输入服务器地址');
return '';
}
try {
new URL(value);
return value;
} catch (error) {
this.showAuthMessage('无效的服务器地址格式');
return '';
}
}
afterAuthSuccess(server, token, user) {
state.currentServer = server;
state.token = token;
state.user = user;
persistCurrentServer(server);
setToken(server, token);
setUser(server, user);
if (this.serverManager) {
this.serverManager.ensureServerInList(server);
}
this.hideAuthModal();
this.showMainContainer();
this.updateUserDisplay();
if (this.serverManager) {
this.serverManager.setCurrentServer(server);
}
this.calendarView.loadData();
this.clearAuthForm();
this.clearAuthMessage();
}
handleAuthError(error) {
if (error instanceof ApiError) {
this.showAuthMessage(error.message || '操作失败');
return;
}
this.showAuthMessage('操作失败,请稍后再试');
}
clearAuthForm() {
if (this.authPassword) this.authPassword.value = '';
}
showAuthMessage(message, type = 'error') {
if (!this.authMessage) return;
this.authMessage.textContent = message;
this.authMessage.className = `auth-message ${type}`;
}
clearAuthMessage() {
if (!this.authMessage) return;
this.authMessage.textContent = '';
this.authMessage.className = 'auth-message';
}
updateAuthUI() {
if (!this.authTitle || !this.loginBtn || !this.registerBtn || !this.switchToLogin || !this.switchToRegister) {
return;
}
if (state.isLoginMode) {
this.authTitle.textContent = '登录';
this.loginBtn.style.display = 'block';
this.registerBtn.style.display = 'none';
this.switchToRegister.style.display = 'block';
this.switchToLogin.style.display = 'none';
} else {
this.authTitle.textContent = '注册';
this.loginBtn.style.display = 'none';
this.registerBtn.style.display = 'block';
this.switchToRegister.style.display = 'none';
this.switchToLogin.style.display = 'block';
}
}
showAuthModal(prefillServer) {
if (this.authModal) {
this.authModal.style.display = 'flex';
}
if (this.mainContainer) {
this.mainContainer.style.display = 'none';
}
const serverValue = prefillServer || state.currentServer || window.location.origin;
if (this.authServer) {
this.authServer.value = serverValue;
}
state.isLoginMode = true;
this.updateAuthUI();
this.clearAuthMessage();
}
hideAuthModal() {
if (this.authModal) {
this.authModal.style.display = 'none';
}
}
showMainContainer() {
if (this.mainContainer) {
this.mainContainer.style.display = 'block';
}
this.hideAuthModal();
}
hideMainContainer() {
if (this.mainContainer) {
this.mainContainer.style.display = 'none';
}
}
updateUserDisplay() {
if (this.userDisplay) {
this.userDisplay.textContent = state.user ? `用户: ${state.user.username}` : '未登录';
}
}
async handleServerSelection(server) {
if (!server) {
persistCurrentServer('');
state.currentServer = '';
state.token = '';
state.user = null;
this.calendarView.clearData();
this.hideMainContainer();
this.showAuthModal();
this.updateUserDisplay();
return;
}
persistCurrentServer(server);
state.currentServer = server;
const storedToken = getToken(server);
const storedUser = getUser(server);
if (storedToken && storedUser) {
state.token = storedToken;
state.user = storedUser;
this.showMainContainer();
this.updateUserDisplay();
this.calendarView.loadData();
this.hideAuthModal();
} else {
state.token = '';
state.user = null;
this.calendarView.clearData();
this.updateUserDisplay();
this.showAuthModal(server);
}
}
logout() {
if (state.currentServer) {
clearAuth(state.currentServer);
}
state.token = '';
state.user = null;
this.calendarView.clearData();
this.updateUserDisplay();
showToast('已退出登录', 'success');
this.hideMainContainer();
this.showAuthModal(state.currentServer || window.location.origin);
}
checkExistingSession() {
const storedServer = getCurrentServer();
if (storedServer) {
state.currentServer = storedServer;
if (this.serverManager) {
this.serverManager.ensureServerInList(storedServer);
this.serverManager.setCurrentServer(storedServer);
}
}
const storedToken = storedServer ? getToken(storedServer) : '';
const storedUser = storedServer ? getUser(storedServer) : null;
if (storedServer && storedToken && storedUser) {
state.token = storedToken;
state.user = storedUser;
this.showMainContainer();
this.updateUserDisplay();
this.calendarView.loadData();
} else {
this.hideMainContainer();
this.showAuthModal(storedServer || window.location.origin);
}
}
}

244
public/js/calendar.js Normal file
View File

@@ -0,0 +1,244 @@
import state from './state.js';
import { fetchCalendar, saveCalendar, ApiError } from './api.js';
import { showToast } from './toast.js';
export default class CalendarView {
constructor({ onUnauthorized } = {}) {
this.calendarEl = document.getElementById('calendar');
this.monthDisplayEl = document.getElementById('currentMonth');
this.prevBtn = document.getElementById('prevMonth');
this.nextBtn = document.getElementById('nextMonth');
this.todayBtn = document.getElementById('todayBtn');
this.weekdays = ['日', '一', '二', '三', '四', '五', '六'];
this.monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
this.onUnauthorized = onUnauthorized;
this.saveDelay = 500;
}
init() {
this.bindNavigation();
this.renderCalendar();
}
bindNavigation() {
if (this.prevBtn) {
this.prevBtn.addEventListener('click', () => {
state.currentDate.setMonth(state.currentDate.getMonth() - 1);
this.renderCalendar();
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener('click', () => {
state.currentDate.setMonth(state.currentDate.getMonth() + 1);
this.renderCalendar();
});
}
if (this.todayBtn) {
this.todayBtn.addEventListener('click', () => {
state.currentDate = new Date();
this.renderCalendar();
});
}
}
async loadData() {
if (!state.currentServer || !state.token) {
return;
}
try {
showToast('正在加载...', 'info');
const result = await fetchCalendar(state.currentServer, state.token);
state.markedDates = result.markedDates || {};
this.renderCalendar();
showToast('数据加载成功', 'success');
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
showToast('认证已过期,请重新登录', 'error');
if (this.onUnauthorized) {
this.onUnauthorized();
}
return;
}
showToast(error.message || '加载失败', 'error');
}
}
clearData() {
state.markedDates = {};
this.renderCalendar();
}
renderCalendar() {
if (!this.calendarEl || !this.monthDisplayEl) return;
const currentDate = state.currentDate;
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
this.calendarEl.innerHTML = '';
this.weekdays.forEach(day => {
const weekdayEl = document.createElement('div');
weekdayEl.className = 'weekday';
weekdayEl.textContent = day;
this.calendarEl.appendChild(weekdayEl);
});
this.monthDisplayEl.textContent = `${year}${this.monthNames[month]}`;
const firstDay = this.getFirstDayOfMonth(currentDate);
const daysInMonth = this.getDaysInMonth(currentDate);
const prevMonthDate = new Date(year, month, 0);
const daysInPrevMonth = prevMonthDate.getDate();
for (let i = firstDay - 1; i >= 0; i--) {
const day = daysInPrevMonth - i;
const date = new Date(year, month - 1, day);
this.calendarEl.appendChild(this.createDayElement(date, day, true));
}
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
this.calendarEl.appendChild(this.createDayElement(date, day, false));
}
const totalCells = this.calendarEl.children.length - this.weekdays.length;
const remainingCells = 42 - totalCells;
for (let day = 1; day <= remainingCells; day++) {
const date = new Date(year, month + 1, day);
this.calendarEl.appendChild(this.createDayElement(date, day, true));
}
}
createDayElement(date, dayNumber, isOtherMonth) {
const dayEl = document.createElement('div');
dayEl.className = 'day';
if (isOtherMonth) {
dayEl.classList.add('other-month');
}
if (this.isToday(date)) {
dayEl.classList.add('today');
}
const status = this.getDateStatus(date);
if (status !== 'none') {
dayEl.classList.add(status);
}
const dayNumberEl = document.createElement('div');
dayNumberEl.className = 'day-number';
dayNumberEl.textContent = dayNumber;
dayEl.appendChild(dayNumberEl);
if (status !== 'none') {
const markEl = document.createElement('div');
markEl.className = 'day-mark';
markEl.textContent = status === 'completed' ? '✓' : '○';
dayEl.appendChild(markEl);
}
dayEl.addEventListener('click', () => {
if (!isOtherMonth) {
this.toggleDateStatus(date);
}
});
return dayEl;
}
toggleDateStatus(date) {
if (!state.currentServer || !state.token) {
showToast('请先选择服务器并登录', 'error');
return;
}
const dateKey = this.formatDate(date);
const currentStatus = state.markedDates[dateKey] || 'none';
let nextStatus;
switch (currentStatus) {
case 'none':
nextStatus = 'completed';
break;
case 'completed':
nextStatus = 'partial';
break;
case 'partial':
default:
nextStatus = 'none';
break;
}
if (nextStatus === 'none') {
delete state.markedDates[dateKey];
} else {
state.markedDates[dateKey] = nextStatus;
}
this.renderCalendar();
this.scheduleSave();
}
scheduleSave() {
if (!state.currentServer || !state.token) return;
if (state.saveTimeoutId) {
clearTimeout(state.saveTimeoutId);
}
state.saveTimeoutId = setTimeout(() => {
this.persistMarkedDates();
}, this.saveDelay);
}
async persistMarkedDates() {
state.saveTimeoutId = null;
try {
await saveCalendar(state.currentServer, state.token, state.markedDates);
showToast('数据已保存', 'success');
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
showToast('认证已过期,请重新登录', 'error');
if (this.onUnauthorized) {
this.onUnauthorized();
}
return;
}
showToast(error.message || '保存失败', 'error');
}
}
getFirstDayOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
}
getDaysInMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
}
getDateStatus(date) {
const dateKey = this.formatDate(date);
return state.markedDates[dateKey] || 'none';
}
isToday(date) {
const today = new Date();
return date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
}
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}

24
public/js/main.js Normal file
View File

@@ -0,0 +1,24 @@
import CalendarView from './calendar.js';
import ServerManager from './serverManager.js';
import AuthManager from './auth.js';
let authManagerRef = null;
window.addEventListener('DOMContentLoaded', () => {
const calendarView = new CalendarView({
onUnauthorized: () => authManagerRef && authManagerRef.logout()
});
calendarView.init();
const serverManager = new ServerManager();
serverManager.init();
authManagerRef = new AuthManager(calendarView, serverManager);
authManagerRef.init();
serverManager.onChange((server) => {
authManagerRef.handleServerSelection(server);
});
authManagerRef.checkExistingSession();
});

166
public/js/serverManager.js Normal file
View File

@@ -0,0 +1,166 @@
import state from './state.js';
import {
getServers,
saveServers,
getCurrentServer,
setCurrentServer as persistCurrentServer,
clearAuth
} from './storage.js';
import { showToast } from './toast.js';
export default class ServerManager {
constructor() {
this.serverInput = document.getElementById('serverInput');
this.addBtn = document.getElementById('addServerBtn');
this.removeBtn = document.getElementById('removeServerBtn');
this.selectEl = document.getElementById('serverSelect');
this.servers = [];
this.changeHandler = null;
}
init() {
this.servers = getServers();
this.renderOptions();
const storedCurrent = getCurrentServer();
if (storedCurrent) {
this.setCurrentServer(storedCurrent);
state.currentServer = storedCurrent;
}
if (this.addBtn) {
this.addBtn.addEventListener('click', () => this.handleAdd());
}
if (this.removeBtn) {
this.removeBtn.addEventListener('click', () => this.handleRemove());
}
if (this.selectEl) {
this.selectEl.addEventListener('change', () => this.handleSelect());
}
}
onChange(handler) {
this.changeHandler = handler;
}
handleAdd() {
const server = (this.serverInput?.value || '').trim();
if (!server) {
showToast('请输入服务器地址', 'error');
return;
}
if (!this.isValidUrl(server)) {
showToast('无效的服务器地址格式', 'error');
return;
}
if (this.servers.includes(server)) {
showToast('服务器已存在', 'error');
return;
}
this.servers.push(server);
saveServers(this.servers);
this.renderOptions();
this.setCurrentServer(server);
persistCurrentServer(server);
showToast('服务器已添加', 'success');
if (this.changeHandler) {
this.changeHandler(server);
}
if (this.serverInput) {
this.serverInput.value = '';
}
}
handleRemove() {
const server = this.selectEl?.value;
if (!server) {
showToast('请选择要删除的服务器', 'error');
return;
}
if (!confirm(`确定要删除服务器 "${server}" 吗?`)) {
return;
}
this.servers = this.servers.filter(item => item !== server);
saveServers(this.servers);
clearAuth(server);
if (state.currentServer === server) {
state.currentServer = '';
}
persistCurrentServer('');
this.renderOptions();
showToast('服务器已删除', 'success');
if (this.changeHandler) {
this.changeHandler('');
}
}
handleSelect() {
const server = this.selectEl?.value || '';
persistCurrentServer(server);
state.currentServer = server;
if (this.changeHandler) {
this.changeHandler(server);
}
}
ensureServerInList(server) {
if (!server) return;
if (!this.servers.includes(server)) {
this.servers.push(server);
saveServers(this.servers);
this.renderOptions();
}
this.setCurrentServer(server);
}
setCurrentServer(server) {
if (!this.selectEl) return;
const optionExists = Array.from(this.selectEl.options).some(opt => opt.value === server);
if (!optionExists && server) {
const option = document.createElement('option');
option.value = server;
option.textContent = server;
this.selectEl.appendChild(option);
}
this.selectEl.value = server || '';
}
renderOptions() {
if (!this.selectEl) return;
this.selectEl.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = '选择服务器';
this.selectEl.appendChild(placeholder);
this.servers.forEach(server => {
const option = document.createElement('option');
option.value = server;
option.textContent = server;
this.selectEl.appendChild(option);
});
}
isValidUrl(value) {
try {
new URL(value);
return true;
} catch (error) {
return false;
}
}
}

11
public/js/state.js Normal file
View File

@@ -0,0 +1,11 @@
const state = {
currentDate: new Date(),
markedDates: {},
currentServer: '',
token: '',
user: null,
saveTimeoutId: null,
isLoginMode: true
};
export default state;

53
public/js/storage.js Normal file
View File

@@ -0,0 +1,53 @@
const SERVERS_KEY = 'apiServers';
const CURRENT_SERVER_KEY = 'currentServer';
const TOKEN_PREFIX = 'token_';
const USER_PREFIX = 'user_';
export function getServers() {
const servers = localStorage.getItem(SERVERS_KEY);
return servers ? JSON.parse(servers) : [];
}
export function saveServers(servers) {
localStorage.setItem(SERVERS_KEY, JSON.stringify(servers));
}
export function getCurrentServer() {
return localStorage.getItem(CURRENT_SERVER_KEY) || '';
}
export function setCurrentServer(server) {
if (server) {
localStorage.setItem(CURRENT_SERVER_KEY, server);
} else {
localStorage.removeItem(CURRENT_SERVER_KEY);
}
}
export function getToken(server) {
return server ? localStorage.getItem(`${TOKEN_PREFIX}${server}`) || '' : '';
}
export function setToken(server, token) {
if (server) {
localStorage.setItem(`${TOKEN_PREFIX}${server}`, token);
}
}
export function getUser(server) {
if (!server) return null;
const value = localStorage.getItem(`${USER_PREFIX}${server}`);
return value ? JSON.parse(value) : null;
}
export function setUser(server, user) {
if (server) {
localStorage.setItem(`${USER_PREFIX}${server}`, JSON.stringify(user));
}
}
export function clearAuth(server) {
if (!server) return;
localStorage.removeItem(`${TOKEN_PREFIX}${server}`);
localStorage.removeItem(`${USER_PREFIX}${server}`);
}

22
public/js/toast.js Normal file
View File

@@ -0,0 +1,22 @@
const toastEl = document.getElementById('statusMessage');
export function showToast(message, type = 'info', duration = null) {
if (!toastEl) return;
toastEl.textContent = message;
toastEl.className = `status-message-toast ${type}`;
requestAnimationFrame(() => {
toastEl.classList.add('show');
});
const timeout = duration !== null ? duration : type === 'info' ? 4000 : 3000;
setTimeout(() => {
toastEl.classList.remove('show');
setTimeout(() => {
toastEl.textContent = '';
toastEl.className = 'status-message-toast';
}, 300);
}, timeout);
}

617
public/style.css Normal file
View File

@@ -0,0 +1,617 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
/* 模态框样式 */
.modal {
display: flex;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
}
.modal-content {
background-color: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 400px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
color: #667eea;
font-size: 1.5em;
}
.modal-close {
background: none;
border: none;
font-size: 28px;
color: #999;
cursor: pointer;
line-height: 1;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
color: #333;
}
.modal-body {
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-actions {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.btn-primary, .btn-secondary {
flex: 1;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #10b981;
color: white;
}
.btn-secondary:hover {
background: #059669;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.auth-message {
margin-bottom: 15px;
padding: 10px;
border-radius: 6px;
font-size: 13px;
text-align: center;
min-height: 20px;
}
.auth-message.error {
background: #fee2e2;
color: #991b1b;
}
.auth-message.success {
background: #d1fae5;
color: #065f46;
}
.auth-switch {
text-align: center;
font-size: 13px;
color: #667eea;
cursor: pointer;
}
.auth-switch:hover {
text-decoration: underline;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 30px;
}
header {
text-align: center;
margin-bottom: 30px;
}
.user-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding: 10px 15px;
background: #f8f9fa;
border-radius: 8px;
}
.user-info span {
font-weight: 600;
color: #667eea;
}
.btn-logout {
padding: 6px 12px;
background: #ef4444;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-logout:hover {
background: #dc2626;
transform: translateY(-1px);
}
header h1 {
font-size: 2.5em;
color: #667eea;
margin-bottom: 20px;
font-weight: 600;
}
.server-section {
margin-top: 30px;
margin-bottom: 0;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
border-top: 2px solid #e5e7eb;
}
.server-section label {
display: block;
margin-bottom: 10px;
font-weight: 600;
color: #333;
font-size: 14px;
}
.server-input-group {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
#serverInput {
flex: 1;
min-width: 200px;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
}
#serverInput:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.server-select {
flex: 1;
min-width: 200px;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
background: white;
cursor: pointer;
transition: all 0.3s ease;
}
.server-select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn-add, .btn-remove {
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-add {
background: #10b981;
color: white;
}
.btn-add:hover {
background: #059669;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.btn-remove {
background: #ef4444;
color: white;
}
.btn-remove:hover {
background: #dc2626;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
/* 右上角 Toast 提示消息 */
.status-message-toast {
position: fixed;
top: 20px;
right: 20px;
padding: 14px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
min-width: 200px;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
opacity: 0;
transform: translateX(400px);
transition: all 0.3s ease;
pointer-events: none;
}
.status-message-toast.show {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.status-message-toast.success {
background: #10b981;
color: white;
}
.status-message-toast.error {
background: #ef4444;
color: white;
}
.status-message-toast.info {
background: #667eea;
color: white;
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.btn-nav {
background: #667eea;
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 24px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.btn-nav:hover {
background: #5568d3;
transform: scale(1.1);
}
.btn-nav:active {
transform: scale(0.95);
}
.month-display {
font-size: 1.5em;
font-weight: 600;
color: #333;
min-width: 200px;
}
.btn-today {
background: #764ba2;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-today:hover {
background: #653a8a;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4);
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin-bottom: 25px;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.mark {
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.mark.completed {
background: #10b981;
color: white;
}
.mark.partial {
background: #f59e0b;
color: white;
}
.mark.none {
background: #e5e7eb;
border: 2px solid #d1d5db;
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-bottom: 20px;
}
.weekday {
text-align: center;
font-weight: 600;
color: #667eea;
padding: 12px;
font-size: 14px;
background: #f0f4ff;
border-radius: 8px;
}
.day {
aspect-ratio: 1;
border: 2px solid #e5e7eb;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
background: white;
position: relative;
padding: 8px;
}
.day:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.day.other-month {
opacity: 0.4;
background: #f9fafb;
}
.day.today {
border-color: #667eea;
background: #f0f4ff;
font-weight: 600;
}
.day.completed {
background: #d1fae5;
border-color: #10b981;
}
.day.completed .day-number {
color: #065f46;
}
.day.partial {
background: #fef3c7;
border-color: #f59e0b;
}
.day.partial .day-number {
color: #92400e;
}
.day-number {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.day-mark {
font-size: 20px;
font-weight: bold;
line-height: 1;
}
.day.completed .day-mark {
color: #10b981;
}
.day.partial .day-mark {
color: #f59e0b;
}
.instructions {
text-align: center;
padding: 15px;
background: #f0f4ff;
border-radius: 10px;
color: #667eea;
font-size: 14px;
}
.instructions p {
margin: 5px 0;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
header h1 {
font-size: 2em;
}
.month-display {
font-size: 1.2em;
min-width: 150px;
}
.calendar {
gap: 4px;
}
.day {
padding: 4px;
}
.day-number {
font-size: 14px;
}
.day-mark {
font-size: 16px;
}
.legend {
gap: 15px;
}
.server-input-group {
flex-direction: column;
}
#serverInput, .server-select {
width: 100%;
}
.btn-add, .btn-remove {
width: 100%;
}
.status-message-toast {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
min-width: auto;
}
}

34
server.js Normal file
View File

@@ -0,0 +1,34 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const config = require('./server/config');
require('./server/database'); // 初始化数据库
const { authenticateToken } = require('./server/middleware/auth');
const authRouter = require('./server/routes/auth');
const calendarRouter = require('./server/routes/calendar');
const { registerShutdown } = require('./server/utils/shutdown');
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api/auth', authRouter);
app.use('/api/calendar', authenticateToken, calendarRouter);
// 全局错误处理
app.use((err, req, res, next) => {
console.error('未处理的错误:', err);
res.status(500).json({ success: false, error: '服务器内部错误' });
});
const server = app.listen(config.PORT, () => {
console.log(`服务器运行在 http://localhost:${config.PORT}`);
console.log(`数据库文件: ${config.DB_PATH}`);
});
registerShutdown(server);
module.exports = app;

11
server/config.js Normal file
View File

@@ -0,0 +1,11 @@
const path = require('path');
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const DB_PATH = path.join(__dirname, '..', 'data.db');
module.exports = {
PORT,
JWT_SECRET,
DB_PATH
};

54
server/database.js Normal file
View File

@@ -0,0 +1,54 @@
const sqlite3 = require('sqlite3').verbose();
const { DB_PATH } = require('./config');
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('数据库连接失败:', err.message);
process.exit(1);
}
console.log('已连接到 SQLite 数据库');
});
function initializeDatabase() {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS calendar_marks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
date TEXT NOT NULL,
status TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_user_date ON calendar_marks(user_id, date)`);
});
}
initializeDatabase();
function closeDatabase() {
return new Promise((resolve) => {
db.close((err) => {
if (err) {
console.error('关闭数据库失败:', err.message);
} else {
console.log('数据库连接已关闭');
}
resolve();
});
});
}
module.exports = {
db,
closeDatabase
};

23
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,23 @@
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config');
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ success: false, error: '未提供认证令牌' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ success: false, error: '无效的认证令牌' });
}
req.user = user;
next();
});
}
module.exports = {
authenticateToken
};

86
server/routes/auth.js Normal file
View File

@@ -0,0 +1,86 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { db } = require('../database');
const { JWT_SECRET } = require('../config');
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
router.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, error: '用户名和密码不能为空' });
}
if (username.length < 3) {
return res.status(400).json({ success: false, error: '用户名至少需要3个字符' });
}
if (password.length < 6) {
return res.status(400).json({ success: false, error: '密码至少需要6个字符' });
}
const passwordHash = await bcrypt.hash(password, 10);
db.run(
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
[username, passwordHash],
function (err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(400).json({ success: false, error: '用户名已存在' });
}
return res.status(500).json({ success: false, error: '注册失败' });
}
const token = jwt.sign({ userId: this.lastID, username }, JWT_SECRET, { expiresIn: '7d' });
res.json({ success: true, token, userId: this.lastID, username });
}
);
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/login', (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, error: '用户名和密码不能为空' });
}
db.get(
'SELECT * FROM users WHERE username = ?',
[username],
async (err, user) => {
if (err) {
return res.status(500).json({ success: false, error: '登录失败' });
}
if (!user) {
return res.status(401).json({ success: false, error: '用户名或密码错误' });
}
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({ success: false, error: '用户名或密码错误' });
}
const token = jwt.sign({ userId: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
res.json({ success: true, token, userId: user.id, username: user.username });
}
);
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/me', authenticateToken, (req, res) => {
res.json({ success: true, userId: req.user.userId, username: req.user.username });
});
module.exports = router;

92
server/routes/calendar.js Normal file
View File

@@ -0,0 +1,92 @@
const express = require('express');
const { db } = require('../database');
const router = express.Router();
router.get('/', (req, res) => {
const userId = req.user.userId;
db.all(
'SELECT date, status FROM calendar_marks WHERE user_id = ?',
[userId],
(err, rows) => {
if (err) {
return res.status(500).json({ success: false, error: '获取数据失败' });
}
const markedDates = {};
rows.forEach((row) => {
markedDates[row.date] = row.status;
});
res.json({ success: true, markedDates });
}
);
});
router.post('/', (req, res) => {
const userId = req.user.userId;
const { markedDates } = req.body || {};
if (!markedDates || typeof markedDates !== 'object') {
return res.status(400).json({ success: false, error: '无效的数据格式' });
}
const dbOperations = Object.entries(markedDates).map(([date, status]) => {
return new Promise((resolve, reject) => {
db.run(
`INSERT INTO calendar_marks (user_id, date, status, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, date) DO UPDATE SET
status = excluded.status,
updated_at = CURRENT_TIMESTAMP`,
[userId, date, status],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
});
const dates = Object.keys(markedDates);
if (dates.length > 0) {
const placeholders = dates.map(() => '?').join(',');
dbOperations.push(
new Promise((resolve, reject) => {
db.run(
`DELETE FROM calendar_marks WHERE user_id = ? AND date NOT IN (${placeholders})`,
[userId, ...dates],
(err) => {
if (err) reject(err);
else resolve();
}
);
})
);
} else {
dbOperations.push(
new Promise((resolve, reject) => {
db.run(
'DELETE FROM calendar_marks WHERE user_id = ?',
[userId],
(err) => {
if (err) reject(err);
else resolve();
}
);
})
);
}
Promise.all(dbOperations)
.then(() => {
res.json({ success: true, message: '数据保存成功' });
})
.catch((err) => {
console.error('保存数据失败:', err);
res.status(500).json({ success: false, error: '保存数据失败' });
});
});
module.exports = router;

41
server/utils/shutdown.js Normal file
View File

@@ -0,0 +1,41 @@
const { closeDatabase } = require('../database');
function registerShutdown(server) {
let isShuttingDown = false;
async function shutdown(reason) {
if (isShuttingDown) return;
isShuttingDown = true;
if (reason) {
console.log(`\n收到 ${reason},正在关闭服务器...`);
}
await new Promise((resolve) => {
server.close(() => {
console.log('HTTP 服务器已关闭');
resolve();
});
});
await closeDatabase();
process.exit(reason === 'uncaughtException' || reason === 'unhandledRejection' ? 1 : 0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('uncaughtException', (err) => {
console.error('未捕获的异常:', err);
shutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('未处理的 Promise 拒绝:', reason);
shutdown('unhandledRejection');
});
}
module.exports = {
registerShutdown
};

View File

@@ -1,211 +0,0 @@
// 日历状态管理
class CalendarApp {
constructor() {
this.currentDate = new Date();
this.markedDates = this.loadData();
this.init();
}
init() {
this.renderCalendar();
this.bindEvents();
}
// 获取月份的第一天是星期几0=周日, 1=周一...
getFirstDayOfMonth(date) {
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
return firstDay.getDay();
}
// 获取月份的天数
getDaysInMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
}
// 格式化日期为 YYYY-MM-DD
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 检查是否为今天
isToday(date) {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
}
// 获取日期标记状态
getDateStatus(date) {
const dateStr = this.formatDate(date);
return this.markedDates[dateStr] || 'none';
}
// 切换日期标记状态
toggleDateStatus(date) {
const dateStr = this.formatDate(date);
const currentStatus = this.markedDates[dateStr] || 'none';
// 状态循环: none -> completed -> partial -> none
let newStatus;
switch (currentStatus) {
case 'none':
newStatus = 'completed';
break;
case 'completed':
newStatus = 'partial';
break;
case 'partial':
newStatus = 'none';
break;
default:
newStatus = 'none';
}
if (newStatus === 'none') {
delete this.markedDates[dateStr];
} else {
this.markedDates[dateStr] = newStatus;
}
this.saveData();
this.renderCalendar();
}
// 渲染日历
renderCalendar() {
const calendar = document.getElementById('calendar');
calendar.innerHTML = '';
// 星期标题
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
weekdays.forEach(day => {
const weekdayEl = document.createElement('div');
weekdayEl.className = 'weekday';
weekdayEl.textContent = day;
calendar.appendChild(weekdayEl);
});
// 更新月份显示
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月',
'七月', '八月', '九月', '十月', '十一月', '十二月'];
const monthDisplay = document.getElementById('currentMonth');
monthDisplay.textContent = `${this.currentDate.getFullYear()}${monthNames[this.currentDate.getMonth()]}`;
// 获取月份信息
const firstDay = this.getFirstDayOfMonth(this.currentDate);
const daysInMonth = this.getDaysInMonth(this.currentDate);
const today = new Date();
// 添加上个月的日期(填充空白)
const prevMonth = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, 0);
const daysInPrevMonth = prevMonth.getDate();
for (let i = firstDay - 1; i >= 0; i--) {
const day = daysInPrevMonth - i;
const date = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, day);
this.createDayElement(calendar, date, day, true);
}
// 添加当前月的日期
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), day);
this.createDayElement(calendar, date, day, false);
}
// 添加下个月的日期(填充到完整周)
const totalCells = calendar.children.length - 7; // 减去星期标题
const remainingCells = 42 - totalCells; // 6行 x 7列 = 42
for (let day = 1; day <= remainingCells; day++) {
const date = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, day);
this.createDayElement(calendar, date, day, true);
}
}
// 创建日期元素
createDayElement(container, date, dayNumber, isOtherMonth) {
const dayEl = document.createElement('div');
dayEl.className = 'day';
if (isOtherMonth) {
dayEl.classList.add('other-month');
}
if (this.isToday(date)) {
dayEl.classList.add('today');
}
const status = this.getDateStatus(date);
if (status !== 'none') {
dayEl.classList.add(status);
}
const dayNumberEl = document.createElement('div');
dayNumberEl.className = 'day-number';
dayNumberEl.textContent = dayNumber;
dayEl.appendChild(dayNumberEl);
if (status !== 'none') {
const markEl = document.createElement('div');
markEl.className = 'day-mark';
markEl.textContent = status === 'completed' ? '✓' : '○';
dayEl.appendChild(markEl);
}
// 添加点击事件
dayEl.addEventListener('click', () => {
if (!isOtherMonth) {
this.toggleDateStatus(date);
}
});
container.appendChild(dayEl);
}
// 绑定事件
bindEvents() {
document.getElementById('prevMonth').addEventListener('click', () => {
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.renderCalendar();
});
document.getElementById('nextMonth').addEventListener('click', () => {
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.renderCalendar();
});
document.getElementById('todayBtn').addEventListener('click', () => {
this.currentDate = new Date();
this.renderCalendar();
});
}
// 保存数据到 localStorage
saveData() {
try {
localStorage.setItem('calendarMarkedDates', JSON.stringify(this.markedDates));
} catch (e) {
console.error('保存数据失败:', e);
}
}
// 从 localStorage 加载数据
loadData() {
try {
const data = localStorage.getItem('calendarMarkedDates');
return data ? JSON.parse(data) : {};
} catch (e) {
console.error('加载数据失败:', e);
return {};
}
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
new CalendarApp();
});

View File

@@ -1,46 +0,0 @@
<!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="style.css">
</head>
<body>
<div class="container">
<header>
<h1>任务日历</h1>
<div class="controls">
<button id="prevMonth" class="btn-nav"></button>
<span id="currentMonth" class="month-display"></span>
<button id="nextMonth" class="btn-nav"></button>
<button id="todayBtn" class="btn-today">今天</button>
</div>
</header>
<div class="legend">
<div class="legend-item">
<span class="mark completed"></span>
<span>任务已完成</span>
</div>
<div class="legend-item">
<span class="mark partial"></span>
<span>部分未完成</span>
</div>
<div class="legend-item">
<span class="mark none"></span>
<span>未标记</span>
</div>
</div>
<div class="calendar" id="calendar"></div>
<div class="instructions">
<p>💡 点击日期可切换标记状态:未标记 → 已完成 → 部分完成 → 未标记</p>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -1,266 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 30px;
}
header {
text-align: center;
margin-bottom: 30px;
}
header h1 {
font-size: 2.5em;
color: #667eea;
margin-bottom: 20px;
font-weight: 600;
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.btn-nav {
background: #667eea;
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 24px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.btn-nav:hover {
background: #5568d3;
transform: scale(1.1);
}
.btn-nav:active {
transform: scale(0.95);
}
.month-display {
font-size: 1.5em;
font-weight: 600;
color: #333;
min-width: 200px;
}
.btn-today {
background: #764ba2;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-today:hover {
background: #653a8a;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4);
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin-bottom: 25px;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.mark {
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.mark.completed {
background: #10b981;
color: white;
}
.mark.partial {
background: #f59e0b;
color: white;
}
.mark.none {
background: #e5e7eb;
border: 2px solid #d1d5db;
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
margin-bottom: 20px;
}
.weekday {
text-align: center;
font-weight: 600;
color: #667eea;
padding: 12px;
font-size: 14px;
background: #f0f4ff;
border-radius: 8px;
}
.day {
aspect-ratio: 1;
border: 2px solid #e5e7eb;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
background: white;
position: relative;
padding: 8px;
}
.day:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.day.other-month {
opacity: 0.4;
background: #f9fafb;
}
.day.today {
border-color: #667eea;
background: #f0f4ff;
font-weight: 600;
}
.day.completed {
background: #d1fae5;
border-color: #10b981;
}
.day.completed .day-number {
color: #065f46;
}
.day.partial {
background: #fef3c7;
border-color: #f59e0b;
}
.day.partial .day-number {
color: #92400e;
}
.day-number {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.day-mark {
font-size: 20px;
font-weight: bold;
line-height: 1;
}
.day.completed .day-mark {
color: #10b981;
}
.day.partial .day-mark {
color: #f59e0b;
}
.instructions {
text-align: center;
padding: 15px;
background: #f0f4ff;
border-radius: 10px;
color: #667eea;
font-size: 14px;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
header h1 {
font-size: 2em;
}
.month-display {
font-size: 1.2em;
min-width: 150px;
}
.calendar {
gap: 4px;
}
.day {
padding: 4px;
}
.day-number {
font-size: 14px;
}
.day-mark {
font-size: 16px;
}
.legend {
gap: 15px;
}
}