fix: bug
This commit is contained in:
59
DEPLOY.md
59
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`)。
|
||||
|
||||
20
Dockerfile
20
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
|
||||
|
||||
6
map.md
6
map.md
@@ -282,3 +282,9 @@ const IRAN_SOURCE = [51.3890, 35.6892] // Tehran
|
||||
所有动画走 WebGL 图层
|
||||
|
||||
禁止 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);
|
||||
`)
|
||||
} 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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,15 +140,42 @@ export function HeaderPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleLike = () => {
|
||||
if (liked) return
|
||||
const handleLike = async () => {
|
||||
if (liked || likeSending) return
|
||||
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
|
||||
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) =>
|
||||
d.toLocaleString('zh-CN', {
|
||||
@@ -218,7 +246,8 @@ export function HeaderPanel() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
? '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'
|
||||
|
||||
Reference in New Issue
Block a user