diff --git a/docs/发布与更新.md b/docs/发布与更新.md new file mode 100644 index 0000000..f3677d9 --- /dev/null +++ b/docs/发布与更新.md @@ -0,0 +1,32 @@ +# 发布与更新(轻量版) + +应用内「检查更新」按钮(顶栏右上角)走**轻量版本检查**:查询 Gitea 最新 release,与当前版本号比较,有新版则弹窗显示发布说明并提供「前往下载」。不做签名校验、不在应用内静默安装——用户下载安装包后手动运行覆盖安装。 + +## 工作原理 + +- 检查接口(硬编码于 `src-tauri/src/main.rs` 的 `GITEA_LATEST_RELEASE_API`): + `http://gitea.xxhhcty.xyz:8080/api/v1/repos/zcdsj/tcjs/releases/latest` +- 当前版本取自 `src-tauri/tauri.conf.json` 的 `version`(`app.package_info().version`)。 +- 比较规则:release 的 `tag_name`(去掉前导 `v`/`V`)按点分十进制与当前版本比大小。 +- 「前往下载」打开 release 资产里第一个 `.exe`/`.msi`,没有资产则打开 release 页面。 + +## 发布一个新版本 + +1. **改版本号**:编辑 `src-tauri/tauri.conf.json` 的 `version`(如 `0.1.0` → `0.2.0`)。建议同步 `package.json`、`src-tauri/Cargo.toml`,保持一致。 +2. **构建安装包**:`npm run tauri:build` + 产物在 `src-tauri/target/release/bundle/`: + - `nsis/*.exe`(推荐发布这个) + - `msi/*.msi` +3. **在 Gitea 建 release**: + - 仓库 `zcdsj/tcjs` → Releases → New Release。 + - **Tag** 用 `v0.2.0`(与第 1 步版本一致;检查时会去掉前导 `v`)。 + - 把第 2 步的 `.exe`(和/或 `.msi`)作为**附件**上传。 + - release 正文(body)会作为「发布说明」显示在更新弹窗里,写更新内容即可。 + - 发布(标记为最新 release)。 +4. 旧版本用户点「检查更新」→ 发现 `v0.2.0` 比本地新 → 弹窗 → 「前往下载」→ 下载安装包覆盖安装。 + +## 备注 / 后续可选 + +- **HTTP 站点**:当前 ureq 关闭了 TLS(Gitea 走 http)。若 Gitea 迁到 https,需要给 `src-tauri/Cargo.toml` 的 `ureq` 打开 `tls`(rustls)特性。 +- **改成静默自动更新**:若以后要"应用内下载+安装+重启",可切换到官方 `tauri-plugin-updater`(需生成签名密钥、构建时签名、每次发布额外上传 `latest.json` 与 `.sig`)。本版刻意不做以保持轻量。 +- **API 私有仓库**:若仓库非公开,Gitea API 需要 token;当前实现按公开仓库匿名访问。 diff --git a/package.json b/package.json index 470b57c..c4288ff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ceramic-radioactivity-app", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite --config ui/vite.config.ts", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7a3d147..1bce673 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -285,7 +285,7 @@ dependencies = [ [[package]] name = "ceramic-radioactivity-tauri" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ceramic-radioactivity", "rusqlite", @@ -295,6 +295,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "ureq", ] [[package]] @@ -3763,6 +3764,31 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -3794,6 +3820,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 41abf88..485a55e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ceramic-radioactivity-tauri" -version = "0.1.0" +version = "0.2.0" edition = "2021" [build-dependencies] @@ -14,3 +14,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1.0.150" tauri = { version = "2", features = [] } tauri-plugin-dialog = "2.7.1" +ureq = { version = "3.3.0", default-features = false } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 47e42aa..87286fb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -78,6 +78,110 @@ fn export_excel(app: tauri::AppHandle, db: State, id: i64) -> Result, +} + +/// 查询 Gitea 最新 release,与当前版本比较(轻量版本检查,不做签名校验)。 +#[tauri::command] +fn check_update(app: tauri::AppHandle) -> Result { + let current = app.package_info().version.to_string(); + + let mut resp = match ureq::get(GITEA_LATEST_RELEASE_API).call() { + Ok(resp) => resp, + // 仓库尚无任何发布:视为已是最新,而非报错。 + Err(ureq::Error::StatusCode(404)) => return Ok(up_to_date(current)), + Err(ureq::Error::StatusCode(code)) => return Err(format!("Gitea 返回状态 {code}")), + Err(e) => return Err(format!("请求 Gitea 失败:{e}")), + }; + match resp.status().as_u16() { + 200 => {} + 404 => return Ok(up_to_date(current)), + code => return Err(format!("Gitea 返回状态 {code}")), + } + let body = resp.body_mut().read_to_string().map_err(|e| e.to_string())?; + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; + + let latest_version = json["tag_name"] + .as_str() + .unwrap_or_default() + .trim_start_matches(['v', 'V']) + .to_string(); + let notes = json["body"].as_str().unwrap_or_default().to_string(); + let release_url = json["html_url"].as_str().unwrap_or_default().to_string(); + let download_url = json["assets"].as_array().and_then(|assets| { + assets.iter().find_map(|asset| { + let name = asset["name"].as_str().unwrap_or_default().to_ascii_lowercase(); + if name.ends_with(".exe") || name.ends_with(".msi") { + asset["browser_download_url"].as_str().map(str::to_string) + } else { + None + } + }) + }); + + Ok(UpdateInfo { + has_update: is_newer(&latest_version, ¤t), + current_version: current, + latest_version, + notes, + release_url, + download_url, + }) +} + +/// 构造「已是最新」结果(无更新)。 +fn up_to_date(current: String) -> UpdateInfo { + UpdateInfo { + latest_version: current.clone(), + current_version: current, + has_update: false, + notes: String::new(), + release_url: String::new(), + download_url: None, + } +} + +/// 用默认浏览器打开 URL(跳转到 release 下载页 / 安装包)。 +#[tauri::command] +fn open_external(url: String) -> Result<(), String> { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", "start", "", &url]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW,避免闪现控制台 + } + command.spawn().map_err(|e| e.to_string())?; + Ok(()) +} + +/// 比较点分十进制版本:a 是否比 b 新(缺位按 0)。 +fn is_newer(a: &str, b: &str) -> bool { + let parse = |s: &str| -> Vec { + s.split('.').map(|p| p.trim().parse().unwrap_or(0)).collect() + }; + let (va, vb) = (parse(a), parse(b)); + for i in 0..va.len().max(vb.len()) { + let x = va.get(i).copied().unwrap_or(0); + let y = vb.get(i).copied().unwrap_or(0); + if x != y { + return x > y; + } + } + false +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -95,8 +199,26 @@ fn main() { list_records, get_record, delete_record, - export_excel + export_excel, + check_update, + open_external ]) .run(tauri::generate_context!()) .expect("failed to run Tauri application"); } + +#[cfg(test)] +mod tests { + use super::is_newer; + + #[test] + fn version_comparison() { + assert!(is_newer("0.2.0", "0.1.0")); + assert!(is_newer("1.0.0", "0.9.9")); + assert!(is_newer("0.1.1", "0.1.0")); + assert!(is_newer("0.2", "0.1.5")); // 缺位按 0 + assert!(!is_newer("0.1.0", "0.1.0")); + assert!(!is_newer("0.1.0", "0.2.0")); + assert!(!is_newer("", "0.1.0")); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9fd96b6..eeb3d7f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Building Material Radioactivity", - "version": "0.1.0", + "version": "0.2.0", "identifier": "com.tcjs.ceramic-radioactivity", "build": { "beforeDevCommand": "npm run dev", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 90e8082..29c3d88 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,7 @@ import { App as AntApp, ConfigProvider, Tabs } from "antd"; import zhCN from "antd/locale/zh_CN"; import { CalculatorPanel } from "./CalculatorPanel"; import { HistoryTab } from "./HistoryTab"; +import { UpdateButton } from "./UpdateButton"; import type { SampleInput } from "./types"; function Brand() { @@ -47,7 +48,7 @@ function App() { activeKey={activeTab} onChange={setActiveTab} className="main-tabs" - tabBarExtraContent={{ left: }} + tabBarExtraContent={{ left: , right: }} items={[ { key: "calc", diff --git a/ui/src/UpdateButton.tsx b/ui/src/UpdateButton.tsx new file mode 100644 index 0000000..7b8ea62 --- /dev/null +++ b/ui/src/UpdateButton.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { App, Button } from "antd"; +import type { UpdateInfo } from "./types"; + +/** 手动「检查更新」:查询 Gitea 最新 release,有新版则提示并可跳转下载。 */ +function UpdateButton() { + const { message, modal } = App.useApp(); + const [loading, setLoading] = useState(false); + + const check = async () => { + setLoading(true); + try { + const info = await invoke("check_update"); + if (info.has_update) { + modal.confirm({ + title: `发现新版本 v${info.latest_version}`, + width: 540, + okText: "前往下载", + cancelText: "稍后", + content: ( +
+

+ 当前 v{info.current_version} → 最新 v{info.latest_version} +

+ {info.notes ?
{info.notes}
: null} +
+ ), + onOk: () => + invoke("open_external", { url: info.download_url || info.release_url }).catch((err) => + message.error(`打开下载页失败:${String(err)}`) + ) + }); + } else { + message.success(`已是最新版本 v${info.current_version}`); + } + } catch (err) { + message.error(`检查更新失败:${String(err)}`); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} + +export { UpdateButton }; diff --git a/ui/src/styles.css b/ui/src/styles.css index 07b206d..449a19b 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -83,6 +83,39 @@ body { white-space: nowrap; } +/* 顶栏右侧「检查更新」按钮:navy 底上的浅色描边按钮 */ +.update-btn { + background: transparent; + border-color: rgba(255, 255, 255, 0.4); + color: #e8edf5; +} + +.update-btn:hover, +.update-btn:focus { + background: transparent !important; + border-color: #f2b50c !important; + color: #f2b50c !important; +} + +.update-versions { + margin: 4px 0 10px; + color: #69727c; +} + +.update-notes { + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 10px 12px; + background: #f6f8fa; + border: 1px solid #e9edf1; + border-radius: 8px; + font-size: 13px; + line-height: 1.5; +} + .results-toolbar { display: flex; gap: 8px; diff --git a/ui/src/types.ts b/ui/src/types.ts index e5f026b..5098748 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -109,6 +109,15 @@ export type RecordDetail = { result: CalculationResult; }; +export type UpdateInfo = { + current_version: string; + latest_version: string; + has_update: boolean; + notes: string; + release_url: string; + download_url: string | null; +}; + export type RecordFilter = { sample_id: string | null; material_type: MaterialType | null;