Add lightweight update check via Gitea releases; bump to 0.2.0

- check_update command: query Gitea releases/latest, compare versions
  (404 = no release yet -> treated as up to date), return notes + asset URL
- open_external command to open the download page / installer
- "检查更新" button in the top bar (manual check only)
- docs/发布与更新.md: release & update workflow
- bump app version to 0.2.0 (tauri.conf / package.json / Cargo)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-11 16:33:57 +08:00
parent fd2dfbdfb8
commit 8320432789
10 changed files with 287 additions and 6 deletions

32
docs/发布与更新.md Normal file
View File

@ -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 关闭了 TLSGitea 走 http。若 Gitea 迁到 https需要给 `src-tauri/Cargo.toml``ureq` 打开 `tls`rustls特性。
- **改成静默自动更新**:若以后要"应用内下载+安装+重启",可切换到官方 `tauri-plugin-updater`(需生成签名密钥、构建时签名、每次发布额外上传 `latest.json``.sig`)。本版刻意不做以保持轻量。
- **API 私有仓库**若仓库非公开Gitea API 需要 token当前实现按公开仓库匿名访问。

View File

@ -1,7 +1,7 @@
{ {
"name": "ceramic-radioactivity-app", "name": "ceramic-radioactivity-app",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --config ui/vite.config.ts", "dev": "vite --config ui/vite.config.ts",

34
src-tauri/Cargo.lock generated
View File

@ -285,7 +285,7 @@ dependencies = [
[[package]] [[package]]
name = "ceramic-radioactivity-tauri" name = "ceramic-radioactivity-tauri"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"ceramic-radioactivity", "ceramic-radioactivity",
"rusqlite", "rusqlite",
@ -295,6 +295,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"ureq",
] ]
[[package]] [[package]]
@ -3763,6 +3764,31 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -3794,6 +3820,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "ceramic-radioactivity-tauri" name = "ceramic-radioactivity-tauri"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[build-dependencies] [build-dependencies]
@ -14,3 +14,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1.0.150" serde_json = "1.0.150"
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
ureq = { version = "3.3.0", default-features = false }

View File

@ -78,6 +78,110 @@ fn export_excel(app: tauri::AppHandle, db: State<Db>, id: i64) -> Result<Option<
excel::write_to_path(&path, &detail).map(Some) excel::write_to_path(&path, &detail).map(Some)
} }
/// Gitea「最新 release」API。如迁移到 https 站点,需为 ureq 启用 tls 特性。
const GITEA_LATEST_RELEASE_API: &str =
"http://gitea.xxhhcty.xyz:8080/api/v1/repos/zcdsj/tcjs/releases/latest";
#[derive(serde::Serialize)]
struct UpdateInfo {
current_version: String,
latest_version: String,
has_update: bool,
notes: String,
release_url: String,
download_url: Option<String>,
}
/// 查询 Gitea 最新 release与当前版本比较轻量版本检查不做签名校验
#[tauri::command]
fn check_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
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, &current),
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<u64> {
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() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@ -95,8 +199,26 @@ fn main() {
list_records, list_records,
get_record, get_record,
delete_record, delete_record,
export_excel export_excel,
check_update,
open_external
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("failed to run Tauri application"); .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"));
}
}

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Building Material Radioactivity", "productName": "Building Material Radioactivity",
"version": "0.1.0", "version": "0.2.0",
"identifier": "com.tcjs.ceramic-radioactivity", "identifier": "com.tcjs.ceramic-radioactivity",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@ -3,6 +3,7 @@ import { App as AntApp, ConfigProvider, Tabs } from "antd";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
import { CalculatorPanel } from "./CalculatorPanel"; import { CalculatorPanel } from "./CalculatorPanel";
import { HistoryTab } from "./HistoryTab"; import { HistoryTab } from "./HistoryTab";
import { UpdateButton } from "./UpdateButton";
import type { SampleInput } from "./types"; import type { SampleInput } from "./types";
function Brand() { function Brand() {
@ -47,7 +48,7 @@ function App() {
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
className="main-tabs" className="main-tabs"
tabBarExtraContent={{ left: <Brand /> }} tabBarExtraContent={{ left: <Brand />, right: <UpdateButton /> }}
items={[ items={[
{ {
key: "calc", key: "calc",

51
ui/src/UpdateButton.tsx Normal file
View File

@ -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<UpdateInfo>("check_update");
if (info.has_update) {
modal.confirm({
title: `发现新版本 v${info.latest_version}`,
width: 540,
okText: "前往下载",
cancelText: "稍后",
content: (
<div>
<p className="update-versions">
v{info.current_version} <strong>v{info.latest_version}</strong>
</p>
{info.notes ? <pre className="update-notes">{info.notes}</pre> : null}
</div>
),
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 (
<Button size="small" className="update-btn" loading={loading} onClick={() => void check()}>
</Button>
);
}
export { UpdateButton };

View File

@ -83,6 +83,39 @@ body {
white-space: nowrap; 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 { .results-toolbar {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@ -109,6 +109,15 @@ export type RecordDetail = {
result: CalculationResult; 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 = { export type RecordFilter = {
sample_id: string | null; sample_id: string | null;
material_type: MaterialType | null; material_type: MaterialType | null;