feat: 添加客户端更新检测功能,支持版本检查和更新提示;更新相关组件和样式

This commit is contained in:
Pine
2026-08-19 13:14:00 +08:00
parent 5ed396aea4
commit 91e20c3a81
7 changed files with 251 additions and 3 deletions
+48
View File
@@ -6,6 +6,7 @@
import asyncio
import logging
import re
import threading
import time
from contextlib import asynccontextmanager
@@ -15,6 +16,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .admin import admin_login, admin_logout, admin_page
from .config import settings
@@ -120,6 +122,52 @@ async def download_installer():
return JSONResponse({"detail": "安装包不存在"}, status_code=404)
# ---------- 客户端更新检测(仅 Windows:比对版本 → 下发安装包下载地址) ----------
def _latest_installer():
"""返回 backend/ 下最新(按文件名排序取最大)的 *.setup.exe。"""
for f in sorted(_DOWNLOAD_ROOT.glob("*setup.exe"), reverse=True):
if f.is_file():
return f
return None
def _version_from_name(name: str) -> str:
"""从安装包文件名解析版本,如 `..._0.1.0_x64-setup.exe` → `0.1.0`。"""
m = re.search(r"_(\d+\.\d+(?:\.\d+)?)", name or "")
return m.group(1) if m else ""
def _version_key(v: str):
return tuple(int(x) for x in v.split(".") if x.isdigit())
class UpdateCheckBody(BaseModel):
current: str = "" # 前端上报的当前版本,如 0.1.0
@app.post("/api/update/check", include_in_schema=False)
async def update_check(body: UpdateCheckBody):
"""检测更新:返回最新版本号、安装包下载地址、是否需要更新(仅 Windows)。"""
inst = _latest_installer()
if not inst:
return {"ok": False, "error": "installer not found"}
latest = _version_from_name(inst.name)
current = (body.current or "").strip()
update_available = False
if latest and current and current != latest:
update_available = _version_key(latest) > _version_key(current)
elif latest and not current:
update_available = True
return {
"ok": True,
"latest_version": latest,
"installer_name": inst.name,
"download_url": "/download",
"update_available": update_available,
}
# ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ----------
@app.get("/{path:path}", include_in_schema=False)
+13 -3
View File
@@ -141,9 +141,19 @@ export default function PageHeader() {
{/* <span className="bd-logo">
<Image src="/logo.png" alt="Logo" />
</span> */}
<Avatar size="lg">
<AvatarImage src="/logo.png" alt="" />
</Avatar>
<span
className="bd-logo-update"
role="button"
tabIndex={0}
title="点击检测更新"
aria-label="点击检测更新"
onClick={() => window.dispatchEvent(new CustomEvent('dpm:check-update'))}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') window.dispatchEvent(new CustomEvent('dpm:check-update')); }}
>
<Avatar size="lg">
<AvatarImage src="/logo.png" alt="" />
</Avatar>
</span>
<div className="bd-header-titles">
<h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1>
<div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span></div>
+2
View File
@@ -2,6 +2,7 @@ import { Outlet } from 'react-router-dom';
import PageHeader from './PageHeader';
import DpmOverlays from './DpmOverlays';
import ToolStatusToast from './ToolStatusToast';
import UpdateNotify from './UpdateNotify';
import GlobalVision from './GlobalVision';
import { usePageNav } from '../utils/pageNav';
import { useMqttControl } from '../utils/useMqttControl';
@@ -29,6 +30,7 @@ export default function ScreenLayout() {
<GlobalVision />
<DpmOverlays />
<ToolStatusToast />
<UpdateNotify />
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Icon from './Icons';
import { checkForUpdate, openUpdate } from '../utils/updateCheck';
/* =========================================================
UpdateNotify —— 检测新版本(Windows
· 启动静默检查一次 + 每 30 分钟一次(仅在有更新时提示)
· 页眉 logo 点击 → dpm:check-update → 主动检测:
有更新 → 弹更新提示;无更新 → 临时提示「已是最新版本」
========================================================= */
export default function UpdateNotify() {
const [upd, setUpd] = useState(null); // 更新提示
const [msg, setMsg] = useState(null); // 临时提示(已是最新 / 失败)
const [dismissed, setDismissed] = useState(false);
const timerRef = useRef(null);
const run = useCallback(async (manual) => {
const u = await checkForUpdate();
if (u) {
if (u.available) {
setUpd(u);
setDismissed(false);
} else if (manual) {
setMsg(`已是最新版本 v${u.latest}`);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setMsg(null), 3000);
}
} else if (manual) {
setMsg('更新检测失败');
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setMsg(null), 3000);
}
}, []);
useEffect(() => {
const onCheck = () => run(true);
window.addEventListener('dpm:check-update', onCheck);
run(false); // 启动静默检测
const iv = setInterval(() => run(false), 30 * 60 * 1000);
return () => {
window.removeEventListener('dpm:check-update', onCheck);
clearInterval(iv);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [run]);
return (
<>
{upd && !dismissed && (
<div className="upd-toast" role="status">
<div className="upd-ico"><Icon name="rocket" size={15} /></div>
<div className="upd-body">
<b>发现新版本 v{upd.latest}</b>
<span>当前 v{upd.current} · 点击下载更新安装包Windows</span>
</div>
<button className="upd-btn" onClick={() => openUpdate(upd.url)}>下载更新</button>
<button className="upd-close" onClick={() => setDismissed(true)} aria-label="关闭">×</button>
</div>
)}
{msg && (
<div className="upd-toast upd-msg" role="status">
<div className="upd-ico"><Icon name="check" size={15} /></div>
<div className="upd-body"><b>{msg}</b></div>
</div>
)}
</>
);
}
+80
View File
@@ -2201,6 +2201,86 @@
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
}
/* ---- 页眉 logo:点击检测更新 ---- */
.bd-logo-update {
display: inline-flex;
cursor: pointer;
border-radius: 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.bd-logo-update:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(29, 93, 206, 0.25);
}
.bd-logo-update:active { transform: translateY(0); }
.bd-logo-update:focus-visible { outline: 2px solid var(--bd-blue); outline-offset: 2px; }
/* ---- 底部:更新提示(Windows 检测到新版本) ---- */
.upd-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 3200;
display: flex;
align-items: center;
gap: 12px;
max-width: 540px;
padding: 12px 16px;
background: #fff;
border: 1px solid var(--bd-line);
border-radius: 12px;
box-shadow: 0 12px 30px rgba(29, 44, 68, 0.16);
animation: bdOverlayIn 0.3s ease;
}
.upd-ico {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
}
.upd-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.upd-body b { font-size: 13px; color: var(--bd-ink); }
.upd-body span { font-size: 11.5px; color: var(--bd-sub); }
.upd-btn {
flex-shrink: 0;
padding: 7px 15px;
border: none;
border-radius: 8px;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.upd-btn:hover { opacity: 0.9; }
.upd-msg .upd-ico { background: linear-gradient(140deg, var(--bd-green), #4ade80); }
.upd-close {
width: 24px;
height: 24px;
flex-shrink: 0;
border: none;
background: transparent;
color: var(--bd-sub);
font-size: 16px;
line-height: 1;
cursor: pointer;
border-radius: 6px;
}
.upd-close:hover { color: var(--bd-red); background: rgba(240, 68, 56, 0.1); }
/* ---- 右上角:工具 / 操作进行中状态提示 ---- */
.ts-toast {
position: fixed;
+40
View File
@@ -0,0 +1,40 @@
/* =========================================================
客户端更新检测(仅 Windows)
后端 POST /api/update/check 返回最新版本 + 安装包下载地址;
前端比对后提示,并提供下载(opener 打开安装包下载链接)。
========================================================= */
import { getApiBase } from '../config';
import { openUrl } from '@tauri-apps/plugin-opener';
// 当前版本:发布新版本时同步 package.json 的 version
export const APP_VERSION = '0.1.0';
export async function checkForUpdate() {
try {
const res = await fetch(`${getApiBase()}/api/update/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: APP_VERSION }),
});
const data = await res.json();
if (data && data.ok) {
return {
current: APP_VERSION,
latest: data.latest_version,
available: !!data.update_available,
url: `${getApiBase()}${data.download_url || '/download'}`,
name: data.installer_name || '',
};
}
} catch { /* 后端不可达时忽略 */ }
return null;
}
/** 打开安装包下载地址(优先系统浏览器下载)。 */
export function openUpdate(url) {
try {
openUrl(url);
} catch {
window.open(url, '_blank');
}
}