From 4680003a11bc2f9ad6d73e84462a1340f555e73b Mon Sep 17 00:00:00 2001
From: crosswk
Date: Wed, 15 Jul 2026 23:29:42 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=80=E9=94=AE=E6=A3=80=E6=9F=A5?=
=?UTF-8?q?=E6=9B=B4=E6=96=B0=E3=80=81=E4=BF=AE=E5=A4=8D=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?=E5=90=8E=E8=87=AA=E5=8A=A8=E9=87=8D=E5=BC=80=E3=80=81=E9=A6=96?=
=?UTF-8?q?=E9=A1=B5=E8=BF=9C=E7=A8=8B=E5=85=AC=E5=91=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 关于页「检查更新」改为一步到位:检查→下载→安装
- 修复自动更新安装完成后无法自动重新打开应用的问题
- 新增首页公告栏:可由服务端下发通知,引导用户(如手动下载新版)
---
client/src-tauri/src/commands/system.rs | 28 ++++-
client/src/components/NoticeBanner.tsx | 117 ++++++++++++++++++++
client/src/features/update/autoUpdate.ts | 34 +++++-
client/src/pages/About.tsx | 4 +-
client/src/pages/Home.tsx | 3 +
client/src/services/defaults.ts | 3 +
client/src/services/notice.ts | 133 +++++++++++++++++++++++
7 files changed, 313 insertions(+), 9 deletions(-)
create mode 100644 client/src/components/NoticeBanner.tsx
create mode 100644 client/src/services/notice.ts
diff --git a/client/src-tauri/src/commands/system.rs b/client/src-tauri/src/commands/system.rs
index 149a29c..29937ea 100644
--- a/client/src-tauri/src/commands/system.rs
+++ b/client/src-tauri/src/commands/system.rs
@@ -368,14 +368,30 @@ pub fn install_downloaded_update(file_path: String, app: AppHandle) -> Result<()
.map_err(|e| format!("获取当前程序路径失败: {}", e))?;
let exe_str = current_exe.to_string_lossy().to_string();
- // start /wait 会等安装程序进程退出后才继续,/S 走静默安装。
- // 安装完成后拉起新 exe,带 --open-about 让新实例自动跳转到关于页。
- let watchdog_cmd = format!(
- "start /wait \"\" \"{}\" /S && start \"\" \"{}\" --open-about",
- file_path, exe_str
+ // 用一个临时 .bat 脚本接管"等安装完成再拉起新版本"。
+ // 关键:不要把带引号、含 && 的复合命令直接塞给 `cmd /C`——Rust 会把这个
+ // 含空格的参数整体再套引号并把内部 " 转义成 \",而 cmd.exe 不认识 \" 转义,
+ // 导致路径被啃坏(历史 bug:装完自动重启报「找不到 \ 文件」)。
+ // 脚本文件里的引号是文件字面量,不经过参数转义层,最稳;给 cmd /C 传单个
+ // 脚本路径是 cmd 唯一能干净处理的情形。
+ // - ping 兜 ~1s,等旧进程完全退出,避免安装程序覆盖 exe 时撞文件锁
+ // - start /wait 等静默安装结束,再拉起新 exe(--open-about 跳转关于页)
+ // - del "%~f0" 让脚本运行完自删
+ let script = format!(
+ "@echo off\r\n\
+ ping -n 2 127.0.0.1 >nul\r\n\
+ start /wait \"\" \"{installer}\" /S\r\n\
+ start \"\" \"{exe}\" --open-about\r\n\
+ del \"%~f0\"\r\n",
+ installer = file_path,
+ exe = exe_str,
);
+ let script_path = std::env::temp_dir().join("sayit-update-relaunch.bat");
+ std::fs::write(&script_path, script)
+ .map_err(|e| format!("写入更新重启脚本失败: {}", e))?;
+
Command::new("cmd")
- .args(["/C", &watchdog_cmd])
+ .args(["/C", &script_path.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("启动安装看门人进程失败: {}", e))?;
diff --git a/client/src/components/NoticeBanner.tsx b/client/src/components/NoticeBanner.tsx
new file mode 100644
index 0000000..98d6a58
--- /dev/null
+++ b/client/src/components/NoticeBanner.tsx
@@ -0,0 +1,117 @@
+// 远程公告 banner — 展示服务端下发的通知,与更新机制解耦。
+// 文案纯文本渲染,链接仅 https 且用系统浏览器打开(详见 services/notice.ts 的安全约束)。
+
+import { useEffect, useState } from 'react'
+import { Info, AlertTriangle, Megaphone, X } from 'lucide-react'
+import { open as shellOpen } from '@tauri-apps/plugin-shell'
+import {
+ fetchActiveNotice,
+ dismissNotice,
+ isSafeHttpsUrl,
+ type NoticeLevel,
+ type RemoteNotice,
+} from '@/services/notice'
+
+// 每 3 小时刷新一次,长期开着也能收到新公告
+const REFRESH_MS = 3 * 60 * 60 * 1000
+
+const LEVEL_STYLES: Record = {
+ info: {
+ wrap: 'border-primary/30 bg-primary/5',
+ accent: 'text-primary',
+ Icon: Info,
+ },
+ warning: {
+ wrap: 'border-amber-500/40 bg-amber-500/10',
+ accent: 'text-amber-500',
+ Icon: AlertTriangle,
+ },
+ critical: {
+ wrap: 'border-red-500/40 bg-red-500/10',
+ accent: 'text-red-500',
+ Icon: Megaphone,
+ },
+}
+
+/** 去掉末尾斜杠,展示更干净的网址 */
+function prettyUrl(url: string): string {
+ return url.replace(/\/+$/, '')
+}
+
+export default function NoticeBanner() {
+ const [notice, setNotice] = useState(null)
+
+ useEffect(() => {
+ let alive = true
+ const load = () => {
+ void fetchActiveNotice(__APP_VERSION__).then((n) => {
+ if (alive) setNotice(n)
+ })
+ }
+ load()
+ const timer = setInterval(load, REFRESH_MS)
+ return () => {
+ alive = false
+ clearInterval(timer)
+ }
+ }, [])
+
+ if (!notice) return null
+
+ const style = LEVEL_STYLES[notice.level] ?? LEVEL_STYLES.info
+ const Icon = style.Icon
+ const canDismiss = notice.dismissible !== false
+ const hasLink = isSafeHttpsUrl(notice.linkUrl)
+ const url = hasLink ? (notice.linkUrl as string) : ''
+ const linkText = notice.linkLabel || prettyUrl(url)
+
+ const handleDismiss = () => {
+ void dismissNotice(notice.id)
+ setNotice(null)
+ }
+
+ const handleOpen = () => {
+ if (hasLink) void shellOpen(url)
+ }
+
+ return (
+
+
+
+
+
{notice.title}
+
+ {(notice.body || hasLink) && (
+
+ {notice.body}
+ {hasLink && (
+ <>
+ {notice.body ? ' ' : ''}
+ {/* 链接自然融入正文,普通超链接样式;点击用系统浏览器打开 */}
+
+ >
+ )}
+
+ )}
+
+
+ {canDismiss && (
+
+ )}
+
+ )
+}
diff --git a/client/src/features/update/autoUpdate.ts b/client/src/features/update/autoUpdate.ts
index b777dfb..1902828 100644
--- a/client/src/features/update/autoUpdate.ts
+++ b/client/src/features/update/autoUpdate.ts
@@ -54,7 +54,39 @@ export function onAutoUpdateChange(cb: (state: AutoUpdateState) => void) {
return () => { listeners.delete(cb) }
}
-/** 手动触发一次检查更新(关于页“检查更新”按钮使用),与启动自动检查共用同一状态与并发锁 */
+/**
+ * 手动触发「检查并更新」(关于页“检查更新”按钮使用):
+ * 检查 → 发现新版本则自动下载 → 自动安装,一步到位,与开机自动更新同一套流程。
+ * 与启动自动检查共用并发锁,不会重复触发。
+ */
+export async function checkAndUpdateNow(): Promise {
+ if (inFlight) { await inFlight; return }
+ const task = (async () => {
+ setState({ phase: 'checking', error: null })
+ const info = await checkVersionUpdate(__APP_VERSION__)
+ setState({ phase: 'checked', versionInfo: info, checkedAt: Date.now() })
+
+ if (!info?.hasUpdate || !info.downloadUrl) return
+
+ setState({ phase: 'downloading', version: info.latestVersion || undefined, downloadPercent: 0 })
+ try {
+ const filePath = await bridge.downloadUpdate(info.downloadUrl)
+ setState({ phase: 'downloaded', downloadedFilePath: filePath, downloadPercent: 100 })
+
+ // 通知用户即将安装,给 3 秒缓冲
+ setState({ phase: 'installing', version: info.latestVersion || undefined })
+ await new Promise((r) => setTimeout(r, 3000))
+
+ await bridge.installDownloadedUpdate(filePath)
+ } catch (err) {
+ setState({ phase: 'checked', error: String(err) })
+ }
+ })()
+ inFlight = task.finally(() => { inFlight = null })
+ await inFlight
+}
+
+/** 手动触发一次检查更新(仅检查、不下载),与启动自动检查共用同一状态与并发锁 */
export async function checkNow(): Promise {
if (inFlight) {
await inFlight
diff --git a/client/src/pages/About.tsx b/client/src/pages/About.tsx
index e16c837..992d574 100644
--- a/client/src/pages/About.tsx
+++ b/client/src/pages/About.tsx
@@ -3,7 +3,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Github, Download, Loader2, CheckCircle } from 'lucide-react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
-import { getAutoUpdateState, onAutoUpdateChange, checkNow, downloadNow, installNow, type AutoUpdateState } from '@/features/update/autoUpdate'
+import { getAutoUpdateState, onAutoUpdateChange, checkAndUpdateNow, downloadNow, installNow, type AutoUpdateState } from '@/features/update/autoUpdate'
import { RELEASE_HIGHLIGHTS } from '@/features/update/releaseHighlights'
import appIcon from '@/assets/icon-128.png'
@@ -116,7 +116,7 @@ export default function About() {
)}
{/* 检查更新按钮 */}
{!downloading && !installing && !downloaded && (
-
+
+
{isNewUser && (
diff --git a/client/src/services/defaults.ts b/client/src/services/defaults.ts
index b7f8b4f..2d2356b 100644
--- a/client/src/services/defaults.ts
+++ b/client/src/services/defaults.ts
@@ -77,6 +77,9 @@ export const DEFAULTS: Record = {
// ── 引导 ──
onboardingVersion: '', // 已完成引导的版本号,空字符串 = 未完成
+
+ // ── 远程公告 ──
+ dismissedNoticeIds: [], // 已被用户关闭的公告 id 列表
}
/**
diff --git a/client/src/services/notice.ts b/client/src/services/notice.ts
new file mode 100644
index 0000000..f2f3808
--- /dev/null
+++ b/client/src/services/notice.ts
@@ -0,0 +1,133 @@
+// 远程公告服务 — 与更新机制完全解耦的服务端可控通知通道。
+//
+// 即使自动更新出问题、或不想让用户更新软件,也能通过后端下发一条公告,
+// 在 App 内提示用户(例如引导去官网/GitHub 手动下载新版)。
+//
+// 安全约束(远程内容视为不可信输入):
+// - 文案一律按纯文本渲染(React 默认转义),绝不当 HTML 注入。
+// - 链接只允许 https,且用系统浏览器打开,绝不导航 WebView 自身。
+
+import { getBackendBaseUrl } from './runtimeConfig'
+import { getSetting, setSetting } from './store'
+
+export type NoticeLevel = 'info' | 'warning' | 'critical'
+
+export interface RemoteNotice {
+ /** 唯一 id:用户关闭后按 id 记忆,不再重复弹 */
+ id: string
+ /** 级别,决定 banner 配色与图标 */
+ level: NoticeLevel
+ title: string
+ body?: string
+ /** 行动按钮链接(仅 https) */
+ linkUrl?: string
+ linkLabel?: string
+ /** 只对 >= minVersion 的客户端显示 */
+ minVersion?: string | null
+ /** 只对 <= maxVersion 的客户端显示(例如只提示旧版用户去手动更新) */
+ maxVersion?: string | null
+ /** 生效时间窗(ISO 字符串),可选 */
+ startAt?: string | null
+ endAt?: string | null
+ /** 是否允许关闭,默认 true */
+ dismissible?: boolean
+}
+
+const DISMISSED_KEY = 'dismissedNoticeIds'
+
+function compareVersions(a: string, b: string): number {
+ const pa = a.split('.').map(Number)
+ const pb = b.split('.').map(Number)
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
+ const d = (pa[i] || 0) - (pb[i] || 0)
+ if (d !== 0) return d
+ }
+ return 0
+}
+
+function isValidNotice(v: unknown): v is RemoteNotice {
+ if (!v || typeof v !== 'object') return false
+ const n = v as Record
+ const levelOk = n.level === 'info' || n.level === 'warning' || n.level === 'critical'
+ return typeof n.id === 'string' && n.id.length > 0
+ && typeof n.title === 'string' && n.title.length > 0
+ && levelOk
+}
+
+function matchesVersion(notice: RemoteNotice, current: string): boolean {
+ if (notice.minVersion && compareVersions(current, notice.minVersion) < 0) return false
+ if (notice.maxVersion && compareVersions(current, notice.maxVersion) > 0) return false
+ return true
+}
+
+function withinTimeWindow(notice: RemoteNotice, now = Date.now()): boolean {
+ if (notice.startAt) {
+ const t = Date.parse(notice.startAt)
+ if (!Number.isNaN(t) && now < t) return false
+ }
+ if (notice.endAt) {
+ const t = Date.parse(notice.endAt)
+ if (!Number.isNaN(t) && now > t) return false
+ }
+ return true
+}
+
+async function loadRaw(): Promise {
+ // 开发预览:dev 环境下可在控制台执行
+ // localStorage.setItem('__devNotice', JSON.stringify({ id:'t1', level:'warning', title:'测试公告', body:'内容…', linkUrl:'https://sayitapp.site', linkLabel:'前往官网' }))
+ // 刷新即可看到 banner 效果,无需后端。清除:localStorage.removeItem('__devNotice')
+ if (import.meta.env.DEV) {
+ try {
+ const dev = localStorage.getItem('__devNotice')
+ if (dev) return JSON.parse(dev)
+ } catch {
+ /* ignore */
+ }
+ }
+
+ const baseUrl = getBackendBaseUrl()
+ const resp = await fetch(`${baseUrl}/api/notice`, {
+ cache: 'no-store',
+ signal: AbortSignal.timeout(8000),
+ })
+ if (!resp.ok) return null
+ return await resp.json()
+}
+
+/** 拉取当前应展示的公告;无公告/不适用当前版本/不在时间窗/已被用户关闭 → 返回 null。 */
+export async function fetchActiveNotice(currentVersion: string): Promise {
+ try {
+ const raw = await loadRaw()
+ if (!isValidNotice(raw)) return null
+ const notice = raw
+ if (!matchesVersion(notice, currentVersion)) return null
+ if (!withinTimeWindow(notice)) return null
+ if (notice.dismissible !== false) {
+ const dismissed = await getSetting(DISMISSED_KEY, [])
+ if (Array.isArray(dismissed) && dismissed.includes(notice.id)) return null
+ }
+ return notice
+ } catch {
+ return null
+ }
+}
+
+/** 记住某条公告已被用户关闭(按 id)。 */
+export async function dismissNotice(id: string): Promise {
+ const dismissed = await getSetting(DISMISSED_KEY, [])
+ const list = Array.isArray(dismissed) ? dismissed : []
+ if (!list.includes(id)) {
+ // 只保留最近 50 条,避免无限增长
+ await setSetting(DISMISSED_KEY, [...list, id].slice(-50))
+ }
+}
+
+/** 校验是否为安全的 https 外链。 */
+export function isSafeHttpsUrl(url: string | undefined): url is string {
+ if (!url) return false
+ try {
+ return new URL(url).protocol === 'https:'
+ } catch {
+ return false
+ }
+}