fix:优化docker p配置

This commit is contained in:
Daniel
2026-03-02 14:23:36 +08:00
parent 36576592a2
commit 2d800094b1
13 changed files with 135 additions and 1 deletions

View File

@@ -90,6 +90,16 @@ docker compose up -d
**拉取镜像超时?** 在 Docker Desktop 配置镜像加速,见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md) **拉取镜像超时?** 在 Docker Desktop 配置镜像加速,见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md)
**开发时无需每次 rebuild**:使用开发模式挂载源码 + 热重载:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
- API`node --watch` 监听 `server/` 变更并自动重启
- 爬虫:`uvicorn --reload` 监听 `crawler/` 变更并自动重启
- 修改 `server/``crawler/` 后,服务会自动重载,无需重新 build
环境变量(可选,在 .env 或 docker-compose.yml 中配置): 环境变量(可选,在 .env 或 docker-compose.yml 中配置):
- `VITE_MAPBOX_ACCESS_TOKEN`Mapbox 令牌,构建时注入 - `VITE_MAPBOX_ACCESS_TOKEN`Mapbox 令牌,构建时注入

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

13
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,13 @@
# 开发模式:挂载源码 + 热重载,代码更新后无需重新 build
# 使用: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# 或: docker compose --profile dev up -d (需在 dev 服务加 profiles)
services:
api:
volumes:
- ./server:/app/server:ro
command: ["node", "--watch", "server/index.js"]
crawler:
volumes:
- ./crawler:/app
command: ["uvicorn", "realtime_conflict_service:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -162,4 +162,16 @@ try {
`) `)
} catch (_) {} } catch (_) {}
// 后台留言:供开发者收集用户反馈
try {
db.exec(`
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
ip TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`)
} catch (_) {}
module.exports = db module.exports = db

View File

@@ -8,6 +8,7 @@ const router = express.Router()
router.get('/db/dashboard', (req, res) => { router.get('/db/dashboard', (req, res) => {
try { try {
const tables = [ const tables = [
'feedback',
'situation', 'situation',
'force_summary', 'force_summary',
'power_index', 'power_index',
@@ -23,6 +24,7 @@ router.get('/db/dashboard', (req, res) => {
] ]
const data = {} const data = {}
const timeSort = { const timeSort = {
feedback: 'created_at DESC',
situation: 'updated_at DESC', situation: 'updated_at DESC',
situation_update: 'timestamp DESC', situation_update: 'timestamp DESC',
gdelt_events: 'event_time DESC', gdelt_events: 'event_time DESC',
@@ -89,6 +91,23 @@ router.post('/visit', (req, res) => {
} }
}) })
router.post('/feedback', (req, res) => {
try {
const content = (req.body?.content ?? '').toString().trim()
if (!content || content.length > 2000) {
return res.status(400).json({ ok: false, error: '留言内容 12000 字' })
}
const ip = getClientIp(req)
db.prepare(
'INSERT INTO feedback (content, ip) VALUES (?, ?)'
).run(content.slice(0, 2000), ip)
res.json({ ok: true })
} catch (err) {
console.error(err)
res.status(500).json({ ok: false, error: err.message })
}
})
router.get('/stats', (req, res) => { router.get('/stats', (req, res) => {
try { try {
const viewers = db.prepare( const viewers = db.prepare(

View File

@@ -3,7 +3,7 @@ import { StatCard } from './StatCard'
import { useSituationStore } from '@/store/situationStore' import { useSituationStore } from '@/store/situationStore'
import { useReplaySituation } from '@/hooks/useReplaySituation' import { useReplaySituation } from '@/hooks/useReplaySituation'
import { usePlaybackStore } from '@/store/playbackStore' import { usePlaybackStore } from '@/store/playbackStore'
import { Wifi, WifiOff, Clock, Share2, Heart, Eye } from 'lucide-react' import { Wifi, WifiOff, Clock, Share2, Heart, Eye, MessageSquare } from 'lucide-react'
const STORAGE_LIKES = 'us-iran-dashboard-likes' const STORAGE_LIKES = 'us-iran-dashboard-likes'
@@ -25,6 +25,10 @@ export function HeaderPanel() {
const [liked, setLiked] = useState(false) const [liked, setLiked] = useState(false)
const [viewers, setViewers] = useState(0) const [viewers, setViewers] = useState(0)
const [cumulative, setCumulative] = useState(0) const [cumulative, setCumulative] = useState(0)
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [feedbackText, setFeedbackText] = useState('')
const [feedbackSending, setFeedbackSending] = useState(false)
const [feedbackDone, setFeedbackDone] = useState(false)
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000) const timer = setInterval(() => setNow(new Date()), 1000)
@@ -69,6 +73,32 @@ export function HeaderPanel() {
return navigator.clipboard?.writeText(text) ?? Promise.resolve() return navigator.clipboard?.writeText(text) ?? Promise.resolve()
} }
const handleFeedback = async () => {
const text = feedbackText.trim()
if (!text || feedbackSending) return
setFeedbackSending(true)
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
})
const data = await res.json()
if (data.ok) {
setFeedbackText('')
setFeedbackDone(true)
setTimeout(() => {
setFeedbackOpen(false)
setFeedbackDone(false)
}, 800)
}
} catch {
setFeedbackDone(false)
} finally {
setFeedbackSending(false)
}
}
const handleLike = () => { const handleLike = () => {
if (liked) return if (liked) return
setLiked(true) setLiked(true)
@@ -127,6 +157,14 @@ export function HeaderPanel() {
<span className="text-[10px] opacity-70">|</span> <span className="text-[10px] opacity-70">|</span>
<span className="text-[10px]"> <b className="tabular-nums">{cumulative}</b></span> <span className="text-[10px]"> <b className="tabular-nums">{cumulative}</b></span>
</div> </div>
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400"
>
<MessageSquare className="h-3 w-3" />
</button>
<button <button
type="button" type="button"
onClick={handleShare} onClick={handleShare}
@@ -198,6 +236,48 @@ export function HeaderPanel() {
className="border-military-accent/50" className="border-military-accent/50"
/> />
</div> </div>
{/* 留言弹窗 */}
{feedbackOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={() => !feedbackSending && setFeedbackOpen(false)}
>
<div
className="w-[90%] max-w-md rounded-lg border border-military-border bg-military-panel p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-2 text-sm font-medium text-military-accent"></h3>
<p className="mb-2 text-[10px] text-military-text-secondary">
</p>
<textarea
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
placeholder="请输入留言内容12000 字)..."
rows={4}
maxLength={2000}
className="mb-3 w-full resize-none rounded border border-military-border bg-military-dark px-2 py-2 text-xs text-military-text-primary placeholder:text-military-text-secondary focus:border-cyan-500 focus:outline-none"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => !feedbackSending && setFeedbackOpen(false)}
className="rounded border border-military-border px-3 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30"
>
</button>
<button
type="button"
onClick={handleFeedback}
disabled={!feedbackText.trim() || feedbackSending}
className="rounded bg-cyan-600 px-3 py-1 text-[10px] text-white hover:bg-cyan-500 disabled:opacity-50"
>
{feedbackSending ? '提交中...' : feedbackDone ? '已提交' : '提交'}
</button>
</div>
</div>
</div>
)}
</header> </header>
) )
} }