Skip to content

Commit 610c720

Browse files
committed
feat: 增加发布通知与Docker版本升级检测
1 parent 9e2ce06 commit 610c720

File tree

4 files changed

+224
-2
lines changed

4 files changed

+224
-2
lines changed

.github/workflows/release.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,50 @@ jobs:
153153
with:
154154
tag_name: ${{ steps.vars.outputs.version }}
155155
body_path: release-notes.md
156+
157+
- name: Notify Telegram
158+
if: ${{ always() }}
159+
continue-on-error: true
160+
env:
161+
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
162+
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
163+
JOB_STATUS: ${{ job.status }}
164+
VERSION: ${{ steps.vars.outputs.version }}
165+
shell: bash
166+
run: |
167+
set -euo pipefail
168+
169+
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then
170+
echo "TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not configured; skip notify."
171+
exit 0
172+
fi
173+
174+
if [[ "${JOB_STATUS}" == "success" ]]; then
175+
icon="✅"
176+
status_text="发布成功"
177+
else
178+
icon="❌"
179+
status_text="发布失败"
180+
fi
181+
182+
run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
183+
ref_name="${{ github.ref_name }}"
184+
actor="${{ github.actor }}"
185+
event_name="${{ github.event_name }}"
186+
187+
if [[ -z "${VERSION}" ]]; then
188+
VERSION="${ref_name}"
189+
fi
190+
191+
text="${icon} PanWatch Release ${status_text}
192+
Repository: ${{ github.repository }}
193+
Version: ${VERSION}
194+
Ref: ${ref_name}
195+
Trigger: ${event_name} by ${actor}
196+
Run: ${run_url}"
197+
198+
curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
199+
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
200+
--data-urlencode "text=${text}" \
201+
--data-urlencode "disable_web_page_preview=true" \
202+
--max-time 20

frontend/src/App.tsx

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { useState, useEffect } from 'react'
1+
import { useState, useEffect, useRef } from 'react'
22
import { Routes, Route, NavLink, useLocation, Navigate } from 'react-router-dom'
33
import { Moon, Sun, TrendingUp, Bot, ScrollText, Settings, List, Database, Clock, LayoutDashboard, LogOut } from 'lucide-react'
44
import { useTheme } from '@/hooks/use-theme'
5-
import { isAuthenticated, logout } from '@/lib/utils'
5+
import { fetchAPI, isAuthenticated, logout } from '@/lib/utils'
66
import DashboardPage from '@/pages/Dashboard'
77
import StocksPage from '@/pages/Stocks'
88
import StockDetailPage from '@/pages/StockDetail'
@@ -13,6 +13,8 @@ import HistoryPage from '@/pages/History'
1313
import LoginPage from '@/pages/Login'
1414
import LogsModal from '@/components/logs-modal'
1515
import AmbientBackground from '@/components/AmbientBackground'
16+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
17+
import { Button } from '@/components/ui/button'
1618

1719
const navItems = [
1820
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
@@ -61,6 +63,9 @@ function App() {
6163
const location = useLocation()
6264
const [version, setVersion] = useState('')
6365
const [logsOpen, setLogsOpen] = useState(false)
66+
const [upgradeOpen, setUpgradeOpen] = useState(false)
67+
const [upgradeInfo, setUpgradeInfo] = useState<{ latest: string; url: string } | null>(null)
68+
const checkedUpdateRef = useRef(false)
6469

6570
useEffect(() => {
6671
fetch(`${API_BASE}/api/version`)
@@ -69,6 +74,26 @@ function App() {
6974
.catch(() => {})
7075
}, [])
7176

77+
useEffect(() => {
78+
if (checkedUpdateRef.current) return
79+
if (!isAuthenticated()) return
80+
const current = String(version || '').trim()
81+
if (!current || current === 'dev') return
82+
checkedUpdateRef.current = true
83+
84+
fetchAPI<any>('/settings/update-check')
85+
.then((res) => {
86+
const latest = String(res?.latest_version || '').trim()
87+
const shouldOpen = !!res?.update_available && !!latest
88+
if (!shouldOpen) return
89+
const dismissed = localStorage.getItem('panwatch_upgrade_dismissed_version') || ''
90+
if (dismissed === latest) return
91+
setUpgradeInfo({ latest, url: String(res?.release_url || 'https://github.com/sunxiao0721/PanWatch/releases') })
92+
setUpgradeOpen(true)
93+
})
94+
.catch(() => {})
95+
}, [version])
96+
7297
// 登录页面不显示导航
7398
if (location.pathname === '/login') {
7499
return (
@@ -224,6 +249,38 @@ function App() {
224249
</Routes>
225250
</main>
226251
<LogsModal open={logsOpen} onOpenChange={setLogsOpen} />
252+
<Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}>
253+
<DialogContent className="max-w-md">
254+
<DialogHeader>
255+
<DialogTitle>发现新版本</DialogTitle>
256+
<DialogDescription>
257+
当前版本 v{version},可升级到 v{upgradeInfo?.latest}
258+
</DialogDescription>
259+
</DialogHeader>
260+
<div className="text-[12px] text-muted-foreground">
261+
建议升级以获取最新功能和修复。
262+
</div>
263+
<DialogFooter>
264+
<Button
265+
variant="secondary"
266+
onClick={() => {
267+
if (upgradeInfo?.latest) localStorage.setItem('panwatch_upgrade_dismissed_version', upgradeInfo.latest)
268+
setUpgradeOpen(false)
269+
}}
270+
>
271+
稍后提醒
272+
</Button>
273+
<Button
274+
onClick={() => {
275+
const url = upgradeInfo?.url || 'https://github.com/sunxiao0721/PanWatch/releases'
276+
window.open(url, '_blank', 'noopener,noreferrer')
277+
}}
278+
>
279+
去升级
280+
</Button>
281+
</DialogFooter>
282+
</DialogContent>
283+
</Dialog>
227284
</div>
228285
</RequireAuth>
229286
)

src/core/update_checker.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""应用升级检测模块(基于 Docker Hub tag)。"""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import re
7+
import time
8+
from datetime import datetime, timezone
9+
from threading import Lock
10+
11+
import requests
12+
13+
_CACHE_LOCK = Lock()
14+
_CACHE: dict[str, object] = {
15+
"ts": 0.0,
16+
"latest_version": None,
17+
"release_url": None,
18+
"error": None,
19+
}
20+
_CACHE_TTL_SECONDS = 15 * 60
21+
22+
23+
def _normalize(version: str | None) -> str:
24+
return str(version or "").strip().lstrip("vV")
25+
26+
27+
def _parse_semver(version: str | None) -> tuple[int, int, int] | None:
28+
v = _normalize(version)
29+
m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", v)
30+
if not m:
31+
return None
32+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
33+
34+
35+
def _fetch_latest_docker_tag(repo: str) -> tuple[str | None, str | None, str | None]:
36+
# Docker Hub API:
37+
# GET /v2/namespaces/{namespace}/repositories/{repository}/tags?page_size=100
38+
parts = [p for p in repo.strip("/").split("/") if p]
39+
if len(parts) != 2:
40+
return None, None, "invalid_repo"
41+
namespace, repository = parts
42+
url = f"https://hub.docker.com/v2/namespaces/{namespace}/repositories/{repository}/tags?page_size=100"
43+
try:
44+
resp = requests.get(url, timeout=8)
45+
if resp.status_code != 200:
46+
return None, None, f"http_{resp.status_code}"
47+
data = resp.json() or {}
48+
results = data.get("results") or []
49+
best_sem: tuple[int, int, int] | None = None
50+
best_tag: str | None = None
51+
for item in results:
52+
tag = str(item.get("name") or "").strip()
53+
sem = _parse_semver(tag)
54+
if sem is None:
55+
continue
56+
if best_sem is None or sem > best_sem:
57+
best_sem = sem
58+
best_tag = _normalize(tag)
59+
tags_url = f"https://hub.docker.com/r/{namespace}/{repository}/tags"
60+
if best_tag:
61+
return best_tag, tags_url, None
62+
return None, tags_url, "no_semver_tag"
63+
except Exception as e:
64+
return None, None, str(e)
65+
66+
67+
def check_update(current_version: str) -> dict[str, object]:
68+
repo = os.getenv("UPDATE_CHECK_DOCKER_REPO", "sunxiao0721/panwatch")
69+
force_disable = os.getenv("UPDATE_CHECK_DISABLE", "").strip() in {"1", "true", "True"}
70+
if force_disable:
71+
return {
72+
"enabled": False,
73+
"source": "docker",
74+
"current_version": _normalize(current_version),
75+
"latest_version": None,
76+
"update_available": False,
77+
"release_url": f"https://hub.docker.com/r/{repo}/tags",
78+
"checked_at": datetime.now(timezone.utc).isoformat(),
79+
"error": "disabled",
80+
}
81+
82+
now = time.monotonic()
83+
with _CACHE_LOCK:
84+
age = now - float(_CACHE["ts"] or 0)
85+
if age <= _CACHE_TTL_SECONDS and _CACHE.get("latest_version") is not None:
86+
latest = str(_CACHE.get("latest_version") or "")
87+
release_url = str(_CACHE.get("release_url") or f"https://hub.docker.com/r/{repo}/tags")
88+
err = _CACHE.get("error")
89+
else:
90+
latest, release_url, err = _fetch_latest_docker_tag(repo)
91+
_CACHE["ts"] = now
92+
_CACHE["latest_version"] = latest
93+
_CACHE["release_url"] = release_url
94+
_CACHE["error"] = err
95+
96+
current_norm = _normalize(current_version)
97+
cur_sem = _parse_semver(current_norm)
98+
latest_sem = _parse_semver(latest)
99+
update_available = bool(cur_sem and latest_sem and latest_sem > cur_sem)
100+
101+
return {
102+
"enabled": True,
103+
"source": "docker",
104+
"current_version": current_norm,
105+
"latest_version": latest,
106+
"update_available": update_available,
107+
"release_url": release_url or f"https://hub.docker.com/r/{repo}/tags",
108+
"checked_at": datetime.now(timezone.utc).isoformat(),
109+
"error": err,
110+
}

src/web/api/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from src.web.database import get_db
77
from src.web.models import AppSettings
88
from src.config import Settings
9+
from src.core.update_checker import check_update
910

1011
router = APIRouter()
1112

@@ -113,3 +114,10 @@ def update_setting(key: str, update: SettingUpdate, db: Session = Depends(get_db
113114
def get_version():
114115
"""获取应用版本号"""
115116
return {"version": get_app_version()}
117+
118+
119+
@router.get("/update-check")
120+
def get_update_check():
121+
"""检查是否有可用新版本(带服务端缓存)。"""
122+
current = get_app_version()
123+
return check_update(current)

0 commit comments

Comments
 (0)