diff --git a/DEPLOY.md b/DEPLOY.md index 380053b..ba86079 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -10,7 +10,7 @@ | crawler| 8000 | RSS 爬虫 + GDELT,内部服务 | - 数据库:SQLite,挂载到 `app-data` volume(`/data/data.db`) -- 前端与 API 合并到同一镜像,访问 `http://主机:3001` 即可 +- 前端与 API 合并到同一镜像,构建时执行 `npm run build` 生成 dist(含修订页 `/edit`),访问 `http://主机:3001` 即可 ## 快速部署 @@ -109,3 +109,60 @@ docker compose down # 回填战损数据(从 situation_update 重新提取) curl -X POST http://localhost:8000/crawler/backfill ``` + +## 服务器直接部署(不用 Docker) + +若在服务器上直接跑 Node(不用 Docker),要能访问修订页 `/edit`,需保证: + +1. **先构建、再启动**:在项目根目录执行 `npm run build`,再启动 API(如 `npm run api` 或 `node server/index.js`)。 + 未构建时没有 `dist` 目录,启动会打日志:`dist 目录不存在,前端页面(含 /edit 修订页)不可用`。 + +2. **若前面有 Nginx**:`curl http://127.0.0.1:3001/edit` 已是 200 但浏览器访问 `/edit` 仍 404,说明 Nginx 没有把前端路由交给后端或没做 SPA fallback。二选一即可: + + **方式 A:Nginx 只反代,所有页面由 Node 提供(推荐)** + ```nginx + server { + listen 80; + server_name 你的域名; + location / { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ws { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } + ``` + + **方式 B:Nginx 提供 dist 静态,仅 /api、/ws 反代** + ```nginx + server { + listen 80; + server_name 你的域名; + root /path/to/项目根目录/dist; # 改成实际路径 + index index.html; + location / { + try_files $uri $uri/ /index.html; + } + location /api { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /ws { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } + ``` + + 修改后执行 `sudo nginx -t` 检查配置,再 `sudo systemctl reload nginx`(或 `sudo nginx -s reload`)。 diff --git a/Dockerfile b/Dockerfile index 9c4a4fd..e1e9721 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,23 @@ -# 仅后端 API 镜像(前端自行部署) +# 前端 + 后端合一镜像:构建阶段产出 dist,运行阶段提供静态与 API(含修订页 /edit) # 国内服务器拉取慢时,可加 --build-arg REGISTRY=docker.m.daocloud.io/library ARG REGISTRY= + +# ---------- 阶段 1:构建前端 ---------- +FROM ${REGISTRY}node:20-slim AS frontend-builder +WORKDIR /app +RUN npm config set registry https://registry.npmmirror.com +COPY package*.json ./ +RUN npm ci +COPY vite.config.ts index.html tsconfig.json tsconfig.app.json ./ +COPY postcss.config.js tailwind.config.js ./ +COPY src ./src +RUN npm run build + +# ---------- 阶段 2:运行(API + 静态) ---------- FROM ${REGISTRY}node:20-slim RUN npm config set registry https://registry.npmmirror.com -# Debian 12 使用 sources.list.d,改为国内源避免 build 时连接超时 RUN rm -f /etc/apt/sources.list.d/debian.sources && \ echo 'deb http://mirrors.aliyun.com/debian bookworm main' > /etc/apt/sources.list && \ echo 'deb http://mirrors.aliyun.com/debian bookworm-updates main' >> /etc/apt/sources.list && \ @@ -13,13 +25,11 @@ RUN rm -f /etc/apt/sources.list.d/debian.sources && \ apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \ rm -rf /var/lib/apt/lists/* - WORKDIR /app - COPY package*.json ./ RUN npm ci --omit=dev - COPY server ./server +COPY --from=frontend-builder /app/dist ./dist ENV NODE_ENV=production ENV API_PORT=3001 diff --git a/map.md b/map.md index a4b6c02..1749dfa 100644 --- a/map.md +++ b/map.md @@ -281,4 +281,10 @@ const IRAN_SOURCE = [51.3890, 35.6892] // Tehran 所有动画走 WebGL 图层 -禁止 DOM 动画 \ No newline at end of file +禁止 DOM 动画 + + + +git代码更新:git fetch origin && git reset --hard origin/master +前端发版:npm run build +后端发版:pm2 restart 3 \ No newline at end of file diff --git a/server/db.js b/server/db.js index 7905d98..fa6f0fd 100644 --- a/server/db.js +++ b/server/db.js @@ -236,6 +236,15 @@ function runMigrations(db) { INSERT OR IGNORE INTO share_count (id, total) VALUES (1, 0); `) } catch (_) {} + try { + exec(` + CREATE TABLE IF NOT EXISTS like_count ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total INTEGER NOT NULL DEFAULT 0 + ); + INSERT OR IGNORE INTO like_count (id, total) VALUES (1, 0); + `) + } catch (_) {} try { exec(` CREATE TABLE IF NOT EXISTS display_stats ( diff --git a/server/index.js b/server/index.js index e2f0969..b03bd1f 100644 --- a/server/index.js +++ b/server/index.js @@ -32,15 +32,18 @@ app.post('/api/crawler/notify', (req, res) => { res.json({ ok: true }) }) -// 生产环境:提供前端静态文件 +// 生产环境:提供前端静态文件(含修订页 /edit,依赖 SPA fallback) const distPath = path.join(__dirname, '..', 'dist') if (fs.existsSync(distPath)) { app.use(express.static(distPath)) + // 非 API/WS 的请求一律返回 index.html,由前端路由处理 /、/edit、/db 等 app.get('*', (req, res, next) => { if (!req.path.startsWith('/api') && req.path !== '/ws') { res.sendFile(path.join(distPath, 'index.html')) } else next() }) +} else { + console.warn('[server] dist 目录不存在,前端页面(含 /edit 修订页)不可用。请在项目根目录执行 npm run build 后再启动。') } const server = http.createServer(app) diff --git a/server/routes.js b/server/routes.js index c393165..4a3ef93 100644 --- a/server/routes.js +++ b/server/routes.js @@ -163,6 +163,18 @@ router.post('/share', (req, res) => { } }) +router.post('/like', (req, res) => { + try { + db.prepare( + 'INSERT INTO like_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1' + ).run() + res.json(getStats()) + } catch (err) { + console.error(err) + res.status(500).json({ viewers: 0, cumulative: 0, feedbackCount: 0, shareCount: 0, likeCount: 0 }) + } +}) + router.get('/stats', (req, res) => { try { res.json(getStats()) @@ -215,6 +227,10 @@ router.get('/edit/raw', (req, res) => { "SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')" ).get()?.n ?? 0 const realFeedback = db.prepare('SELECT COUNT(*) as n FROM feedback').get()?.n ?? 0 + let realLikeCount = 0 + try { + realLikeCount = db.prepare('SELECT total FROM like_count WHERE id = 1').get()?.total ?? 0 + } catch (_) {} res.json({ combatLosses: { us: lossesUs || null, iran: lossesIr || null }, keyLocations: { us: locUs || [], iran: locIr || [] }, @@ -224,7 +240,7 @@ router.get('/edit/raw', (req, res) => { viewers: displayStats?.viewers ?? liveViewers, cumulative: displayStats?.cumulative ?? realCumulative, shareCount: displayStats?.share_count ?? realShare, - likeCount: displayStats?.like_count ?? 0, + likeCount: displayStats?.like_count ?? realLikeCount, feedbackCount: displayStats?.feedback_count ?? realFeedback, }, }) diff --git a/server/stats.js b/server/stats.js index ad84aa1..51fc3f7 100644 --- a/server/stats.js +++ b/server/stats.js @@ -13,11 +13,15 @@ function getStats() { const cumulativeRow = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get() const feedbackRow = db.prepare('SELECT COUNT(*) as n FROM feedback').get() const shareRow = db.prepare('SELECT total FROM share_count WHERE id = 1').get() + let realLikeCount = 0 + try { + realLikeCount = toNum(db.prepare('SELECT total FROM like_count WHERE id = 1').get()?.total) + } catch (_) {} let viewers = toNum(viewersRow?.n) let cumulative = toNum(cumulativeRow?.total) let feedbackCount = toNum(feedbackRow?.n) let shareCount = toNum(shareRow?.total) - let likeCount = 0 + let likeCount = realLikeCount let display = null try { display = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get() @@ -27,6 +31,7 @@ function getStats() { if (display.cumulative != null) cumulative = toNum(display.cumulative) if (display.share_count != null) shareCount = toNum(display.share_count) if (display.like_count != null) likeCount = toNum(display.like_count) + else likeCount = realLikeCount if (display.feedback_count != null) feedbackCount = toNum(display.feedback_count) } return { viewers, cumulative, feedbackCount, shareCount, likeCount } diff --git a/src/components/HeaderPanel.tsx b/src/components/HeaderPanel.tsx index 47c1605..4c9fdf8 100644 --- a/src/components/HeaderPanel.tsx +++ b/src/components/HeaderPanel.tsx @@ -50,6 +50,7 @@ export function HeaderPanel() { const [feedbackText, setFeedbackText] = useState('') const [feedbackSending, setFeedbackSending] = useState(false) const [feedbackDone, setFeedbackDone] = useState(false) + const [likeSending, setLikeSending] = useState(false) useEffect(() => { const timer = setInterval(() => setNow(new Date()), 1000) @@ -139,14 +140,41 @@ export function HeaderPanel() { } } - const handleLike = () => { - if (liked) return + const handleLike = async () => { + if (liked || likeSending) return setLiked(true) - const next = likes + 1 - setLikes(next) + setLikeSending(true) try { - localStorage.setItem(STORAGE_LIKES, String(next)) - } catch {} + const res = await fetch('/api/like', { method: 'POST' }) + const data = await res.json() + if (res.ok && data.likeCount != null) { + setStats({ + viewers: data.viewers, + cumulative: data.cumulative, + feedbackCount: data.feedbackCount, + shareCount: data.shareCount, + likeCount: data.likeCount, + }) + setLikes(data.likeCount) + try { + localStorage.setItem(STORAGE_LIKES, String(data.likeCount)) + } catch {} + } else { + const next = likes + 1 + setLikes(next) + try { + localStorage.setItem(STORAGE_LIKES, String(next)) + } catch {} + } + } catch { + const next = likes + 1 + setLikes(next) + try { + localStorage.setItem(STORAGE_LIKES, String(next)) + } catch {} + } finally { + setLikeSending(false) + } } const formatDateTime = (d: Date) => @@ -218,7 +246,8 @@ export function HeaderPanel() {