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 asyncio
import logging import logging
import re
import threading import threading
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -15,6 +16,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .admin import admin_login, admin_logout, admin_page from .admin import admin_login, admin_logout, admin_page
from .config import settings from .config import settings
@@ -120,6 +122,52 @@ async def download_installer():
return JSONResponse({"detail": "安装包不存在"}, status_code=404) 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 即可) ---------- # ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ----------
@app.get("/{path:path}", include_in_schema=False) @app.get("/{path:path}", include_in_schema=False)
+10
View File
@@ -141,9 +141,19 @@ export default function PageHeader() {
{/* <span className="bd-logo"> {/* <span className="bd-logo">
<Image src="/logo.png" alt="Logo" /> <Image src="/logo.png" alt="Logo" />
</span> */} </span> */}
<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"> <Avatar size="lg">
<AvatarImage src="/logo.png" alt="" /> <AvatarImage src="/logo.png" alt="" />
</Avatar> </Avatar>
</span>
<div className="bd-header-titles"> <div className="bd-header-titles">
<h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1> <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> <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 PageHeader from './PageHeader';
import DpmOverlays from './DpmOverlays'; import DpmOverlays from './DpmOverlays';
import ToolStatusToast from './ToolStatusToast'; import ToolStatusToast from './ToolStatusToast';
import UpdateNotify from './UpdateNotify';
import GlobalVision from './GlobalVision'; import GlobalVision from './GlobalVision';
import { usePageNav } from '../utils/pageNav'; import { usePageNav } from '../utils/pageNav';
import { useMqttControl } from '../utils/useMqttControl'; import { useMqttControl } from '../utils/useMqttControl';
@@ -29,6 +30,7 @@ export default function ScreenLayout() {
<GlobalVision /> <GlobalVision />
<DpmOverlays /> <DpmOverlays />
<ToolStatusToast /> <ToolStatusToast />
<UpdateNotify />
</div> </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; color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan)); 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 { .ts-toast {
position: fixed; 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');
}
}