Compare commits

...

8 Commits

Author SHA1 Message Date
1328b490c9 show main page by default.
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
2025-11-11 17:11:40 +08:00
68d13112ec optimize: api resp size.
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
2025-11-11 17:06:17 +08:00
96042461f9 feat: update time tooltip.
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
2025-11-11 16:42:49 +08:00
7bc3e10b52 copilot: docker optimize.
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
2025-11-11 16:00:24 +08:00
17bbbe7fbc fix: docker permission again two.
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
2025-11-11 15:28:38 +08:00
3c271a2a72 fix: docker permission again.
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
2025-11-11 15:24:40 +08:00
cf74607dbf fix: docker permission.
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
2025-11-11 15:18:01 +08:00
f1a88c9890 feat: docker buildx.
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
2025-11-11 15:09:02 +08:00
15 changed files with 527 additions and 81 deletions

View File

@@ -1,7 +1,13 @@
.DS_Store .DS_Store
node_modules/ node_modules
.git/ .git
.github
Dockerfile
.gitignore .gitignore
.dockerignore
*.md *.md
data.db

52
.github/workflows/docker-buildx.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
jobs:
buildx:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKERHUB_USERNAME || 'username' }}/timeline-calendar
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,12 +1,30 @@
FROM node:25-alpine FROM node:lts-alpine
# Use a non-root working directory
WORKDIR /app WORKDIR /app
COPY package*.json ./ # Copy package files first to leverage Docker layer caching
RUN npm install --production COPY package.json package-lock.json ./
# Install production dependencies deterministically
# --omit=dev keeps devDependencies out of the final install
RUN npm ci --omit=dev --no-audit --no-fund
# Copy the rest of the application
COPY . . COPY . .
# Install tiny helper to drop privileges at container start
# and keep image small (no cache)
RUN apk add --no-cache su-exec
# Runtime entrypoint ensures the data dir ownership and drops privileges
COPY scripts/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV NODE_ENV=production
ENV DB_PATH=/app/data/data.db
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -26,6 +26,37 @@ npm start
> 如需自定义端口,运行前设定 `PORT=xxxx` 即可。 > 如需自定义端口,运行前设定 `PORT=xxxx` 即可。
## 🚀 Docker Buildx 支持
本项目现在支持使用 Docker Buildx 构建多平台镜像,并可推送至 Docker Hub。
### 使用 GitHub Actions 自动构建和推送
1. 在 GitHub 仓库中设置以下 secrets:
- `DOCKERHUB_USERNAME` - 你的 Docker Hub 用户名
- `DOCKERHUB_TOKEN` - 你的 Docker Hub 访问令牌
2. 推送代码到 main 分支或创建版本标签GitHub Actions 将自动构建并推送镜像。
### 手动构建和推送
```bash
# 设置 buildx
docker buildx create --name mybuilder --use
docker buildx inspect --bootstrap
# 构建并推送多平台镜像
docker buildx build --platform linux/amd64,linux/arm64 -t registry.cn-beijing.aliyuncs.com/licsber/timeline:latest --push .
```
## 🗄️ 数据库持久化
Docker 部署时,用户数据存储在 SQLite 数据库中,默认会持久化到项目根目录的 `data` 文件夹中。
- 数据库文件路径:`./data/data.db`
要备份数据,只需复制整个 `data` 目录。要恢复数据,将备份的 `data` 目录复制回项目根目录即可。
## 🧭 使用指南 ## 🧭 使用指南
1. **添加服务器**:页面底部输入想连接的后端地址,点击「添加」,并从下拉框选择它 1. **添加服务器**:页面底部输入想连接的后端地址,点击「添加」,并从下拉框选择它
2. **注册 / 登录**:按提示填写信息,完成后即可自动加载个人日历 2. **注册 / 登录**:按提示填写信息,完成后即可自动加载个人日历
@@ -67,11 +98,12 @@ timeline/
## ⚙️ 配置项 ## ⚙️ 配置项
- `PORT`:后端监听端口,默认 `3000` - `PORT`:后端监听端口,默认 `3000`
- `JWT_SECRET`JWT 密钥,默认简单字符串,生产环境务必更换 - `JWT_SECRET`JWT 密钥,默认简单字符串,生产环境务必更换
- `DB_PATH`SQLite 数据库文件路径,默认 `/app/data/data.db`
## 💡 小贴士 ## 💡 小贴士
- 服务器列表保存在浏览器 LocalStorage可随时增删 - 服务器列表保存在浏览器 LocalStorage可随时增删
- 支持多个浏览器/设备同时登录同一账号,数据实时同步 - 支持多个浏览器/设备同时登录同一账号,数据实时同步
- 若要重置数据,直接删除根目录下的 `data.db` 即可(注意备份) - 若要重置数据,直接删除根目录下的 `data` 目录即可(注意备份)
## 🧱 技术栈速览 ## 🧱 技术栈速览
- **后端**Node.js、Express 5、SQLite、JWT、bcryptjs - **后端**Node.js、Express 5、SQLite、JWT、bcryptjs

View File

@@ -1,12 +1,16 @@
services: services:
Timeline: Timeline:
build: . build:
context: .
dockerfile: Dockerfile
image: registry.cn-beijing.aliyuncs.com/licsber/timeline:latest
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=production
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET:-change-this-secret-key-in-production} - JWT_SECRET=${JWT_SECRET:-change-this-secret-key-in-production}
- DB_PATH=/app/data/data.db
volumes: volumes:
- ./data.db:/app/data.db - ./data:/app/data
- ./public:/app/public
restart: unless-stopped restart: unless-stopped

View File

@@ -8,7 +8,7 @@
</head> </head>
<body> <body>
<!-- 登录/注册模态框 --> <!-- 登录/注册模态框 -->
<div id="authModal" class="modal"> <div id="authModal" class="modal" style="display: none;">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="authTitle">登录</h2> <h2 id="authTitle">登录</h2>
@@ -43,7 +43,7 @@
</div> </div>
<!-- 主应用容器 --> <!-- 主应用容器 -->
<div class="container" id="mainContainer" style="display: none;"> <div class="container" id="mainContainer">
<!-- 日历放在顶部 --> <!-- 日历放在顶部 -->
<div class="calendar" id="calendar"></div> <div class="calendar" id="calendar"></div>

View File

@@ -53,14 +53,22 @@ export async function fetchCurrentUser(server, token) {
return request(server, '/api/auth/me', { token }); return request(server, '/api/auth/me', { token });
} }
export async function fetchCalendar(server, token) { export async function fetchCalendar(server, token, year, month) {
return request(server, '/api/calendar', { token }); const params = new URLSearchParams();
if (year !== undefined && month !== undefined) {
params.append('year', year);
params.append('month', month);
}
const queryString = params.toString();
const path = queryString ? `/api/calendar?${queryString}` : '/api/calendar';
return request(server, path, { token });
} }
export async function saveCalendar(server, token, markedDates) {
export async function saveCalendar(server, token, markedDates, deletedDates) {
return request(server, '/api/calendar', { return request(server, '/api/calendar', {
method: 'POST', method: 'POST',
token, token,
body: { markedDates } body: { markedDates, deletedDates }
}); });
} }

View File

@@ -62,16 +62,19 @@ export default class AuthManager {
if (this.closeModalBtn) { if (this.closeModalBtn) {
this.closeModalBtn.addEventListener('click', () => { this.closeModalBtn.addEventListener('click', () => {
if (!state.token) { // Allow closing modal even without login
showToast('请先登录后再关闭', 'error');
return;
}
this.hideAuthModal(); this.hideAuthModal();
}); });
} }
if (this.logoutBtn) { if (this.logoutBtn) {
this.logoutBtn.addEventListener('click', () => this.logout()); this.logoutBtn.addEventListener('click', () => {
if (state.user) {
this.logout();
} else {
this.showAuthModal(state.currentServer || window.location.origin);
}
});
} }
if (this.authPassword) { if (this.authPassword) {
@@ -229,9 +232,7 @@ export default class AuthManager {
if (this.authModal) { if (this.authModal) {
this.authModal.style.display = 'flex'; this.authModal.style.display = 'flex';
} }
if (this.mainContainer) { // Don't hide main container anymore
this.mainContainer.style.display = 'none';
}
const serverValue = prefillServer || state.currentServer || window.location.origin; const serverValue = prefillServer || state.currentServer || window.location.origin;
if (this.authServer) { if (this.authServer) {
@@ -266,6 +267,10 @@ export default class AuthManager {
if (this.userDisplay) { if (this.userDisplay) {
this.userDisplay.textContent = state.user ? `用户: ${state.user.username}` : '未登录'; this.userDisplay.textContent = state.user ? `用户: ${state.user.username}` : '未登录';
} }
// Update logout button text based on login state
if (this.logoutBtn) {
this.logoutBtn.textContent = state.user ? '退出' : '登录';
}
} }
async handleServerSelection(server) { async handleServerSelection(server) {
@@ -275,9 +280,8 @@ export default class AuthManager {
state.token = ''; state.token = '';
state.user = null; state.user = null;
this.calendarView.clearData(); this.calendarView.clearData();
this.hideMainContainer();
this.showAuthModal();
this.updateUserDisplay(); this.updateUserDisplay();
// Keep main container visible
return; return;
} }
@@ -299,6 +303,8 @@ export default class AuthManager {
state.user = null; state.user = null;
this.calendarView.clearData(); this.calendarView.clearData();
this.updateUserDisplay(); this.updateUserDisplay();
// Show main container and auth modal together
this.showMainContainer();
this.showAuthModal(server); this.showAuthModal(server);
} }
} }
@@ -312,7 +318,7 @@ export default class AuthManager {
this.calendarView.clearData(); this.calendarView.clearData();
this.updateUserDisplay(); this.updateUserDisplay();
showToast('已退出登录', 'success'); showToast('已退出登录', 'success');
this.hideMainContainer(); // Keep main container visible, just show auth modal
this.showAuthModal(state.currentServer || window.location.origin); this.showAuthModal(state.currentServer || window.location.origin);
} }
@@ -329,15 +335,18 @@ export default class AuthManager {
const storedToken = storedServer ? getToken(storedServer) : ''; const storedToken = storedServer ? getToken(storedServer) : '';
const storedUser = storedServer ? getUser(storedServer) : null; const storedUser = storedServer ? getUser(storedServer) : null;
// Always show main container first
this.showMainContainer();
if (storedServer && storedToken && storedUser) { if (storedServer && storedToken && storedUser) {
state.token = storedToken; state.token = storedToken;
state.user = storedUser; state.user = storedUser;
this.showMainContainer();
this.updateUserDisplay(); this.updateUserDisplay();
// Try to load data, will show login if it fails
this.calendarView.loadData(); this.calendarView.loadData();
} else { } else {
this.hideMainContainer(); // Render empty calendar
this.showAuthModal(storedServer || window.location.origin); this.updateUserDisplay();
this.calendarView.renderCalendar();
} }
} }
} }

View File

@@ -13,6 +13,7 @@ export default class CalendarView {
this.monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; this.monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
this.onUnauthorized = onUnauthorized; this.onUnauthorized = onUnauthorized;
this.saveDelay = 500; this.saveDelay = 500;
this.pendingTooltipDate = null; // Track date that needs tooltip after render
} }
init() { init() {
@@ -25,6 +26,8 @@ export default class CalendarView {
this.prevBtn.addEventListener('click', () => { this.prevBtn.addEventListener('click', () => {
state.currentDate.setMonth(state.currentDate.getMonth() - 1); state.currentDate.setMonth(state.currentDate.getMonth() - 1);
this.renderCalendar(); this.renderCalendar();
// Load data for the new month
this.loadData();
}); });
} }
@@ -32,6 +35,8 @@ export default class CalendarView {
this.nextBtn.addEventListener('click', () => { this.nextBtn.addEventListener('click', () => {
state.currentDate.setMonth(state.currentDate.getMonth() + 1); state.currentDate.setMonth(state.currentDate.getMonth() + 1);
this.renderCalendar(); this.renderCalendar();
// Load data for the new month
this.loadData();
}); });
} }
@@ -39,19 +44,27 @@ export default class CalendarView {
this.todayBtn.addEventListener('click', () => { this.todayBtn.addEventListener('click', () => {
state.currentDate = new Date(); state.currentDate = new Date();
this.renderCalendar(); this.renderCalendar();
// Load data for the current month
this.loadData();
}); });
} }
} }
async loadData() { async loadData() {
if (!state.currentServer || !state.token) { if (!state.currentServer || !state.token) {
// No server or token, just render calendar without loading
this.renderCalendar();
return; return;
} }
try { try {
showToast('正在加载...', 'info'); showToast('正在加载...', 'info');
const result = await fetchCalendar(state.currentServer, state.token); // Pass current year and month to only fetch data for the displayed month
state.markedDates = result.markedDates || {}; const year = state.currentDate.getFullYear();
const month = state.currentDate.getMonth() + 1; // getMonth() returns 0-11, need 1-12
const result = await fetchCalendar(state.currentServer, state.token, year, month);
// Merge with existing data
Object.assign(state.markedDates, result.markedDates || {});
this.renderCalendar(); this.renderCalendar();
showToast('数据加载成功', 'success'); showToast('数据加载成功', 'success');
} catch (error) { } catch (error) {
@@ -63,11 +76,14 @@ export default class CalendarView {
return; return;
} }
showToast(error.message || '加载失败', 'error'); showToast(error.message || '加载失败', 'error');
// Still render the calendar even if loading fails
this.renderCalendar();
} }
} }
clearData() { clearData() {
state.markedDates = {}; state.markedDates = {};
state.deletedDates = [];
this.renderCalendar(); this.renderCalendar();
} }
@@ -149,7 +165,34 @@ export default class CalendarView {
dayEl.appendChild(markEl); dayEl.appendChild(markEl);
} }
dayEl.addEventListener('click', () => { // Add timestamp tooltip functionality
const timestamp = this.getDateTimestamp(date);
if (timestamp) {
// Store the ISO timestamp
dayEl.dataset.modificationTime = timestamp;
// Add hover events for custom tooltip
dayEl.addEventListener('mouseenter', (e) => {
const isoTime = e.currentTarget.dataset.modificationTime;
// Convert to user's local timezone
const localTime = new Date(isoTime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
this.showTooltip(e, `修改时间: ${localTime}`);
});
dayEl.addEventListener('mouseleave', () => {
this.hideTooltip();
});
}
dayEl.addEventListener('click', (e) => {
if (!isOtherMonth) { if (!isOtherMonth) {
this.toggleDateStatus(date); this.toggleDateStatus(date);
} }
@@ -158,6 +201,106 @@ export default class CalendarView {
return dayEl; return dayEl;
} }
// Tooltip methods
showTooltip(event, content) {
// Remove any existing tooltips completely
const existingTooltips = document.querySelectorAll('.tooltip');
existingTooltips.forEach(tip => {
if (tip.parentNode) {
tip.parentNode.removeChild(tip);
}
});
// Create new tooltip element with unique ID
const tooltip = document.createElement('div');
const uniqueId = 'tooltip-' + Date.now();
tooltip.id = uniqueId;
tooltip.className = 'tooltip';
tooltip.textContent = content;
document.body.appendChild(tooltip);
// Position tooltip relative to the target element
const rect = event.currentTarget.getBoundingClientRect();
tooltip.style.left = (rect.left + rect.width / 2) + 'px';
tooltip.style.top = (rect.top - 10) + 'px';
tooltip.style.transform = 'translate(-50%, -100%)';
// Show tooltip with a slight delay to ensure position is set
requestAnimationFrame(() => {
tooltip.classList.add('show');
});
}
hideTooltip() {
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(tooltip => {
tooltip.classList.remove('show');
// Remove tooltip element after transition
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 200);
});
}
refreshTooltip(event, element) {
// Check if element has timestamp data
const isoTime = element.dataset.modificationTime;
if (isoTime) {
// Convert to user's local timezone
const localTime = new Date(isoTime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
this.showTooltip(event, `修改时间: ${localTime}`);
} else {
// No timestamp, hide tooltip
this.hideTooltip();
}
}
showTooltipForDate(dateKey) {
// Find the day element for this date
const dayElements = this.calendarEl.querySelectorAll('.day:not(.other-month)');
for (const dayEl of dayElements) {
if (dayEl.dataset.modificationTime) {
// Extract date from the element's position
const dayNumber = dayEl.querySelector('.day-number')?.textContent;
if (dayNumber) {
const year = state.currentDate.getFullYear();
const month = state.currentDate.getMonth();
const testDate = new Date(year, month, parseInt(dayNumber));
if (this.formatDate(testDate) === dateKey) {
// Found the element, show tooltip
const isoTime = dayEl.dataset.modificationTime;
const localTime = new Date(isoTime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
// Create a synthetic event for positioning
const rect = dayEl.getBoundingClientRect();
const syntheticEvent = {
currentTarget: dayEl
};
this.showTooltip(syntheticEvent, `修改时间: ${localTime}`);
break;
}
}
}
}
}
toggleDateStatus(date) { toggleDateStatus(date) {
if (!state.currentServer || !state.token) { if (!state.currentServer || !state.token) {
showToast('请先选择服务器并登录', 'error'); showToast('请先选择服务器并登录', 'error');
@@ -165,7 +308,8 @@ export default class CalendarView {
} }
const dateKey = this.formatDate(date); const dateKey = this.formatDate(date);
const currentStatus = state.markedDates[dateKey] || 'none'; const dateData = state.markedDates[dateKey];
const currentStatus = (dateData && dateData.status) || 'none';
let nextStatus; let nextStatus;
switch (currentStatus) { switch (currentStatus) {
@@ -183,8 +327,23 @@ export default class CalendarView {
if (nextStatus === 'none') { if (nextStatus === 'none') {
delete state.markedDates[dateKey]; delete state.markedDates[dateKey];
// Track this deletion
if (!state.deletedDates.includes(dateKey)) {
state.deletedDates.push(dateKey);
}
// Clear pending tooltip for removed marks
this.pendingTooltipDate = null;
this.hideTooltip();
} else { } else {
state.markedDates[dateKey] = nextStatus; // Store status, timestamp will be updated by server on save
state.markedDates[dateKey] = { status: nextStatus };
// Remove from deleted dates if it was there
const deleteIndex = state.deletedDates.indexOf(dateKey);
if (deleteIndex !== -1) {
state.deletedDates.splice(deleteIndex, 1);
}
// Store the date for tooltip after save completes
this.pendingTooltipDate = dateKey;
} }
this.renderCalendar(); this.renderCalendar();
@@ -206,8 +365,25 @@ export default class CalendarView {
async persistMarkedDates() { async persistMarkedDates() {
state.saveTimeoutId = null; state.saveTimeoutId = null;
try { try {
await saveCalendar(state.currentServer, state.token, state.markedDates); // Convert state format { date: { status, updatedAt } } to API format { date: status }
const markedDatesForAPI = {};
for (const [date, data] of Object.entries(state.markedDates)) {
if (data && typeof data === 'object' && data.status) {
markedDatesForAPI[date] = data.status;
}
}
await saveCalendar(state.currentServer, state.token, markedDatesForAPI, state.deletedDates);
// Clear the deleted dates array after successful save
state.deletedDates = [];
showToast('数据已保存', 'success'); showToast('数据已保存', 'success');
// Reload data to get updated timestamps
await this.loadData();
// Show tooltip for the pending date after data is loaded
if (this.pendingTooltipDate) {
this.showTooltipForDate(this.pendingTooltipDate);
this.pendingTooltipDate = null;
}
} catch (error) { } catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
showToast('认证已过期,请重新登录', 'error'); showToast('认证已过期,请重新登录', 'error');
@@ -230,7 +406,20 @@ export default class CalendarView {
getDateStatus(date) { getDateStatus(date) {
const dateKey = this.formatDate(date); const dateKey = this.formatDate(date);
return state.markedDates[dateKey] || 'none'; const dateData = state.markedDates[dateKey];
if (dateData && typeof dateData === 'object') {
return dateData.status || 'none';
}
return 'none';
}
getDateTimestamp(date) {
const dateKey = this.formatDate(date);
const dateData = state.markedDates[dateKey];
if (dateData && typeof dateData === 'object' && dateData.updatedAt) {
return dateData.updatedAt;
}
return null;
} }
isToday(date) { isToday(date) {

View File

@@ -1,6 +1,7 @@
const state = { const state = {
currentDate: new Date(), currentDate: new Date(),
markedDates: {}, markedDates: {}, // Now stores objects with { status, updatedAt }
deletedDates: [], // Track dates that have been explicitly deleted
currentServer: '', currentServer: '',
token: '', token: '',
user: null, user: null,

View File

@@ -575,6 +575,37 @@ header h1 {
color: white; color: white;
} }
/* Custom tooltip for modification time */
.tooltip {
position: fixed;
background: #333;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
z-index: 10001;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.tooltip.show {
opacity: 1;
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -6px;
border-width: 6px;
border-style: solid;
border-color: #333 transparent transparent transparent;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
padding: 20px; padding: 20px;

15
scripts/entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -e
# Ensure the data directory exists and is writable by the 'node' user.
# This runs as root inside the container during startup, fixes ownership for
# both named volumes and bind mounts, then drops privileges to the 'node' user.
DATA_DIR=/app/data
mkdir -p "$DATA_DIR"
chown -R node:node "$DATA_DIR" || true
chmod 755 "$DATA_DIR" || true
# Exec the requested command as the 'node' user
exec su-exec node "$@"

View File

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

View File

@@ -1,9 +1,32 @@
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require('sqlite3').verbose();
const { DB_PATH } = require('./config'); const { DB_PATH } = require('./config');
const fs = require('fs');
const path = require('path');
// Ensure the directory exists with proper permissions
const dbDir = path.dirname(DB_PATH);
if (!fs.existsSync(dbDir)) {
try {
fs.mkdirSync(dbDir, { recursive: true });
console.log('Created database directory:', dbDir);
} catch (err) {
console.error('Failed to create database directory:', err.message);
}
}
// Try to create the database file if it doesn't exist
try {
fs.closeSync(fs.openSync(DB_PATH, 'a'));
console.log('Database file is accessible:', DB_PATH);
} catch (err) {
console.error('Failed to access database file:', err.message);
}
const db = new sqlite3.Database(DB_PATH, (err) => { const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) { if (err) {
console.error('数据库连接失败:', err.message); console.error('数据库连接失败:', err.message);
console.error('数据库路径:', DB_PATH);
console.error('当前工作目录:', process.cwd());
process.exit(1); process.exit(1);
} }
console.log('已连接到 SQLite 数据库'); console.log('已连接到 SQLite 数据库');

View File

@@ -5,71 +5,129 @@ const router = express.Router();
router.get('/', (req, res) => { router.get('/', (req, res) => {
const userId = req.user.userId; const userId = req.user.userId;
const { year, month } = req.query;
db.all( // Build query based on whether year and month are provided
'SELECT date, status FROM calendar_marks WHERE user_id = ?', let query = 'SELECT date, status, updated_at FROM calendar_marks WHERE user_id = ?';
[userId], const params = [userId];
(err, rows) => {
// If year and month are provided, filter by that month plus adjacent months
if (year && month) {
const yearNum = parseInt(year, 10);
const monthNum = parseInt(month, 10);
// Validate year and month
if (isNaN(yearNum) || isNaN(monthNum) || monthNum < 1 || monthNum > 12) {
return res.status(400).json({ success: false, error: '无效的年份或月份' });
}
// Calculate the date range: previous month to next month
// This covers all dates that might be visible in the calendar view
const prevMonth = monthNum === 1 ? 12 : monthNum - 1;
const prevYear = monthNum === 1 ? yearNum - 1 : yearNum;
const nextMonth = monthNum === 12 ? 1 : monthNum + 1;
const nextYear = monthNum === 12 ? yearNum + 1 : yearNum;
const startDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-01`;
// Get the last day of next month
const lastDayOfNextMonth = new Date(nextYear, nextMonth, 0).getDate();
const endDate = `${nextYear}-${String(nextMonth).padStart(2, '0')}-${String(lastDayOfNextMonth).padStart(2, '0')}`;
query += ' AND date >= ? AND date <= ?';
params.push(startDate, endDate);
}
db.all(query, params, (err, rows) => {
if (err) { if (err) {
return res.status(500).json({ success: false, error: '获取数据失败' }); return res.status(500).json({ success: false, error: '获取数据失败' });
} }
const markedDates = {}; const markedDates = {};
rows.forEach((row) => { rows.forEach((row) => {
markedDates[row.date] = row.status; markedDates[row.date] = {
status: row.status,
updatedAt: row.updated_at
};
}); });
res.json({ success: true, markedDates }); res.json({ success: true, markedDates });
} });
);
}); });
router.post('/', (req, res) => { router.post('/', (req, res) => {
const userId = req.user.userId; const userId = req.user.userId;
const { markedDates } = req.body || {}; const { markedDates, deletedDates } = req.body || {};
if (!markedDates || typeof markedDates !== 'object') { if (!markedDates || typeof markedDates !== 'object') {
return res.status(400).json({ success: false, error: '无效的数据格式' }); return res.status(400).json({ success: false, error: '无效的数据格式' });
} }
const dbOperations = Object.entries(markedDates).map(([date, status]) => { // Helper function to get current timestamp in ISO format (UTC)
return new Promise((resolve, reject) => { const getLocalTimestamp = () => {
return new Date().toISOString();
};
const dbOperations = [];
// Update or insert marked dates
Object.entries(markedDates).forEach(([date, status]) => {
dbOperations.push(
new Promise((resolve, reject) => {
// First check if this date exists and if status has changed
db.get(
'SELECT status FROM calendar_marks WHERE user_id = ? AND date = ?',
[userId, date],
(err, row) => {
if (err) {
return reject(err);
}
// Only update timestamp if status changed or it's a new entry
const statusChanged = !row || row.status !== status;
if (statusChanged) {
// Status changed - update with new timestamp
const currentTimestamp = getLocalTimestamp();
db.run( db.run(
`INSERT INTO calendar_marks (user_id, date, status, updated_at) `INSERT INTO calendar_marks (user_id, date, status, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, date) DO UPDATE SET ON CONFLICT(user_id, date) DO UPDATE SET
status = excluded.status, status = excluded.status,
updated_at = CURRENT_TIMESTAMP`, updated_at = excluded.updated_at`,
[userId, date, status], [userId, date, status, currentTimestamp],
(err) => { (err) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
} }
); );
}); } else {
}); // Status unchanged - keep existing timestamp
const dates = Object.keys(markedDates);
if (dates.length > 0) {
const placeholders = dates.map(() => '?').join(',');
dbOperations.push(
new Promise((resolve, reject) => {
db.run( db.run(
`DELETE FROM calendar_marks WHERE user_id = ? AND date NOT IN (${placeholders})`, `INSERT INTO calendar_marks (user_id, date, status, updated_at)
[userId, ...dates], VALUES (?, ?, ?, (SELECT updated_at FROM calendar_marks WHERE user_id = ? AND date = ?))
ON CONFLICT(user_id, date) DO UPDATE SET
status = excluded.status`,
[userId, date, status, userId, date],
(err) => { (err) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
} }
); );
}
}
);
}) })
); );
} else { });
// Delete explicitly removed dates
if (deletedDates && Array.isArray(deletedDates) && deletedDates.length > 0) {
const placeholders = deletedDates.map(() => '?').join(',');
dbOperations.push( dbOperations.push(
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
db.run( db.run(
'DELETE FROM calendar_marks WHERE user_id = ?', `DELETE FROM calendar_marks WHERE user_id = ? AND date IN (${placeholders})`,
[userId], [userId, ...deletedDates],
(err) => { (err) => {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();