Compare commits
8 Commits
c8b789afe3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1328b490c9 | |||
| 68d13112ec | |||
| 96042461f9 | |||
| 7bc3e10b52 | |||
| 17bbbe7fbc | |||
| 3c271a2a72 | |||
| cf74607dbf | |||
| f1a88c9890 |
@@ -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
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
|
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"]
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<button id="removeServerBtn" class="btn-remove">删除</button>
|
<button id="removeServerBtn" class="btn-remove">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 用户信息移到最后 -->
|
<!-- 用户信息移到最后 -->
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span id="userDisplay">未登录</span>
|
<span id="userDisplay">未登录</span>
|
||||||
|
|||||||
@@ -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 }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
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 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,
|
||||||
|
|||||||
@@ -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 数据库');
|
||||||
|
|||||||
@@ -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 (err) {
|
|
||||||
return res.status(500).json({ success: false, error: '获取数据失败' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const markedDates = {};
|
// If year and month are provided, filter by that month plus adjacent months
|
||||||
rows.forEach((row) => {
|
if (year && month) {
|
||||||
markedDates[row.date] = row.status;
|
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) => {
|
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 = () => {
|
||||||
db.run(
|
return new Date().toISOString();
|
||||||
`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);
|
const dbOperations = [];
|
||||||
if (dates.length > 0) {
|
|
||||||
const placeholders = dates.map(() => '?').join(',');
|
// Update or insert marked dates
|
||||||
|
Object.entries(markedDates).forEach(([date, status]) => {
|
||||||
dbOperations.push(
|
dbOperations.push(
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
db.run(
|
// First check if this date exists and if status has changed
|
||||||
`DELETE FROM calendar_marks WHERE user_id = ? AND date NOT IN (${placeholders})`,
|
db.get(
|
||||||
[userId, ...dates],
|
'SELECT status FROM calendar_marks WHERE user_id = ? AND date = ?',
|
||||||
(err) => {
|
[userId, date],
|
||||||
if (err) reject(err);
|
(err, row) => {
|
||||||
else resolve();
|
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(
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user