Compare commits
8 Commits
c8b789afe3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1328b490c9 | |||
| 68d13112ec | |||
| 96042461f9 | |||
| 7bc3e10b52 | |||
| 17bbbe7fbc | |||
| 3c271a2a72 | |||
| cf74607dbf | |||
| f1a88c9890 |
@@ -1,7 +1,13 @@
|
||||
.DS_Store
|
||||
|
||||
node_modules/
|
||||
.git/
|
||||
node_modules
|
||||
.git
|
||||
.github
|
||||
|
||||
Dockerfile
|
||||
.gitignore
|
||||
.dockerignore
|
||||
|
||||
*.md
|
||||
|
||||
data.db
|
||||
|
||||
52
.github/workflows/docker-buildx.yml
vendored
Normal file
52
.github/workflows/docker-buildx.yml
vendored
Normal 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 }}
|
||||
24
Dockerfile
24
Dockerfile
@@ -1,12 +1,30 @@
|
||||
FROM node:25-alpine
|
||||
FROM node:lts-alpine
|
||||
|
||||
# Use a non-root working directory
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
# Copy package files first to leverage Docker layer caching
|
||||
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 . .
|
||||
|
||||
# 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
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
34
README.md
34
README.md
@@ -26,6 +26,37 @@ npm start
|
||||
|
||||
> 如需自定义端口,运行前设定 `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. **添加服务器**:页面底部输入想连接的后端地址,点击「添加」,并从下拉框选择它
|
||||
2. **注册 / 登录**:按提示填写信息,完成后即可自动加载个人日历
|
||||
@@ -67,11 +98,12 @@ timeline/
|
||||
## ⚙️ 配置项
|
||||
- `PORT`:后端监听端口,默认 `3000`
|
||||
- `JWT_SECRET`:JWT 密钥,默认简单字符串,生产环境务必更换
|
||||
- `DB_PATH`:SQLite 数据库文件路径,默认 `/app/data/data.db`
|
||||
|
||||
## 💡 小贴士
|
||||
- 服务器列表保存在浏览器 LocalStorage,可随时增删
|
||||
- 支持多个浏览器/设备同时登录同一账号,数据实时同步
|
||||
- 若要重置数据,直接删除根目录下的 `data.db` 即可(注意备份)
|
||||
- 若要重置数据,直接删除根目录下的 `data` 目录即可(注意备份)
|
||||
|
||||
## 🧱 技术栈速览
|
||||
- **后端**:Node.js、Express 5、SQLite、JWT、bcryptjs
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
services:
|
||||
Timeline:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: registry.cn-beijing.aliyuncs.com/licsber/timeline:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET:-change-this-secret-key-in-production}
|
||||
- DB_PATH=/app/data/data.db
|
||||
volumes:
|
||||
- ./data.db:/app/data.db
|
||||
- ./public:/app/public
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<!-- 登录/注册模态框 -->
|
||||
<div id="authModal" class="modal">
|
||||
<div id="authModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="authTitle">登录</h2>
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 主应用容器 -->
|
||||
<div class="container" id="mainContainer" style="display: none;">
|
||||
<div class="container" id="mainContainer">
|
||||
<!-- 日历放在顶部 -->
|
||||
<div class="calendar" id="calendar"></div>
|
||||
|
||||
|
||||
@@ -53,14 +53,22 @@ 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 fetchCalendar(server, token, year, month) {
|
||||
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', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { markedDates }
|
||||
body: { markedDates, deletedDates }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,16 +62,19 @@ export default class AuthManager {
|
||||
|
||||
if (this.closeModalBtn) {
|
||||
this.closeModalBtn.addEventListener('click', () => {
|
||||
if (!state.token) {
|
||||
showToast('请先登录后再关闭', 'error');
|
||||
return;
|
||||
}
|
||||
// Allow closing modal even without login
|
||||
this.hideAuthModal();
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -229,9 +232,7 @@ export default class AuthManager {
|
||||
if (this.authModal) {
|
||||
this.authModal.style.display = 'flex';
|
||||
}
|
||||
if (this.mainContainer) {
|
||||
this.mainContainer.style.display = 'none';
|
||||
}
|
||||
// Don't hide main container anymore
|
||||
|
||||
const serverValue = prefillServer || state.currentServer || window.location.origin;
|
||||
if (this.authServer) {
|
||||
@@ -266,6 +267,10 @@ export default class AuthManager {
|
||||
if (this.userDisplay) {
|
||||
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) {
|
||||
@@ -275,9 +280,8 @@ export default class AuthManager {
|
||||
state.token = '';
|
||||
state.user = null;
|
||||
this.calendarView.clearData();
|
||||
this.hideMainContainer();
|
||||
this.showAuthModal();
|
||||
this.updateUserDisplay();
|
||||
// Keep main container visible
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -299,6 +303,8 @@ export default class AuthManager {
|
||||
state.user = null;
|
||||
this.calendarView.clearData();
|
||||
this.updateUserDisplay();
|
||||
// Show main container and auth modal together
|
||||
this.showMainContainer();
|
||||
this.showAuthModal(server);
|
||||
}
|
||||
}
|
||||
@@ -312,7 +318,7 @@ export default class AuthManager {
|
||||
this.calendarView.clearData();
|
||||
this.updateUserDisplay();
|
||||
showToast('已退出登录', 'success');
|
||||
this.hideMainContainer();
|
||||
// Keep main container visible, just show auth modal
|
||||
this.showAuthModal(state.currentServer || window.location.origin);
|
||||
}
|
||||
|
||||
@@ -329,15 +335,18 @@ export default class AuthManager {
|
||||
const storedToken = storedServer ? getToken(storedServer) : '';
|
||||
const storedUser = storedServer ? getUser(storedServer) : null;
|
||||
|
||||
// Always show main container first
|
||||
this.showMainContainer();
|
||||
if (storedServer && storedToken && storedUser) {
|
||||
state.token = storedToken;
|
||||
state.user = storedUser;
|
||||
this.showMainContainer();
|
||||
this.updateUserDisplay();
|
||||
// Try to load data, will show login if it fails
|
||||
this.calendarView.loadData();
|
||||
} else {
|
||||
this.hideMainContainer();
|
||||
this.showAuthModal(storedServer || window.location.origin);
|
||||
// Render empty calendar
|
||||
this.updateUserDisplay();
|
||||
this.calendarView.renderCalendar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export default class CalendarView {
|
||||
this.monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
|
||||
this.onUnauthorized = onUnauthorized;
|
||||
this.saveDelay = 500;
|
||||
this.pendingTooltipDate = null; // Track date that needs tooltip after render
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -25,6 +26,8 @@ export default class CalendarView {
|
||||
this.prevBtn.addEventListener('click', () => {
|
||||
state.currentDate.setMonth(state.currentDate.getMonth() - 1);
|
||||
this.renderCalendar();
|
||||
// Load data for the new month
|
||||
this.loadData();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +35,8 @@ export default class CalendarView {
|
||||
this.nextBtn.addEventListener('click', () => {
|
||||
state.currentDate.setMonth(state.currentDate.getMonth() + 1);
|
||||
this.renderCalendar();
|
||||
// Load data for the new month
|
||||
this.loadData();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,19 +44,27 @@ export default class CalendarView {
|
||||
this.todayBtn.addEventListener('click', () => {
|
||||
state.currentDate = new Date();
|
||||
this.renderCalendar();
|
||||
// Load data for the current month
|
||||
this.loadData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
if (!state.currentServer || !state.token) {
|
||||
// No server or token, just render calendar without loading
|
||||
this.renderCalendar();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showToast('正在加载...', 'info');
|
||||
const result = await fetchCalendar(state.currentServer, state.token);
|
||||
state.markedDates = result.markedDates || {};
|
||||
// Pass current year and month to only fetch data for the displayed month
|
||||
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();
|
||||
showToast('数据加载成功', 'success');
|
||||
} catch (error) {
|
||||
@@ -63,11 +76,14 @@ export default class CalendarView {
|
||||
return;
|
||||
}
|
||||
showToast(error.message || '加载失败', 'error');
|
||||
// Still render the calendar even if loading fails
|
||||
this.renderCalendar();
|
||||
}
|
||||
}
|
||||
|
||||
clearData() {
|
||||
state.markedDates = {};
|
||||
state.deletedDates = [];
|
||||
this.renderCalendar();
|
||||
}
|
||||
|
||||
@@ -149,7 +165,34 @@ export default class CalendarView {
|
||||
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) {
|
||||
this.toggleDateStatus(date);
|
||||
}
|
||||
@@ -158,6 +201,106 @@ export default class CalendarView {
|
||||
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) {
|
||||
if (!state.currentServer || !state.token) {
|
||||
showToast('请先选择服务器并登录', 'error');
|
||||
@@ -165,7 +308,8 @@ export default class CalendarView {
|
||||
}
|
||||
|
||||
const dateKey = this.formatDate(date);
|
||||
const currentStatus = state.markedDates[dateKey] || 'none';
|
||||
const dateData = state.markedDates[dateKey];
|
||||
const currentStatus = (dateData && dateData.status) || 'none';
|
||||
let nextStatus;
|
||||
|
||||
switch (currentStatus) {
|
||||
@@ -183,8 +327,23 @@ export default class CalendarView {
|
||||
|
||||
if (nextStatus === 'none') {
|
||||
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 {
|
||||
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();
|
||||
@@ -206,8 +365,25 @@ export default class CalendarView {
|
||||
async persistMarkedDates() {
|
||||
state.saveTimeoutId = null;
|
||||
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');
|
||||
// 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) {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
showToast('认证已过期,请重新登录', 'error');
|
||||
@@ -230,7 +406,20 @@ export default class CalendarView {
|
||||
|
||||
getDateStatus(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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const state = {
|
||||
currentDate: new Date(),
|
||||
markedDates: {},
|
||||
markedDates: {}, // Now stores objects with { status, updatedAt }
|
||||
deletedDates: [], // Track dates that have been explicitly deleted
|
||||
currentServer: '',
|
||||
token: '',
|
||||
user: null,
|
||||
|
||||
@@ -575,6 +575,37 @@ header h1 {
|
||||
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) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
|
||||
15
scripts/entrypoint.sh
Normal file
15
scripts/entrypoint.sh
Normal 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 "$@"
|
||||
@@ -2,7 +2,7 @@ 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');
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'data.db');
|
||||
|
||||
module.exports = {
|
||||
PORT,
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
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) => {
|
||||
if (err) {
|
||||
console.error('数据库连接失败:', err.message);
|
||||
console.error('数据库路径:', DB_PATH);
|
||||
console.error('当前工作目录:', process.cwd());
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('已连接到 SQLite 数据库');
|
||||
|
||||
@@ -5,71 +5,129 @@ const router = express.Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const userId = req.user.userId;
|
||||
const { year, month } = req.query;
|
||||
|
||||
db.all(
|
||||
'SELECT date, status FROM calendar_marks WHERE user_id = ?',
|
||||
[userId],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ success: false, error: '获取数据失败' });
|
||||
}
|
||||
// Build query based on whether year and month are provided
|
||||
let query = 'SELECT date, status, updated_at FROM calendar_marks WHERE user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
const markedDates = {};
|
||||
rows.forEach((row) => {
|
||||
markedDates[row.date] = row.status;
|
||||
});
|
||||
// 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);
|
||||
|
||||
res.json({ success: true, markedDates });
|
||||
// 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) {
|
||||
return res.status(500).json({ success: false, error: '获取数据失败' });
|
||||
}
|
||||
|
||||
const markedDates = {};
|
||||
rows.forEach((row) => {
|
||||
markedDates[row.date] = {
|
||||
status: row.status,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ success: true, markedDates });
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const userId = req.user.userId;
|
||||
const { markedDates } = req.body || {};
|
||||
const { markedDates, deletedDates } = 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();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
// Helper function to get current timestamp in ISO format (UTC)
|
||||
const getLocalTimestamp = () => {
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
const dates = Object.keys(markedDates);
|
||||
if (dates.length > 0) {
|
||||
const placeholders = dates.map(() => '?').join(',');
|
||||
const dbOperations = [];
|
||||
|
||||
// Update or insert marked dates
|
||||
Object.entries(markedDates).forEach(([date, status]) => {
|
||||
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();
|
||||
// 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(
|
||||
`INSERT INTO calendar_marks (user_id, date, status, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, date) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at`,
|
||||
[userId, date, status, currentTimestamp],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Status unchanged - keep existing timestamp
|
||||
db.run(
|
||||
`INSERT INTO calendar_marks (user_id, date, status, updated_at)
|
||||
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) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
});
|
||||
|
||||
// Delete explicitly removed dates
|
||||
if (deletedDates && Array.isArray(deletedDates) && deletedDates.length > 0) {
|
||||
const placeholders = deletedDates.map(() => '?').join(',');
|
||||
dbOperations.push(
|
||||
new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'DELETE FROM calendar_marks WHERE user_id = ?',
|
||||
[userId],
|
||||
`DELETE FROM calendar_marks WHERE user_id = ? AND date IN (${placeholders})`,
|
||||
[userId, ...deletedDates],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
|
||||
Reference in New Issue
Block a user