Compare commits

...

4 Commits

Author SHA1 Message Date
Yuhang Wu ba5dee151e chore: 发布v0.3.2版本,更新变更日志
本次版本新增深色主题支持与可视化更新弹窗,优化了UI样式与发布流程,新增相关依赖插件并修复主题同步逻辑
2026-08-25 12:12:38 +08:00
Yuhang Wu 72b2d5bdfe refactor: 重构更新逻辑,新增可视化更新弹窗
1.  新增tauri-plugin-prevent-default插件,区分开发/生产模式拦截默认事件
2.  重构updater工具函数,拆分检查与更新流程,新增进度状态管理
3.  新增UpdateModal更新弹窗组件,支持markdown格式更新说明与下载进度展示
4.  全局注册新增的naive-ui组件与自定义更新弹窗
5.  新增markdown-it依赖
6.  替换原有弹窗逻辑,将更新流程交由弹窗组件处理
2026-08-25 11:05:09 +08:00
Yuhang Wu c45e8b0d43 style: 调整UI样式细节并统一圆角变量使用
1. 注释掉全局和布局的边框圆角、边框样式
2. 给标题栏圆点按钮统一使用radius-sm变量
3. 为卡片项添加右侧外边距
2026-08-25 10:04:21 +08:00
Yuhang Wu 3ce9735cda feat: 添加深色主题支持并优化外观设置
1. 新增全局深色主题样式变量,适配明暗两种主题
2. 配置naive-ui主题切换,跟随系统或用户设置
3. 修复外观设置页面主题同步逻辑
4. 调整主布局样式,使用css变量适配主题和标题栏高度
2026-08-25 09:31:42 +08:00
19 changed files with 574 additions and 57 deletions

View File

@ -4,6 +4,26 @@
--- ---
## v0.3.22026-08-25
### 🎉 新版本发布!
### ✨ 新增功能
- **深色主题支持**:新增全局深色主题样式变量,适配明暗两种主题;配置 naive-ui 主题跟随系统或用户设置切换,并修复外观设置页面主题同步逻辑。
- **可视化更新弹窗**:新增 `UpdateModal` 更新弹窗组件,支持 Markdown 格式更新说明与下载进度展示;重构 updater 工具,拆分检查与更新流程,新增进度状态管理,更新流程交由弹窗组件统一处理。
### 🎨 优化
- 调整主布局样式,使用 CSS 变量适配主题切换与标题栏高度。
- 统一标题栏圆角变量使用(`radius-sm`),优化卡片间距等 UI 细节。
### 🚀 工程
- 新增 `tauri-plugin-prevent-default` 插件,区分开发/生产模式拦截默认事件。
- 优化发布流程release 工作流自动从 CHANGELOG.md 提取当前 tag 对应版本更新日志作为 Release body。
---
## v0.3.02026-08-24 16:52 ## v0.3.02026-08-24 16:52
### 🎉 新版本发布! ### 🎉 新版本发布!

View File

@ -14,7 +14,7 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
border-radius: 10px; /* border-radius: 10px; */
} }
</style> </style>
</head> </head>

View File

@ -1,7 +1,7 @@
{ {
"name": "stealthreader", "name": "stealthreader",
"private": true, "private": true,
"version": "0.3.1", "version": "0.3.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -18,6 +18,7 @@
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"axios": "^1.19.0", "axios": "^1.19.0",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"markdown-it": "^15.0.0",
"naive-ui": "^2.45.0", "naive-ui": "^2.45.0",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",

View File

@ -32,6 +32,9 @@ importers:
dayjs: dayjs:
specifier: ^1.11.21 specifier: ^1.11.21
version: 1.11.23 version: 1.11.23
markdown-it:
specifier: ^15.0.0
version: 15.0.0
naive-ui: naive-ui:
specifier: ^2.45.0 specifier: ^2.45.0
version: 2.45.0(vue@3.5.41(typescript@5.6.3)) version: 2.45.0(vue@3.5.41(typescript@5.6.3))
@ -586,6 +589,9 @@ packages:
alien-signals@3.2.1: alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
argparse@3.0.0:
resolution: {integrity: sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==}
ast-kit@2.1.2: ast-kit@2.1.2:
resolution: {integrity: sha512-cl76xfBQM6pztbrFWRnxbrDm9EOqDr1BF6+qQnnDZG2Co2LjyUktkN9GTJfBAfdae+DbT2nJf2nCGAdDDN7W2g==} resolution: {integrity: sha512-cl76xfBQM6pztbrFWRnxbrDm9EOqDr1BF6+qQnnDZG2Co2LjyUktkN9GTJfBAfdae+DbT2nJf2nCGAdDDN7W2g==}
engines: {node: '>=20.18.0'} engines: {node: '>=20.18.0'}
@ -679,6 +685,10 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -874,6 +884,9 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
linkify-it@6.1.0:
resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
local-pkg@1.2.1: local-pkg@1.2.1:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -894,10 +907,17 @@ packages:
magic-string@1.2.0: magic-string@1.2.0:
resolution: {integrity: sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==} resolution: {integrity: sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==}
markdown-it@15.0.0:
resolution: {integrity: sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
mdurl@2.1.0:
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
mime-db@1.52.0: mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -990,6 +1010,10 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'} engines: {node: '>=10'}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
quansync@0.2.11: quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@ -1032,6 +1056,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@3.0.0:
resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
ufo@1.6.4: ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
@ -1616,6 +1643,8 @@ snapshots:
alien-signals@3.2.1: {} alien-signals@3.2.1: {}
argparse@3.0.0: {}
ast-kit@2.1.2: ast-kit@2.1.2:
dependencies: dependencies:
'@babel/parser': 7.29.8 '@babel/parser': 7.29.8
@ -1706,6 +1735,8 @@ snapshots:
entities@7.0.1: {} entities@7.0.1: {}
entities@8.0.0: {}
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@ -1860,6 +1891,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0
linkify-it@6.1.0:
dependencies:
uc.micro: 3.0.0
local-pkg@1.2.1: local-pkg@1.2.1:
dependencies: dependencies:
mlly: 1.8.2 mlly: 1.8.2
@ -1882,8 +1917,19 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
markdown-it@15.0.0:
dependencies:
argparse: 3.0.0
entities: 8.0.0
linkify-it: 6.1.0
mdurl: 2.1.0
punycode.js: 2.3.1
uc.micro: 3.0.0
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mdurl@2.1.0: {}
mime-db@1.52.0: {} mime-db@1.52.0: {}
mime-types@2.1.35: mime-types@2.1.35:
@ -1976,6 +2022,8 @@ snapshots:
proxy-from-env@2.1.0: {} proxy-from-env@2.1.0: {}
punycode.js@2.3.1: {}
quansync@0.2.11: {} quansync@0.2.11: {}
readdirp@5.1.1: {} readdirp@5.1.1: {}
@ -2027,6 +2075,8 @@ snapshots:
typescript@5.6.3: {} typescript@5.6.3: {}
uc.micro@3.0.0: {}
ufo@1.6.4: {} ufo@1.6.4: {}
undici-types@8.3.0: {} undici-types@8.3.0: {}

45
src-tauri/Cargo.lock generated
View File

@ -2052,6 +2052,15 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -4076,6 +4085,7 @@ dependencies = [
"tauri-helper", "tauri-helper",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-prevent-default",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
@ -4122,6 +4132,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -4480,6 +4511,20 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-prevent-default"
version = "5.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "674bac5c9831f4ca7ae42840768ac968b76353dcbe30bb0aad149c56f0be1389"
dependencies = [
"bitflags 2.13.1",
"itertools",
"serde",
"strum",
"tauri",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "tauri-plugin-process" name = "tauri-plugin-process"
version = "2.3.1" version = "2.3.1"

View File

@ -41,3 +41,4 @@ regex = "1.13.1"
chardetng = "1.0.0" chardetng = "1.0.0"
tauri-plugin-updater = "2.10.1" tauri-plugin-updater = "2.10.1"
tauri-plugin-process = "2.3.1" tauri-plugin-process = "2.3.1"
tauri-plugin-prevent-default = "5.0.2"

View File

@ -6,6 +6,8 @@ use commands::book::*;
use services::db::init_pool; use services::db::init_pool;
use tauri::Manager; use tauri::Manager;
use tauri_plugin_prevent_default::Flags;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@ -13,6 +15,7 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) // 对话框插件 .plugin(tauri_plugin_dialog::init()) // 对话框插件
.plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件 .plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件
.plugin(tauri_plugin_process::init()) // 进程插件(更新后重启) .plugin(tauri_plugin_process::init()) // 进程插件(更新后重启)
.plugin(prevent_default())
.setup(|app| { .setup(|app| {
let pool = tauri::async_runtime::block_on(async { let pool = tauri::async_runtime::block_on(async {
init_pool(app.handle()) init_pool(app.handle())
@ -28,3 +31,15 @@ pub fn run() {
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
// 开发模式: 保留DevTools和Reload
#[cfg(debug_assertions)]
fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri_plugin_prevent_default::Builder::new().with_flags(Flags::all().difference(Flags::DEV_TOOLS | Flags::RELOAD)).build()
}
// 生产模式: 阻止所有默认事件
#[cfg(not(debug_assertions))]
fn prevent_default() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri_plugin_prevent_default::init()
}

View File

@ -1,10 +1,13 @@
<template> <template>
<n-config-provider :locale="zhCN" :data-locale="dateZhCN"> <n-config-provider :locale="zhCN" :data-locale="dateZhCN" :theme="isDark ? darkTheme : null">
<n-message-provider> <n-message-provider>
<n-notification-provider> <n-notification-provider>
<n-dialog-provider> <n-dialog-provider>
<n-modal-provider>
<GlobalLoading /> <GlobalLoading />
<UpdateModal />
<RouterView /> <RouterView />
</n-modal-provider>
</n-dialog-provider> </n-dialog-provider>
</n-notification-provider> </n-notification-provider>
</n-message-provider> </n-message-provider>
@ -12,15 +15,38 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { zhCN, dateZhCN } from "naive-ui" import { zhCN, dateZhCN, darkTheme } from "naive-ui"
import GlobalLoading from "@/components/GlobalLoading.vue" import GlobalLoading from "@/components/GlobalLoading.vue"
import { checkAndUpdate } from "@/utils/updater" import UpdateModal from "@/components/UpdateModal.vue"
import { checkAndPrompt } from "@/utils/updater"
import { useSettingStore } from "@/stores/setting"
const settingStore = useSettingStore()
const media = window.matchMedia("(prefers-color-scheme: dark)")
const systemDark = ref(media.matches)
const themeMode = computed(() => settingStore.appearance.theme)
const isDark = computed(() => {
return themeMode.value === "dark" || (themeMode.value === "system" && systemDark.value)
})
watchEffect(() => {
document.documentElement.setAttribute("data-theme", isDark.value ? "dark" : "light")
})
const onSystemThemeChange = (e: MediaQueryListEvent) => {
systemDark.value = e.matches
}
onMounted(() => { onMounted(() => {
checkAndUpdate() checkAndPrompt()
media.addEventListener("change", onSystemThemeChange)
})
onUnmounted(() => {
media.removeEventListener("change", onSystemThemeChange)
}) })
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@ -32,3 +32,20 @@
--set-item-title-size: 14px; --set-item-title-size: 14px;
--set-item-desc-size: 12px; --set-item-desc-size: 12px;
} }
/* 深色主题:通过根节点 data-theme="dark" 覆盖同名变量 */
:root[data-theme="dark"] {
--color-window-bg: #1b1c20;
--color-surface: #26272e;
--color-surface-hover: #30323b;
--color-text-primary: #f0f1f5;
--color-text-secondary: #9aa0ab;
--color-text-invert: #1d1d1f;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-hover: rgba(102, 126, 234, 0.5);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-card-hover: 0 10px 28px rgba(0, 0, 0, 0.5);
--shadow-focus: 0 4px 16px rgba(102, 126, 234, 0.3);
}

View File

@ -0,0 +1,224 @@
<template>
<n-modal :show="showUpdateModal" preset="card" :title="modalTitle" :bordered="false" :closable="!busy"
:mask-closable="false" :close-on-esc="false" style="width: 430px" @close="cancelUpdate">
<template #header-extra>
<span v-if="updateInfo" class="update-current">当前 v{{ updateInfo.currentVersion }}</span>
</template>
<div class="update-body">
<n-scrollbar class="update-scroll">
<div v-if="renderedBody" class="markdown-body" v-html="renderedBody" />
<n-empty v-else description="暂无更新说明" />
</n-scrollbar>
</div>
<div v-if="downloading || installing" class="update-progress">
<n-progress type="line" :percentage="progress" :processing="downloading" indicator-placement="inside"
:height="18" />
<div class="progress-hint">{{ progressHint }}</div>
</div>
<template #footer>
<div class="update-footer">
<n-button quaternary :disabled="busy" @click="skipVersion">跳过此版本</n-button>
<div class="update-footer-right">
<n-button v-if="!busy" @click="cancelUpdate">取消</n-button>
<n-button type="primary" :loading="downloading" @click="startUpdate">
{{ installing ? "安装中..." : downloading ? "下载中..." : "立即更新" }}
</n-button>
</div>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed } from "vue"
import MarkdownIt from "markdown-it"
import {
showUpdateModal,
updateInfo,
downloading,
installing,
progress,
downloadedBytes,
totalBytes,
formatBytes,
startUpdate,
skipVersion,
cancelUpdate,
} from "@/utils/updater"
const md = new MarkdownIt({ html: false, linkify: true })
const busy = computed(() => downloading.value || installing.value)
const modalTitle = computed(() =>
updateInfo.value ? `发现新版本 v${updateInfo.value.version}` : "发现新版本"
)
const renderedBody = computed(() => md.render(updateInfo.value?.body || ""))
const progressHint = computed(() => {
if (installing.value) return "正在安装更新,即将重启应用..."
if (downloading.value) {
if (totalBytes.value > 0) {
return `正在下载更新 ${progress.value}% · ${formatBytes(downloadedBytes.value)} / ${formatBytes(totalBytes.value)}`
}
return `正在下载更新... ${formatBytes(downloadedBytes.value)}`
}
return ""
})
</script>
<style lang="scss" scoped>
.update-current {
font-size: 12px;
color: var(--color-text-secondary);
}
.update-body {
.update-scroll {
height: 340px;
}
}
.markdown-body {
padding-right: 8px;
font-size: 13px;
line-height: 1.7;
color: var(--color-text-primary);
word-break: break-word;
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin: 14px 0 8px;
font-weight: 600;
line-height: 1.4;
color: var(--color-text-primary);
}
:deep(h1) {
font-size: 18px;
}
:deep(h2) {
font-size: 16px;
}
:deep(h3) {
font-size: 14px;
}
:deep(h4),
:deep(h5),
:deep(h6) {
font-size: 13px;
}
:deep(p) {
margin: 6px 0;
}
:deep(ul),
:deep(ol) {
margin: 6px 0;
padding-left: 20px;
}
:deep(li) {
margin: 2px 0;
}
:deep(strong) {
font-weight: 600;
}
:deep(code) {
padding: 1px 5px;
border-radius: 4px;
background: var(--color-surface-hover);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
:deep(pre) {
margin: 8px 0;
padding: 10px 12px;
border-radius: 6px;
background: var(--color-surface-hover);
overflow-x: auto;
code {
padding: 0;
background: transparent;
}
}
:deep(a) {
color: var(--color-accent);
text-decoration: none;
}
:deep(blockquote) {
margin: 8px 0;
padding: 4px 12px;
border-left: 3px solid var(--color-border-hover);
color: var(--color-text-secondary);
}
:deep(hr) {
border: none;
border-top: 1px solid var(--color-border);
margin: 10px 0;
}
:deep(table) {
width: 100%;
margin: 8px 0;
border-collapse: collapse;
font-size: 12px;
}
:deep(th),
:deep(td) {
padding: 6px 10px;
border: 1px solid var(--color-border);
text-align: left;
}
:deep(th) {
background: var(--color-surface-hover);
font-weight: 600;
}
}
.update-progress {
margin-top: 12px;
}
.progress-hint {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-secondary);
text-align: center;
}
.update-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.update-footer-right {
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@ -23,9 +23,13 @@ const isMacos = navigator.userAgent.includes("Mac OS X")
.container { .container {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: rgba(255, 255, 255, 1); background: var(--color-window-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2); // border: 1px solid var(--color-border);
:deep(.n-layout) {
background: transparent;
}
} }
.header { .header {
@ -35,6 +39,6 @@ const isMacos = navigator.userAgent.includes("Mac OS X")
.content { .content {
background: transparent; background: transparent;
height: calc(100vh - 40px); height: calc(100vh - var(--titlebar-height));
} }
</style> </style>

View File

@ -53,7 +53,7 @@ function close() {
width: 12px; width: 12px;
height: 12px; height: 12px;
padding: 0; padding: 0;
border-radius: 50%; border-radius: var(--radius-sm);
border: 0.5px solid rgba(0, 0, 0, 0.15); border: 0.5px solid rgba(0, 0, 0, 0.15);
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -51,7 +51,7 @@ function close() {
.dot { .dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: var(--radius-sm);
background: var(--color-accent-gradient); background: var(--color-accent-gradient);
} }
} }

View File

@ -36,9 +36,11 @@ declare module 'vue' {
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader'] NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
NMessageProvider: typeof import('naive-ui')['NMessageProvider'] NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal'] NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPagination: typeof import('naive-ui')['NPagination'] NPagination: typeof import('naive-ui')['NPagination']
NPopover: typeof import('naive-ui')['NPopover'] NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NRadioButton: typeof import('naive-ui')['NRadioButton'] NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']
@ -47,5 +49,6 @@ declare module 'vue' {
NSwitch: typeof import('naive-ui')['NSwitch'] NSwitch: typeof import('naive-ui')['NSwitch']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
UpdateModal: typeof import('./../components/UpdateModal.vue')['default']
} }
} }

23
src/types/markdown-it.d.ts vendored Normal file
View File

@ -0,0 +1,23 @@
declare module "markdown-it" {
interface MarkdownItOptions {
html?: boolean
xhtmlOut?: boolean
breaks?: boolean
langPrefix?: string
linkify?: boolean
typographer?: boolean
quotes?: string | string[]
highlight?: (str: string, lang: string, attrs: string) => string
}
class MarkdownIt {
constructor(options?: MarkdownItOptions)
set(options: MarkdownItOptions): this
render(src: string, env?: unknown): string
parse(src: string, env?: unknown): unknown[]
options: MarkdownItOptions
renderer: unknown
}
export default MarkdownIt
}

View File

@ -1,54 +1,137 @@
import { check, type Update } from "@tauri-apps/plugin-updater" import { ref } from "vue"
import { ask } from "@tauri-apps/plugin-dialog" import { check, type Update, type DownloadEvent } from "@tauri-apps/plugin-updater"
import { relaunch } from "@tauri-apps/plugin-process" import { relaunch } from "@tauri-apps/plugin-process"
/** export type CheckPromptResult = "none" | "error" | "prompting"
*
* - updated
* - none
* - cancelled
* - error
*/
export type CheckUpdateResult = "updated" | "none" | "cancelled" | "error"
/** // 当前待更新的 Update 对象(非响应式,避免代理破坏资源句柄)
* Update null let currentUpdate: Update | null = null
*/
export async function checkUpdate(): Promise<Update | null> { export const checking = ref(false)
return await check() export const downloading = ref(false)
export const installing = ref(false)
export const showUpdateModal = ref(false)
export const progress = ref(0)
export const downloadedBytes = ref(0)
export const totalBytes = ref(0)
export const updateInfo = ref<{
currentVersion: string
version: string
date?: string
body?: string
} | null>(null)
const SKIPPED_KEY = "skippedUpdateVersion"
function getSkippedVersion(): string | null {
try {
return localStorage.getItem(SKIPPED_KEY)
} catch {
return null
}
}
function setSkippedVersion(version: string) {
try {
localStorage.setItem(SKIPPED_KEY, version)
} catch {
// ignore
}
}
export function formatBytes(bytes: number): string {
if (bytes <= 0) return "0 B"
const units = ["B", "KB", "MB", "GB"]
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`
} }
/** /**
* *
* @param update * @returns none=/error=prompting=
* @returns true=false=
*/ */
export async function promptAndInstall(update: Update): Promise<boolean> { export async function checkAndPrompt(): Promise<CheckPromptResult> {
const yes = await ask( if (checking.value || downloading.value || installing.value) return "none"
`发现新版本 ${update.version},是否现在更新?\n\n${update.body || ""}`, checking.value = true
{ title: "发现更新", kind: "info" }
)
if (!yes) return false
await update.downloadAndInstall()
await relaunch()
return true
}
/**
*
*
* @returns
*/
export async function checkAndUpdate(): Promise<CheckUpdateResult> {
try { try {
const update = await check() const update = await check()
if (!update) return "none" if (!update) return "none"
if (getSkippedVersion() === update.version) return "none"
const installed = await promptAndInstall(update) currentUpdate = update
return installed ? "updated" : "cancelled" updateInfo.value = {
currentVersion: update.currentVersion,
version: update.version,
date: update.date,
body: update.body,
}
progress.value = 0
downloadedBytes.value = 0
totalBytes.value = 0
showUpdateModal.value = true
return "prompting"
} catch (error) { } catch (error) {
console.error("检查更新失败:", error) console.error("检查更新失败:", error)
return "error" return "error"
} finally {
checking.value = false
} }
} }
/**
*
*/
export async function startUpdate(): Promise<void> {
if (!currentUpdate || downloading.value || installing.value) return
downloading.value = true
progress.value = 0
downloadedBytes.value = 0
totalBytes.value = 0
try {
await currentUpdate.download((event: DownloadEvent) => {
if (event.event === "Started") {
totalBytes.value = event.data.contentLength ?? 0
} else if (event.event === "Progress") {
downloadedBytes.value += event.data.chunkLength
if (totalBytes.value > 0) {
progress.value = Math.min(
100,
Math.round((downloadedBytes.value / totalBytes.value) * 100)
)
}
}
})
downloading.value = false
installing.value = true
await currentUpdate.install()
await relaunch()
} catch (error) {
console.error("更新失败:", error)
downloading.value = false
installing.value = false
throw error
}
}
/** 跳过当前版本:记录已跳过版本并关闭弹窗 */
export function skipVersion() {
if (currentUpdate) {
setSkippedVersion(currentUpdate.version)
}
currentUpdate = null
updateInfo.value = null
showUpdateModal.value = false
}
/** 取消更新:关闭弹窗 */
export function cancelUpdate() {
currentUpdate = null
updateInfo.value = null
showUpdateModal.value = false
}

View File

@ -336,6 +336,7 @@ onMounted(() => {
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
cursor: pointer; cursor: pointer;
transition: color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; transition: color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
margin-right: 10px;
&:hover { &:hover {
color: var(--color-accent); color: var(--color-accent);

View File

@ -77,7 +77,7 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
} from "@vicons/antd" } from "@vicons/antd"
import appIcon from "../../../app-icon.svg" import appIcon from "../../../app-icon.svg"
import { checkAndUpdate } from "@/utils/updater" import { checkAndPrompt } from "@/utils/updater"
const message = useMessage() const message = useMessage()
const year = new Date().getFullYear() const year = new Date().getFullYear()
@ -92,13 +92,13 @@ const onCheckUpdate = async () => {
if (checkingUpdate.value) return if (checkingUpdate.value) return
checkingUpdate.value = true checkingUpdate.value = true
try { try {
const result = await checkAndUpdate() const result = await checkAndPrompt()
if (result === "none") { if (result === "none") {
message.success("当前已是最新版本") message.success("当前已是最新版本")
} else if (result === "error") { } else if (result === "error") {
message.error("检查更新失败,请稍后重试") message.error("检查更新失败,请稍后重试")
} }
// updatedcancelled // prompting
} finally { } finally {
checkingUpdate.value = false checkingUpdate.value = false
} }

View File

@ -38,6 +38,10 @@ const settingStore = useSettingStore()
const theme = ref(settingStore.appearance.theme) const theme = ref(settingStore.appearance.theme)
const showShadow = ref(settingStore.appearance.showShadow) const showShadow = ref(settingStore.appearance.showShadow)
watch(theme, (newVal) => {
settingStore.appearance.theme = newVal
})
watch(showShadow, (newVal) => { watch(showShadow, (newVal) => {
settingStore.appearance.showShadow = newVal settingStore.appearance.showShadow = newVal
getCurrentWindow().setShadow(newVal) getCurrentWindow().setShadow(newVal)