cursor done.
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
.gitignore
|
||||
*.md
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
|
||||
node_modules/
|
||||
|
||||
data.db
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal 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"]
|
||||
95
README.md
95
README.md
@@ -1,44 +1,81 @@
|
||||
# 任务日历系统
|
||||
|
||||
一个简洁美观的任务日历系统,可以标记每一天的任务完成状态。
|
||||
一个可以给每天打勾/打圈的极简任务日历,支持多服务器、多终端同步,还自带 Docker 部署方案。
|
||||
|
||||
## 功能特性
|
||||
## ✨ 特色功能
|
||||
- **三态标记**:未标记 / 已完成(✓)/ 部分完成(○),一键轮换
|
||||
- **多服务器同步**:同一套前端,想连哪台后端就连哪台
|
||||
- **自动保存**:修改 0.5 秒后自动写入数据库,再也不怕刷新丢数据
|
||||
- **多端布局**:桌面、平板、手机都能舒舒服服地操作
|
||||
- **模块化前后端**:逻辑拆分清晰,方便二次开发
|
||||
|
||||
- 📅 **日历视图**:清晰的月份日历展示
|
||||
- ✅ **任务标记**:支持三种状态
|
||||
- ✓ 任务已完成(绿色)
|
||||
- ○ 任务部分未完成(橙色)
|
||||
- 未标记(灰色)
|
||||
- 💾 **数据保存**:使用浏览器 localStorage 自动保存标记数据
|
||||
- 🎨 **简洁界面**:现代化的 UI 设计,响应式布局
|
||||
- 🖱️ **便捷操作**:点击日期即可切换标记状态
|
||||
## 🚀 快速开始
|
||||
|
||||
## 使用方法
|
||||
### 方式一:Docker
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
然后访问 `http://localhost:3000`
|
||||
|
||||
1. 直接在浏览器中打开 `index.html` 文件
|
||||
2. 点击日历上的日期来切换标记状态:
|
||||
- 第一次点击:标记为"已完成"(✓)
|
||||
- 第二次点击:标记为"部分完成"(○)
|
||||
- 第三次点击:清除标记
|
||||
3. 使用左右箭头按钮切换月份
|
||||
4. 点击"今天"按钮快速回到当前月份
|
||||
### 方式二:本地运行
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
同样访问 `http://localhost:3000`
|
||||
|
||||
## 文件结构
|
||||
> 如需自定义端口,运行前设定 `PORT=xxxx` 即可。
|
||||
|
||||
## 🧭 使用指南
|
||||
1. **添加服务器**:页面底部输入想连接的后端地址,点击「添加」,并从下拉框选择它
|
||||
2. **注册 / 登录**:按提示填写信息,完成后即可自动加载个人日历
|
||||
3. **打勾打圈**:点击某天即可循环切换状态;右上角的 Toast 会给出操作反馈
|
||||
4. **自动保存**:系统会在短暂延迟后后台保存,提示「数据已保存」即完成
|
||||
5. **随时切换**:切换到其他服务器/账号时,会自动同步对应数据
|
||||
|
||||
## 🏗 项目结构
|
||||
```
|
||||
timeline/
|
||||
├── index.html # 主页面
|
||||
├── style.css # 样式文件
|
||||
├── app.js # 应用逻辑
|
||||
└── README.md # 说明文档
|
||||
├── server.js # 后端入口
|
||||
├── server/
|
||||
│ ├── config.js # 基础配置
|
||||
│ ├── 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
14
docker-compose.yml
Normal 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
2629
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
101
public/index.html
Normal 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">×</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
66
public/js/api.js
Normal 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
343
public/js/auth.js
Normal 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
244
public/js/calendar.js
Normal 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
24
public/js/main.js
Normal 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
166
public/js/serverManager.js
Normal 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
11
public/js/state.js
Normal 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
53
public/js/storage.js
Normal 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
22
public/js/toast.js
Normal 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
617
public/style.css
Normal 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
34
server.js
Normal 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
11
server/config.js
Normal 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
54
server/database.js
Normal 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
23
server/middleware/auth.js
Normal 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
86
server/routes/auth.js
Normal 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
92
server/routes/calendar.js
Normal 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
41
server/utils/shutdown.js
Normal 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
|
||||
};
|
||||
211
www/app.js
211
www/app.js
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
266
www/style.css
266
www/style.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user