cursor done.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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