fix: bug
This commit is contained in:
59
DEPLOY.md
59
DEPLOY.md
@@ -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。二选一即可:
|
||||||
|
|
||||||
|
**方式 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`)。
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -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
6
map.md
@@ -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
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,14 +140,41 @@ export function HeaderPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLike = () => {
|
const handleLike = async () => {
|
||||||
if (liked) return
|
if (liked || likeSending) return
|
||||||
setLiked(true)
|
setLiked(true)
|
||||||
const next = likes + 1
|
setLikeSending(true)
|
||||||
setLikes(next)
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_LIKES, String(next))
|
const res = await fetch('/api/like', { method: 'POST' })
|
||||||
} catch {}
|
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) =>
|
const formatDateTime = (d: Date) =>
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user