fix:优化docker p配置
This commit is contained in:
10
README.md
10
README.md
@@ -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 令牌,构建时注入
|
||||||
|
|||||||
BIN
crawler/__pycache__/cleaner_ai.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/cleaner_ai.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/config.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/db_writer.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/db_writer.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/parser.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/parser.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/parser_ai.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/parser_ai.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/realtime_conflict_service.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/realtime_conflict_service.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/translate_utils.cpython-311.pyc
Normal file
BIN
crawler/__pycache__/translate_utils.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
13
docker-compose.dev.yml
Normal file
13
docker-compose.dev.yml
Normal 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"]
|
||||||
12
server/db.js
12
server/db.js
@@ -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
|
||||||
|
|||||||
@@ -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: '留言内容 1–2000 字' })
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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="请输入留言内容(1–2000 字)..."
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user