This commit is contained in:
Daniel
2026-03-04 00:39:01 +08:00
parent 95e2fe1c41
commit 3264b3252a
8 changed files with 152 additions and 17 deletions

View File

@@ -10,7 +10,7 @@
| crawler| 8000 | RSS 爬虫 + GDELT内部服务 | | crawler| 8000 | RSS 爬虫 + GDELT内部服务 |
- 数据库SQLite挂载到 `app-data` volume`/data/data.db` - 数据库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 重新提取) # 回填战损数据(从 situation_update 重新提取)
curl -X POST http://localhost:8000/crawler/backfill 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。二选一即可
**方式 ANginx 只反代,所有页面由 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";
}
}
```
**方式 BNginx 提供 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`)。

View File

@@ -1,11 +1,23 @@
# 仅后端 API 镜像(前端自行部署 # 前端 + 后端合一镜像:构建阶段产出 dist运行阶段提供静态与 API含修订页 /edit
# 国内服务器拉取慢时,可加 --build-arg REGISTRY=docker.m.daocloud.io/library # 国内服务器拉取慢时,可加 --build-arg REGISTRY=docker.m.daocloud.io/library
ARG REGISTRY= 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 FROM ${REGISTRY}node:20-slim
RUN npm config set registry https://registry.npmmirror.com 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 && \ 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 main' > /etc/apt/sources.list && \
echo 'deb http://mirrors.aliyun.com/debian bookworm-updates 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++ && \ apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY server ./server COPY server ./server
COPY --from=frontend-builder /app/dist ./dist
ENV NODE_ENV=production ENV NODE_ENV=production
ENV API_PORT=3001 ENV API_PORT=3001

6
map.md
View File

@@ -282,3 +282,9 @@ const IRAN_SOURCE = [51.3890, 35.6892] // Tehran
所有动画走 WebGL 图层 所有动画走 WebGL 图层
禁止 DOM 动画 禁止 DOM 动画
git代码更新:git fetch origin && git reset --hard origin/master
前端发版npm run build
后端发版pm2 restart 3

View File

@@ -236,6 +236,15 @@ function runMigrations(db) {
INSERT OR IGNORE INTO share_count (id, total) VALUES (1, 0); INSERT OR IGNORE INTO share_count (id, total) VALUES (1, 0);
`) `)
} catch (_) {} } 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 { try {
exec(` exec(`
CREATE TABLE IF NOT EXISTS display_stats ( CREATE TABLE IF NOT EXISTS display_stats (

View File

@@ -32,15 +32,18 @@ app.post('/api/crawler/notify', (req, res) => {
res.json({ ok: true }) res.json({ ok: true })
}) })
// 生产环境:提供前端静态文件 // 生产环境:提供前端静态文件(含修订页 /edit依赖 SPA fallback
const distPath = path.join(__dirname, '..', 'dist') const distPath = path.join(__dirname, '..', 'dist')
if (fs.existsSync(distPath)) { if (fs.existsSync(distPath)) {
app.use(express.static(distPath)) app.use(express.static(distPath))
// 非 API/WS 的请求一律返回 index.html由前端路由处理 /、/edit、/db 等
app.get('*', (req, res, next) => { app.get('*', (req, res, next) => {
if (!req.path.startsWith('/api') && req.path !== '/ws') { if (!req.path.startsWith('/api') && req.path !== '/ws') {
res.sendFile(path.join(distPath, 'index.html')) res.sendFile(path.join(distPath, 'index.html'))
} else next() } else next()
}) })
} else {
console.warn('[server] dist 目录不存在,前端页面(含 /edit 修订页)不可用。请在项目根目录执行 npm run build 后再启动。')
} }
const server = http.createServer(app) const server = http.createServer(app)

View File

@@ -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) => { router.get('/stats', (req, res) => {
try { try {
res.json(getStats()) 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')" "SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')"
).get()?.n ?? 0 ).get()?.n ?? 0
const realFeedback = db.prepare('SELECT COUNT(*) as n FROM feedback').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({ res.json({
combatLosses: { us: lossesUs || null, iran: lossesIr || null }, combatLosses: { us: lossesUs || null, iran: lossesIr || null },
keyLocations: { us: locUs || [], iran: locIr || [] }, keyLocations: { us: locUs || [], iran: locIr || [] },
@@ -224,7 +240,7 @@ router.get('/edit/raw', (req, res) => {
viewers: displayStats?.viewers ?? liveViewers, viewers: displayStats?.viewers ?? liveViewers,
cumulative: displayStats?.cumulative ?? realCumulative, cumulative: displayStats?.cumulative ?? realCumulative,
shareCount: displayStats?.share_count ?? realShare, shareCount: displayStats?.share_count ?? realShare,
likeCount: displayStats?.like_count ?? 0, likeCount: displayStats?.like_count ?? realLikeCount,
feedbackCount: displayStats?.feedback_count ?? realFeedback, feedbackCount: displayStats?.feedback_count ?? realFeedback,
}, },
}) })

View File

@@ -13,11 +13,15 @@ function getStats() {
const cumulativeRow = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get() 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 feedbackRow = db.prepare('SELECT COUNT(*) as n FROM feedback').get()
const shareRow = db.prepare('SELECT total FROM share_count WHERE id = 1').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 viewers = toNum(viewersRow?.n)
let cumulative = toNum(cumulativeRow?.total) let cumulative = toNum(cumulativeRow?.total)
let feedbackCount = toNum(feedbackRow?.n) let feedbackCount = toNum(feedbackRow?.n)
let shareCount = toNum(shareRow?.total) let shareCount = toNum(shareRow?.total)
let likeCount = 0 let likeCount = realLikeCount
let display = null let display = null
try { try {
display = db.prepare('SELECT viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get() 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.cumulative != null) cumulative = toNum(display.cumulative)
if (display.share_count != null) shareCount = toNum(display.share_count) if (display.share_count != null) shareCount = toNum(display.share_count)
if (display.like_count != null) likeCount = toNum(display.like_count) if (display.like_count != null) likeCount = toNum(display.like_count)
else likeCount = realLikeCount
if (display.feedback_count != null) feedbackCount = toNum(display.feedback_count) if (display.feedback_count != null) feedbackCount = toNum(display.feedback_count)
} }
return { viewers, cumulative, feedbackCount, shareCount, likeCount } return { viewers, cumulative, feedbackCount, shareCount, likeCount }

View File

@@ -50,6 +50,7 @@ export function HeaderPanel() {
const [feedbackText, setFeedbackText] = useState('') const [feedbackText, setFeedbackText] = useState('')
const [feedbackSending, setFeedbackSending] = useState(false) const [feedbackSending, setFeedbackSending] = useState(false)
const [feedbackDone, setFeedbackDone] = useState(false) const [feedbackDone, setFeedbackDone] = useState(false)
const [likeSending, setLikeSending] = useState(false)
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000) const timer = setInterval(() => setNow(new Date()), 1000)
@@ -139,15 +140,42 @@ export function HeaderPanel() {
} }
} }
const handleLike = () => { const handleLike = async () => {
if (liked) return if (liked || likeSending) return
setLiked(true) setLiked(true)
setLikeSending(true)
try {
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 const next = likes + 1
setLikes(next) setLikes(next)
try { try {
localStorage.setItem(STORAGE_LIKES, String(next)) localStorage.setItem(STORAGE_LIKES, String(next))
} catch {} } catch {}
} }
} catch {
const next = likes + 1
setLikes(next)
try {
localStorage.setItem(STORAGE_LIKES, String(next))
} catch {}
} finally {
setLikeSending(false)
}
}
const formatDateTime = (d: Date) => const formatDateTime = (d: Date) =>
d.toLocaleString('zh-CN', { d.toLocaleString('zh-CN', {
@@ -218,7 +246,8 @@ export function HeaderPanel() {
<button <button
type="button" type="button"
onClick={handleLike} onClick={handleLike}
className={`flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] transition-colors sm:px-2 sm:py-1 sm:text-[10px] ${ disabled={likeSending}
className={`flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[9px] transition-colors sm:px-2 sm:py-1 sm:text-[10px] disabled:opacity-50 ${
liked liked
? 'border-red-500/50 bg-red-500/20 text-red-400' ? 'border-red-500/50 bg-red-500/20 text-red-400'
: 'border-military-border text-military-text-secondary hover:bg-military-border/30 hover:text-red-400' : 'border-military-border text-military-text-secondary hover:bg-military-border/30 hover:text-red-400'