feat: 添加客户端更新检测功能,支持版本检查和更新提示;更新相关组件和样式
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Binary file not shown.
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user