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. 点击日历上的日期来切换标记状态:
|
```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
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