Compare commits

...

24 Commits

Author SHA1 Message Date
Julian Freeman
52cf6736cf change label 2026-04-18 22:36:12 -04:00
Julian Freeman
cfe93144e6 support categories 2026-04-18 20:45:10 -04:00
Julian Freeman
87ffe2e243 no post after update 2026-04-18 20:17:54 -04:00
Julian Freeman
72d878d221 fix fetch bug 2026-04-18 16:40:10 -04:00
Julian Freeman
708df41063 refactor 6 2026-04-18 16:15:52 -04:00
Julian Freeman
73976d1367 refactor 5 2026-04-18 16:08:37 -04:00
Julian Freeman
2625c8b52f refactor 4 2026-04-18 16:05:00 -04:00
Julian Freeman
0fc523e234 refactor 3 2026-04-18 16:01:38 -04:00
Julian Freeman
db377852fc refactor 2 2026-04-18 15:58:19 -04:00
Julian Freeman
2aaa330c9a refactor 1 2026-04-18 15:55:03 -04:00
Julian Freeman
fe86431899 op1 2026-04-18 15:27:00 -04:00
Julian Freeman
bba113e089 fix ui 2026-04-04 19:58:17 -04:00
Julian Freeman
ff238eb534 fix title area 2026-04-04 19:51:06 -04:00
Julian Freeman
886f513b5d support disable config 2026-04-04 19:06:27 -04:00
Julian Freeman
8067cc870f fix cmd 2026-04-04 18:42:23 -04:00
Julian Freeman
fbdfcc8abe support delay 2026-04-04 18:24:34 -04:00
Julian Freeman
86df026091 add details fetch log 2026-04-04 18:08:50 -04:00
Julian Freeman
fd8241fd43 optimize status fetch 2026-04-04 17:20:06 -04:00
Julian Freeman
66b6ac4738 fix env name 2026-04-04 17:02:33 -04:00
Julian Freeman
1d53f42d10 add file_delete and copy 2026-04-04 16:01:04 -04:00
Julian Freeman
c230847cc0 support del reg 2026-04-04 15:54:40 -04:00
Julian Freeman
dac6f6cd62 add scripts 2026-04-04 15:48:22 -04:00
Julian Freeman
04e4a510e5 support config 2026-04-04 13:22:40 -04:00
Julian Freeman
9aa6f9cd1d upgrade 2026-03-31 20:29:10 -04:00
37 changed files with 3025 additions and 900 deletions

View File

@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

118
scripts/convert-reg.ps1 Normal file
View File

@@ -0,0 +1,118 @@
<#
.SYNOPSIS
将 Windows .reg 文件转换为 win-softmgr 所需的 post_install JSON 格式。
.EXAMPLE
.\convert-reg.ps1 -Path .\adobe.reg
#>
param (
[Parameter(Mandatory=$true)]
[string]$Path,
[Parameter(Mandatory=$false)]
[string]$OutputPath
)
if (-not (Test-Path $Path)) {
Write-Error "文件不存在: $Path"
exit 1
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$fileInfo = Get-Item $Path
$OutputPath = Join-Path $fileInfo.DirectoryName ($fileInfo.BaseName + ".json")
}
# 使用 -Raw 读取并自动检测编码,然后按行拆分
$content = Get-Content $Path -Raw
$lines = $content -split "\r?\n"
$results = @()
$currentBatch = $null
foreach ($line in $lines) {
$line = $line.Trim()
if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith("Windows Registry Editor")) {
continue
}
# 匹配 [HKEY_...] 路径
if ($line.StartsWith("[") -and $line.EndsWith("]")) {
# 检查是否是删除整个 Key 的语法:[-HKEY_...]
$isDeleteKey = $line.StartsWith("[-")
$fullPath = if ($isDeleteKey) { $line.Substring(2, $line.Length - 3) } else { $line.Substring(1, $line.Length - 2) }
$root = ""
$basePath = ""
if ($fullPath -match "^HKEY_CURRENT_USER(\\.*)?$") {
$root = "HKCU"
$basePath = if ($fullPath.Length -gt 17) { $fullPath.Substring(18) } else { "" }
} elseif ($fullPath -match "^HKEY_LOCAL_MACHINE(\\.*)?$") {
$root = "HKLM"
$basePath = if ($fullPath.Length -gt 18) { $fullPath.Substring(19) } else { "" }
}
if ($root -ne "") {
$currentBatch = [ordered]@{
type = "registry_batch"
root = $root
base_path = $basePath
# 如果是删除整个 Key我们可以在这里记录或者扩展 schema
# 但目前我们先处理 Value 删除
values = [ordered]@{}
}
$results += $currentBatch
}
continue
}
# 匹配 "Name"=Value
if ($line -match '^"(.+)"\s*=\s*(.+)$') {
$name = $Matches[1]
$rawVal = $Matches[2]
$vType = ""
$data = $null
if ($rawVal -eq "-") {
# 处理删除 Value 的逻辑: "Key"=-
$vType = "Delete"
$data = $null
} elseif ($rawVal.StartsWith("dword:")) {
$vType = "Dword"
$hex = $rawVal.Substring(6)
$data = [Convert]::ToInt32($hex, 16)
} elseif ($rawVal.StartsWith('"') -and $rawVal.EndsWith('"')) {
$vType = "String"
$data = $rawVal.Substring(1, $rawVal.Length - 2).Replace("\\", "\")
} elseif ($rawVal.StartsWith("hex(7):")) {
$vType = "MultiString"
$hexBytes = $rawVal.Substring(7).Split(',') | ForEach-Object { [Convert]::ToByte($_, 16) }
$decoded = [System.Text.Encoding]::Unicode.GetString($hexBytes)
$data = $decoded.Split("`0", [System.StringSplitOptions]::RemoveEmptyEntries)
} elseif ($rawVal.StartsWith("hex(b):")) {
$vType = "Qword"
$hexBytes = $rawVal.Substring(7).Split(',') | ForEach-Object { $_ }
if ($hexBytes.Count -ge 8) {
$hexStr = ($hexBytes[7,6,5,4,3,2,1,0] -join "")
$data = [Convert]::ToInt64($hexStr, 16)
}
}
if ($null -ne $currentBatch -and $vType -ne "") {
$currentBatch.values[$name] = [ordered]@{
v_type = $vType
data = $data
}
}
}
}
if ($results.Count -eq 0) {
Write-Warning "未在文件中识别到有效的注册表项。"
}
$jsonOutput = ConvertTo-Json $results -Depth 10
[System.IO.File]::WriteAllText($OutputPath, $jsonOutput, [System.Text.Encoding]::UTF8)
Write-Host "转换成功!结果已保存至: $OutputPath" -ForegroundColor Green

View File

@@ -1,6 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/target*/
# Generated by Tauri
# will have schema files for capabilities auto-completion

15
src-tauri/Cargo.lock generated
View File

@@ -859,7 +859,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -5108,6 +5108,7 @@ dependencies = [
name = "win-softmgr"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"regex",
"reqwest 0.12.28",
@@ -5117,6 +5118,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tokio",
"winreg 0.56.0",
]
[[package]]
@@ -5592,6 +5594,17 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"serde",
"windows-sys 0.61.2",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View File

@@ -26,4 +26,5 @@ tokio = { version = "1.50.0", features = ["full"] }
chrono = "0.4.44"
regex = "1.12.3"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
winreg = { version = "0.56.0", features = ["serde"] }
base64 = "0.22.1"

View File

@@ -0,0 +1,57 @@
use tauri::AppHandle;
use crate::domain::models::{
AppSettings, DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, LogPayload, SyncEssentialsResult,
UpdateCandidate,
};
use crate::services::{essentials_service, settings_service, software_state_service};
#[tauri::command]
pub fn get_settings(app: AppHandle) -> AppSettings {
settings_service::get_settings(&app)
}
#[tauri::command]
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
settings_service::save_settings(&app, &settings)
}
#[tauri::command]
pub async fn sync_essentials(app: AppHandle) -> Result<SyncEssentialsResult, String> {
essentials_service::sync_essentials(&app).await
}
#[tauri::command]
pub fn get_essentials(app: AppHandle) -> Option<EssentialsRepo> {
essentials_service::get_essentials(&app)
}
#[tauri::command]
pub async fn initialize_app(app: AppHandle) -> Result<bool, String> {
software_state_service::initialize_app(app).await
}
#[tauri::command]
pub async fn get_dashboard_snapshot(app: AppHandle) -> DashboardSnapshot {
software_state_service::get_dashboard_snapshot(app).await
}
#[tauri::command]
pub async fn get_essentials_status(app: AppHandle) -> (String, Vec<EssentialsStatusItem>) {
software_state_service::get_essentials_status(app).await
}
#[tauri::command]
pub async fn get_update_candidates(app: AppHandle) -> Vec<UpdateCandidate> {
software_state_service::get_update_candidates(app).await
}
#[tauri::command]
pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Option<String> {
software_state_service::get_software_icon(app, id, name).await
}
#[tauri::command]
pub fn get_logs_history() -> Vec<LogPayload> {
vec![]
}

View File

@@ -0,0 +1 @@
pub mod app_commands;

View File

@@ -0,0 +1 @@
pub mod models;

View File

@@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};
use crate::winget::{PostInstallStep, Software};
#[derive(Clone, Serialize, Deserialize)]
pub struct AppSettings {
pub repo_url: String,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
repo_url: "https://karlblue.github.io/winget-repo".to_string(),
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EssentialsRepo {
pub version: String,
pub essentials: Vec<Software>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct InstallTask {
pub id: String,
pub version: Option<String>,
#[serde(default)]
pub use_manifest: bool,
pub manifest_url: Option<String>,
#[serde(default = "default_true")]
pub enable_post_install: bool,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Serialize)]
pub struct LogPayload {
pub id: String,
pub timestamp: String,
pub command: String,
pub output: String,
pub status: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct SyncEssentialsResult {
pub status: String,
pub message: String,
}
#[derive(Clone)]
pub struct ResolvedPostInstall {
pub software: Software,
pub steps: Vec<PostInstallStep>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct TaskEventPayload {
pub task_id: String,
pub software_id: String,
pub task_type: String,
pub status: String,
pub stage: String,
pub progress: f32,
pub target_version: Option<String>,
pub message: Option<String>,
pub software_info: Option<Software>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EssentialsStatusItem {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub recommended_version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
pub action_label: String,
pub target_version: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct UpdateCandidate {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
pub action_label: String,
pub target_version: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct DashboardSnapshot {
pub essentials_version: String,
pub essentials: Vec<EssentialsStatusItem>,
pub updates: Vec<UpdateCandidate>,
pub installed_software: Vec<Software>,
}

View File

@@ -1,369 +1,33 @@
use tauri::Manager;
pub mod commands;
pub mod domain;
pub mod providers;
pub mod services;
pub mod storage;
pub mod tasks;
pub mod winget;
use std::fs;
use std::process::{Command, Stdio};
use std::os::windows::process::CommandExt;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use tokio::sync::mpsc;
use tauri::{AppHandle, Manager, State, Emitter};
use serde::{Serialize, Deserialize};
use winget::{Software, list_installed_software, list_updates, ensure_winget_dependencies};
use regex::Regex;
#[derive(Clone, Serialize, Deserialize)]
pub struct AppSettings {
pub repo_url: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EssentialsRepo {
pub version: String,
pub essentials: Vec<Software>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct InstallTask {
pub id: String,
pub version: Option<String>,
#[serde(default)]
pub use_manifest: bool,
pub manifest_url: Option<String>,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
repo_url: "https://karlblue.github.io/winget-repo".to_string(),
}
}
}
struct AppState {
install_tx: mpsc::Sender<InstallTask>,
}
#[derive(Clone, Serialize)]
pub struct LogPayload {
pub id: String,
pub timestamp: String,
pub command: String,
pub output: String,
pub status: String,
}
pub fn emit_log(handle: &AppHandle, id: &str, command: &str, output: &str, status: &str) {
let now = chrono::Local::now().format("%H:%M:%S").to_string();
let _ = handle.emit("log-event", LogPayload {
id: id.to_string(),
timestamp: now,
command: command.to_string(),
output: output.to_string(),
status: status.to_string(),
});
}
fn get_settings_path(app: &AppHandle) -> PathBuf {
let app_data_dir = app.path().app_data_dir().unwrap_or_default();
if !app_data_dir.exists() {
let _ = fs::create_dir_all(&app_data_dir);
}
app_data_dir.join("settings.json")
}
fn get_essentials_path(app: &AppHandle) -> PathBuf {
let app_data_dir = app.path().app_data_dir().unwrap_or_default();
if !app_data_dir.exists() {
let _ = fs::create_dir_all(&app_data_dir);
}
app_data_dir.join("setup-essentials.json")
}
#[tauri::command]
fn get_settings(app: AppHandle) -> AppSettings {
let path = get_settings_path(&app);
if !path.exists() {
let default_settings = AppSettings::default();
let _ = fs::write(&path, serde_json::to_string_pretty(&default_settings).unwrap());
return default_settings;
}
let content = fs::read_to_string(path).unwrap_or_default();
serde_json::from_str(&content).unwrap_or_default()
}
#[tauri::command]
fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
let path = get_settings_path(&app);
let content = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(path, content).map_err(|e| e.to_string())
}
#[tauri::command]
async fn sync_essentials(app: AppHandle) -> Result<bool, String> {
let settings = get_settings(app.clone());
let url = format!("{}/setup-essentials.json", settings.repo_url.trim_end_matches('/'));
emit_log(&app, "sync-essentials", "Syncing Essentials", &format!("Downloading from {}...", url), "info");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
let content = response.text().await.map_err(|e| e.to_string())?;
// 验证 JSON 格式(新格式:{ version: string, essentials: Vec<Software> }
let validation: Result<EssentialsRepo, _> = serde_json::from_str(&content);
if validation.is_ok() {
let path = get_essentials_path(&app);
fs::write(path, content).map_err(|e| e.to_string())?;
emit_log(&app, "sync-essentials", "Result", "Essentials list updated successfully.", "success");
Ok(true)
} else {
emit_log(&app, "sync-essentials", "Error", "Invalid JSON format from repository. Expected { version, essentials }.", "error");
Err("Invalid JSON format".to_string())
}
} else {
let err_msg = format!("HTTP Error: {}", response.status());
emit_log(&app, "sync-essentials", "Error", &err_msg, "error");
Err(err_msg)
}
}
Err(e) => {
emit_log(&app, "sync-essentials", "Skipped", &format!("Network issue: {}. Using local cache.", e), "info");
Ok(false)
}
}
}
#[tauri::command]
async fn initialize_app(app: AppHandle) -> Result<bool, String> {
let app_clone = app.clone();
tokio::task::spawn_blocking(move || {
ensure_winget_dependencies(&app_clone).map(|_| true)
}).await.unwrap_or(Err("Initialization Task Panicked".to_string()))
}
#[tauri::command]
fn get_essentials(app: AppHandle) -> Option<EssentialsRepo> {
let file_path = get_essentials_path(&app);
if !file_path.exists() {
return None;
}
let content = fs::read_to_string(file_path).unwrap_or_default();
serde_json::from_str(&content).ok()
}
#[tauri::command]
async fn get_installed_software(app: AppHandle) -> Vec<Software> {
tokio::task::spawn_blocking(move || list_installed_software(&app)).await.unwrap_or_default()
}
#[tauri::command]
async fn get_updates(app: AppHandle) -> Vec<Software> {
tokio::task::spawn_blocking(move || list_updates(&app)).await.unwrap_or_default()
}
#[tauri::command]
async fn install_software(
task: InstallTask,
state: State<'_, AppState>
) -> Result<(), String> {
state.install_tx.send(task).await.map_err(|e| e.to_string())
}
#[tauri::command]
fn get_logs_history() -> Vec<LogPayload> {
vec![]
}
#[derive(Clone, Serialize)]
struct InstallProgress {
id: String,
status: String,
progress: f32,
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(move |app| {
let handle = app.handle().clone();
let (tx, mut rx) = mpsc::channel::<InstallTask>(100);
app.manage(AppState { install_tx: tx });
tauri::async_runtime::spawn(async move {
let perc_re = Regex::new(r"(\d+)\s*%").unwrap();
let size_re = Regex::new(r"([\d\.]+)\s*[a-zA-Z]+\s*/\s*([\d\.]+)\s*[a-zA-Z]+").unwrap();
while let Some(task) = rx.recv().await {
let task_id = task.id.clone();
let task_version = task.version.clone();
let use_manifest = task.use_manifest;
let manifest_url = task.manifest_url.clone();
let log_id = format!("install-{}", task_id);
// 1. 发送正在安装状态
let _ = handle.emit("install-status", InstallProgress {
id: task_id.clone(),
status: "installing".to_string(),
progress: 0.0,
});
let mut args = vec!["install".to_string()];
let display_cmd: String;
let mut temp_manifest_path: Option<PathBuf> = None;
if use_manifest && manifest_url.is_some() {
let url = manifest_url.unwrap();
display_cmd = format!("Winget Install (Manifest): {} from {}", task_id, url);
emit_log(&handle, &log_id, &display_cmd, "Downloading remote manifest...", "info");
let client = reqwest::Client::new();
match client.get(&url).timeout(std::time::Duration::from_secs(15)).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(content) = resp.text().await {
let temp_dir = std::env::temp_dir();
let file_name = format!("winget_tmp_{}.yaml", chrono::Local::now().timestamp_millis());
let local_path = temp_dir.join(file_name);
if fs::write(&local_path, content).is_ok() {
args.push("--manifest".to_string());
args.push(local_path.to_string_lossy().to_string());
temp_manifest_path = Some(local_path);
}
}
}
_ => {}
}
if temp_manifest_path.is_none() {
emit_log(&handle, &log_id, "Error", "Failed to download or save manifest.", "error");
let _ = handle.emit("install-status", InstallProgress {
id: task_id.clone(),
status: "error".to_string(),
progress: 0.0,
});
continue;
}
} else {
args.push("--id".to_string());
args.push(task_id.clone());
args.push("-e".to_string());
if let Some(v) = &task_version {
if !v.is_empty() {
args.push("--version".to_string());
args.push(v.clone());
}
}
display_cmd = match &task_version {
Some(v) if !v.is_empty() => format!("Winget Install: {} (v{})", task_id, v),
_ => format!("Winget Install: {}", task_id),
};
}
args.extend([
"--silent".to_string(),
"--accept-package-agreements".to_string(),
"--accept-source-agreements".to_string(),
"--disable-interactivity".to_string(),
]);
let full_command = format!("winget {}", args.join(" "));
emit_log(&handle, &log_id, &display_cmd, &format!("Executing: {}\n---", full_command), "info");
let h = handle.clone();
let current_id = task_id.clone();
let child = Command::new("winget")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.creation_flags(0x08000000)
.spawn();
let status_result = match child {
Ok(mut child_proc) => {
if let Some(stdout) = child_proc.stdout.take() {
let reader = BufReader::new(stdout);
// 使用 split(b'\r') 是为了捕捉 PowerShell 中的动态进度行
for line_res in reader.split(b'\r') {
if let Ok(line_bytes) = line_res {
let line_str = String::from_utf8_lossy(&line_bytes).to_string();
let clean_line = line_str.trim();
if clean_line.is_empty() { continue; }
let mut is_progress = false;
if let Some(caps) = perc_re.captures(clean_line) {
if let Ok(p_val) = caps[1].parse::<f32>() {
let _ = h.emit("install-status", InstallProgress {
id: current_id.clone(),
status: "installing".to_string(),
progress: p_val / 100.0,
});
is_progress = true;
}
} else if let Some(caps) = size_re.captures(clean_line) {
let current = caps[1].parse::<f32>().unwrap_or(0.0);
let total = caps[2].parse::<f32>().unwrap_or(1.0);
if total > 0.0 {
let _ = h.emit("install-status", InstallProgress {
id: current_id.clone(),
status: "installing".to_string(),
progress: (current / total).min(1.0),
});
is_progress = true;
}
}
if !is_progress && clean_line.chars().count() > 1 {
emit_log(&h, &log_id, "", clean_line, "info");
}
}
}
}
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
if exit_status { "success" } else { "error" }
},
Err(e) => {
emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error");
"error"
}
};
// 2. 发送最终完成/失败状态
let _ = handle.emit("install-status", InstallProgress {
id: task_id.clone(),
status: status_result.to_string(),
progress: 1.0,
});
emit_log(&handle, &log_id, "Result", &format!("Execution finished: {}", status_result), if status_result == "success" { "success" } else { "error" });
// 3. 清理临时清单文件
if let Some(path) = temp_manifest_path {
let _ = fs::remove_file(path);
}
}
});
let install_state = tasks::install_queue::create_install_state(app.handle().clone());
app.manage(install_state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
initialize_app,
get_settings,
save_settings,
sync_essentials,
get_essentials,
get_installed_software,
get_updates,
install_software,
get_logs_history
commands::app_commands::initialize_app,
commands::app_commands::get_settings,
commands::app_commands::save_settings,
commands::app_commands::sync_essentials,
commands::app_commands::get_essentials,
commands::app_commands::get_dashboard_snapshot,
commands::app_commands::get_essentials_status,
commands::app_commands::get_update_candidates,
commands::app_commands::get_software_icon,
tasks::install_queue::install_software,
commands::app_commands::get_logs_history
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1 @@
pub mod winget_client;

View File

@@ -0,0 +1,23 @@
use tauri::AppHandle;
use crate::winget::{self, Software};
pub fn ensure_environment_ready(handle: &AppHandle) -> Result<(), String> {
winget::ensure_winget_dependencies(handle)
}
pub fn list_installed_packages(handle: &AppHandle) -> Vec<Software> {
winget::list_installed_software(handle)
}
pub fn list_upgrade_candidates(handle: &AppHandle) -> Vec<Software> {
winget::list_updates(handle)
}
pub fn get_package_by_id(handle: &AppHandle, id: &str) -> Option<Software> {
winget::get_software_info(handle, id)
}
pub fn resolve_icon(handle: &AppHandle, id: &str, name: &str) -> Option<String> {
winget::get_cached_or_extract_icon(handle, id, name)
}

View File

@@ -0,0 +1,163 @@
use reqwest::Client;
use tauri::AppHandle;
use crate::domain::models::{EssentialsRepo, SyncEssentialsResult};
use crate::services::log_service::emit_log;
use crate::services::settings_service;
use crate::services::task_event_service::emit_task_event;
use crate::storage::{essentials_store, paths};
pub async fn sync_essentials(app: &AppHandle) -> Result<SyncEssentialsResult, String> {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"running",
"starting",
0.0,
None,
Some("Starting essentials sync".to_string()),
None,
);
let settings = settings_service::get_settings(app);
let url = format!("{}/setup-essentials.json", settings.repo_url.trim_end_matches('/'));
let cache_path = paths::get_essentials_path(app);
emit_log(
app,
"sync-essentials",
"Syncing Essentials",
&format!("Downloading from {}...", url),
"info",
);
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
let content = response.text().await.map_err(|e| e.to_string())?;
let validation: Result<EssentialsRepo, _> = serde_json::from_str(&content);
if validation.is_ok() {
essentials_store::save_essentials(app, &content)?;
emit_log(
app,
"sync-essentials",
"Result",
"Essentials list updated successfully.",
"success",
);
Ok(SyncEssentialsResult {
status: "updated".to_string(),
message: "清单同步成功".to_string(),
})
.inspect(|_| {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"completed",
"updated",
1.0,
None,
Some("Essentials list updated successfully".to_string()),
None,
);
})
} else {
emit_log(
app,
"sync-essentials",
"Error",
"Invalid JSON format from repository. Expected { version, essentials }.",
"error",
);
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"invalid_json",
1.0,
None,
Some("Invalid JSON format".to_string()),
None,
);
Err("Invalid JSON format".to_string())
}
} else {
let err_msg = format!("HTTP Error: {}", response.status());
emit_log(app, "sync-essentials", "Error", &err_msg, "error");
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"http_error",
1.0,
None,
Some(err_msg.clone()),
None,
);
Err(err_msg)
}
}
Err(e) => {
if cache_path.exists() {
emit_log(
app,
"sync-essentials",
"Skipped",
&format!("Network issue: {}. Using local cache.", e),
"info",
);
Ok(SyncEssentialsResult {
status: "cache_used".to_string(),
message: "网络不可用,已继续使用本地缓存".to_string(),
})
.inspect(|_| {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"completed",
"cache_used",
1.0,
None,
Some("Network unavailable, used local cache".to_string()),
None,
);
})
} else {
let err_msg = format!("Network issue: {}", e);
emit_log(app, "sync-essentials", "Error", &err_msg, "error");
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"network_error",
1.0,
None,
Some(err_msg.clone()),
None,
);
Err(err_msg)
}
}
}
}
pub fn get_essentials(app: &AppHandle) -> Option<EssentialsRepo> {
essentials_store::load_essentials(app)
}

View File

@@ -0,0 +1,17 @@
use tauri::{AppHandle, Emitter};
use crate::domain::models::LogPayload;
pub fn emit_log(handle: &AppHandle, id: &str, command: &str, output: &str, status: &str) {
let now = chrono::Local::now().format("%H:%M:%S").to_string();
let _ = handle.emit(
"log-event",
LogPayload {
id: id.to_string(),
timestamp: now,
command: command.to_string(),
output: output.to_string(),
status: status.to_string(),
},
);
}

View File

@@ -0,0 +1,6 @@
pub mod essentials_service;
pub mod log_service;
pub mod reconcile_service;
pub mod settings_service;
pub mod software_state_service;
pub mod task_event_service;

View File

@@ -0,0 +1,160 @@
use std::collections::HashMap;
use crate::domain::models::{DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, UpdateCandidate};
use crate::winget::Software;
pub fn build_dashboard_snapshot(
repo: Option<EssentialsRepo>,
installed_software: Vec<Software>,
updates: Vec<Software>,
) -> DashboardSnapshot {
let essentials_version = repo
.as_ref()
.map(|item| item.version.clone())
.unwrap_or_default();
let definitions = repo.map(|item| item.essentials).unwrap_or_default();
let essentials = build_essentials_status(&definitions, &installed_software, &updates);
let update_candidates = build_update_candidates(&definitions, updates);
DashboardSnapshot {
essentials_version,
essentials,
updates: update_candidates,
installed_software,
}
}
pub fn build_essentials_status(
definitions: &[Software],
installed_software: &[Software],
updates: &[Software],
) -> Vec<EssentialsStatusItem> {
definitions
.iter()
.map(|definition| {
let installed = installed_software
.iter()
.find(|item| item.id.eq_ignore_ascii_case(&definition.id));
let update = updates
.iter()
.find(|item| item.id.eq_ignore_ascii_case(&definition.id));
let current_version = installed.and_then(|item| item.version.clone());
let recommended_version = definition.version.clone();
let available_version = update.and_then(|item| item.available_version.clone());
let (action_label, target_version) = if installed.is_some() {
match compare_versions(current_version.as_deref(), recommended_version.as_deref()) {
Some(std::cmp::Ordering::Less) => (
"更新".to_string(),
recommended_version.clone().or(available_version.clone()),
),
Some(_) => ("已安装".to_string(), None),
None => ("已安装".to_string(), None),
}
} else {
(
"安装".to_string(),
recommended_version.clone().or(available_version.clone()),
)
};
EssentialsStatusItem {
id: definition.id.clone(),
name: definition.name.clone(),
description: definition.description.clone(),
category: definition.category.clone(),
version: current_version,
recommended_version,
available_version,
icon_url: definition.icon_url.clone(),
use_manifest: definition.use_manifest,
manifest_url: definition.manifest_url.clone(),
post_install: definition.post_install.clone(),
post_install_url: definition.post_install_url.clone(),
action_label,
target_version,
}
})
.collect()
}
pub fn build_update_candidates(
definitions: &[Software],
updates: Vec<Software>,
) -> Vec<UpdateCandidate> {
let definition_map: HashMap<String, &Software> = definitions
.iter()
.map(|item| (item.id.to_ascii_lowercase(), item))
.collect();
let mut result: Vec<UpdateCandidate> = updates
.into_iter()
.map(|update| {
let definition = definition_map.get(&update.id.to_ascii_lowercase()).copied();
UpdateCandidate {
id: update.id.clone(),
name: update.name.clone(),
description: definition.and_then(|item| item.description.clone()),
category: definition.and_then(|item| item.category.clone()),
version: update.version.clone(),
available_version: update.available_version.clone(),
icon_url: update.icon_url.clone().or_else(|| definition.and_then(|item| item.icon_url.clone())),
use_manifest: definition.map(|item| item.use_manifest).unwrap_or(false),
manifest_url: definition.and_then(|item| item.manifest_url.clone()),
post_install: None,
post_install_url: None,
action_label: "更新".to_string(),
target_version: update.available_version.clone(),
}
})
.collect();
result.sort_by(|left, right| left.name.locale_compare(&right.name));
result
}
trait LocaleCompare {
fn locale_compare(&self, other: &str) -> std::cmp::Ordering;
}
impl LocaleCompare for String {
fn locale_compare(&self, other: &str) -> std::cmp::Ordering {
self.to_lowercase().cmp(&other.to_lowercase())
}
}
fn compare_versions(left: Option<&str>, right: Option<&str>) -> Option<std::cmp::Ordering> {
let left = left?;
let right = right?;
if left == right {
return Some(std::cmp::Ordering::Equal);
}
let clean = |value: &str| {
value
.trim_start_matches('v')
.trim_start_matches('V')
.split(['-', '+'])
.next()
.unwrap_or(value)
.split('.')
.map(|item| item.parse::<u32>().unwrap_or(0))
.collect::<Vec<_>>()
};
let left_parts = clean(left);
let right_parts = clean(right);
let max_len = left_parts.len().max(right_parts.len());
for index in 0..max_len {
let left_value = *left_parts.get(index).unwrap_or(&0);
let right_value = *right_parts.get(index).unwrap_or(&0);
match left_value.cmp(&right_value) {
std::cmp::Ordering::Equal => continue,
ordering => return Some(ordering),
}
}
Some(std::cmp::Ordering::Equal)
}

View File

@@ -0,0 +1,12 @@
use tauri::AppHandle;
use crate::domain::models::AppSettings;
use crate::storage::settings_store;
pub fn get_settings(app: &AppHandle) -> AppSettings {
settings_store::get_settings(app)
}
pub fn save_settings(app: &AppHandle, settings: &AppSettings) -> Result<(), String> {
settings_store::save_settings(app, settings)
}

View File

@@ -0,0 +1,85 @@
use tauri::AppHandle;
use crate::domain::models::{DashboardSnapshot, EssentialsStatusItem, UpdateCandidate};
use crate::providers::winget_client;
use crate::services::{essentials_service, reconcile_service};
use crate::services::task_event_service::emit_task_event;
pub async fn initialize_app(app: AppHandle) -> Result<bool, String> {
emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"running",
"checking_environment",
0.0,
None,
Some("Checking WinGet environment".to_string()),
None,
);
let app_clone = app.clone();
let result = tokio::task::spawn_blocking(move || winget_client::ensure_environment_ready(&app_clone).map(|_| true))
.await
.unwrap_or(Err("Initialization Task Panicked".to_string()));
match &result {
Ok(_) => emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"completed",
"ready",
1.0,
None,
Some("WinGet environment ready".to_string()),
None,
),
Err(err) => emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"failed",
"error",
1.0,
None,
Some(err.clone()),
None,
),
}
result
}
pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Option<String> {
tokio::task::spawn_blocking(move || winget_client::resolve_icon(&app, &id, &name))
.await
.unwrap_or(None)
}
pub async fn get_dashboard_snapshot(app: AppHandle) -> DashboardSnapshot {
let repo = essentials_service::get_essentials(&app);
let app_for_installed = app.clone();
let app_for_updates = app.clone();
let installed_handle =
tokio::task::spawn_blocking(move || winget_client::list_installed_packages(&app_for_installed));
let updates_handle =
tokio::task::spawn_blocking(move || winget_client::list_upgrade_candidates(&app_for_updates));
let installed_software = installed_handle.await.unwrap_or_default();
let updates = updates_handle.await.unwrap_or_default();
reconcile_service::build_dashboard_snapshot(repo, installed_software, updates)
}
pub async fn get_essentials_status(app: AppHandle) -> (String, Vec<EssentialsStatusItem>) {
let snapshot = get_dashboard_snapshot(app).await;
(snapshot.essentials_version, snapshot.essentials)
}
pub async fn get_update_candidates(app: AppHandle) -> Vec<UpdateCandidate> {
get_dashboard_snapshot(app).await.updates
}

View File

@@ -0,0 +1,32 @@
use tauri::{AppHandle, Emitter};
use crate::domain::models::TaskEventPayload;
use crate::winget::Software;
pub fn emit_task_event(
handle: &AppHandle,
task_id: &str,
software_id: &str,
task_type: &str,
status: &str,
stage: &str,
progress: f32,
target_version: Option<String>,
message: Option<String>,
software_info: Option<Software>,
) {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: task_id.to_string(),
software_id: software_id.to_string(),
task_type: task_type.to_string(),
status: status.to_string(),
stage: stage.to_string(),
progress,
target_version,
message,
software_info,
},
);
}

View File

@@ -0,0 +1,21 @@
use std::fs;
use tauri::AppHandle;
use crate::domain::models::EssentialsRepo;
use crate::storage::paths::get_essentials_path;
pub fn load_essentials(app: &AppHandle) -> Option<EssentialsRepo> {
let file_path = get_essentials_path(app);
if !file_path.exists() {
return None;
}
let content = fs::read_to_string(file_path).unwrap_or_default();
serde_json::from_str(&content).ok()
}
pub fn save_essentials(app: &AppHandle, content: &str) -> Result<(), String> {
let path = get_essentials_path(app);
fs::write(path, content).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,3 @@
pub mod essentials_store;
pub mod paths;
pub mod settings_store;

View File

@@ -0,0 +1,20 @@
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
pub fn get_app_data_dir(app: &AppHandle) -> PathBuf {
let app_data_dir = app.path().app_data_dir().unwrap_or_default();
if !app_data_dir.exists() {
let _ = fs::create_dir_all(&app_data_dir);
}
app_data_dir
}
pub fn get_settings_path(app: &AppHandle) -> PathBuf {
get_app_data_dir(app).join("settings.json")
}
pub fn get_essentials_path(app: &AppHandle) -> PathBuf {
get_app_data_dir(app).join("setup-essentials.json")
}

View File

@@ -0,0 +1,24 @@
use std::fs;
use tauri::AppHandle;
use crate::domain::models::AppSettings;
use crate::storage::paths::get_settings_path;
pub fn get_settings(app: &AppHandle) -> AppSettings {
let path = get_settings_path(app);
if !path.exists() {
let default_settings = AppSettings::default();
let _ = fs::write(&path, serde_json::to_string_pretty(&default_settings).unwrap());
return default_settings;
}
let content = fs::read_to_string(path).unwrap_or_default();
serde_json::from_str(&content).unwrap_or_default()
}
pub fn save_settings(app: &AppHandle, settings: &AppSettings) -> Result<(), String> {
let path = get_settings_path(app);
let content = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
fs::write(path, content).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,753 @@
use std::fs;
use std::io::{BufRead, BufReader, Read};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use regex::Regex;
use tauri::{AppHandle, Emitter, State};
use tokio::sync::mpsc;
use winreg::enums::*;
use winreg::RegKey;
use crate::domain::models::{InstallTask, TaskEventPayload};
use crate::providers::winget_client;
use crate::services::essentials_service;
use crate::services::log_service::emit_log;
use crate::services::task_event_service;
use crate::winget::{PostInstallStep, Software};
pub struct AppState {
pub install_tx: mpsc::Sender<InstallTask>,
pub app_handle: AppHandle,
}
pub fn create_install_state(handle: AppHandle) -> AppState {
let (tx, mut rx) = mpsc::channel::<InstallTask>(100);
let runtime_handle = handle.clone();
tauri::async_runtime::spawn(async move {
let perc_re = Regex::new(r"(\d+)\s*%").unwrap();
let size_re = Regex::new(r"([\d\.]+)\s*[a-zA-Z]+\s*/\s*([\d\.]+)\s*[a-zA-Z]+").unwrap();
while let Some(task) = rx.recv().await {
let task_id = task.id.clone();
let task_version = task.version.clone();
let use_manifest = task.use_manifest;
let manifest_url = task.manifest_url.clone();
let enable_post_install_flag = task.enable_post_install;
let log_id = format!("install-{}", task_id);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"installing",
0.0,
task_version.clone(),
None,
None,
);
let mut args = vec!["install".to_string()];
let display_cmd: String;
let mut temp_manifest_path: Option<PathBuf> = None;
if use_manifest && manifest_url.is_some() {
let url = manifest_url.unwrap();
display_cmd = format!("Winget Install (Manifest): {} from {}", task_id, url);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"downloading_manifest",
0.0,
task_version.clone(),
Some("Downloading remote manifest".to_string()),
None,
);
emit_log(
&runtime_handle,
&log_id,
&display_cmd,
"Downloading remote manifest...",
"info",
);
let client = reqwest::Client::new();
match client
.get(&url)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(content) = resp.text().await {
let temp_dir = std::env::temp_dir();
let file_name =
format!("winget_tmp_{}.yaml", chrono::Local::now().timestamp_millis());
let local_path = temp_dir.join(file_name);
if fs::write(&local_path, content).is_ok() {
args.push("--manifest".to_string());
args.push(local_path.to_string_lossy().to_string());
temp_manifest_path = Some(local_path);
}
}
}
_ => {}
}
if temp_manifest_path.is_none() {
emit_log(
&runtime_handle,
&log_id,
"Error",
"Failed to download or save manifest.",
"error",
);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"failed",
"manifest_error",
0.0,
task_version.clone(),
Some("Failed to download or save manifest".to_string()),
None,
);
continue;
}
} else {
args.push("--id".to_string());
args.push(task_id.clone());
args.push("-e".to_string());
if let Some(v) = &task_version {
if !v.is_empty() {
args.push("--version".to_string());
args.push(v.clone());
}
}
display_cmd = match &task_version {
Some(v) if !v.is_empty() => format!("Winget Install: {} (v{})", task_id, v),
_ => format!("Winget Install: {}", task_id),
};
}
args.extend([
"--silent".to_string(),
"--accept-package-agreements".to_string(),
"--accept-source-agreements".to_string(),
"--disable-interactivity".to_string(),
]);
let full_command = format!("winget {}", args.join(" "));
emit_log(
&runtime_handle,
&log_id,
&display_cmd,
&format!("Executing: {}\n---", full_command),
"info",
);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"invoking_winget",
0.0,
task_version.clone(),
None,
None,
);
let child = Command::new("winget")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.creation_flags(0x08000000)
.spawn();
let status_result = match child {
Ok(mut child_proc) => {
let stdout_handle = child_proc.stdout.take().map(|stdout| {
spawn_install_stream_reader(
stdout,
runtime_handle.clone(),
log_id.clone(),
task_id.clone(),
"stdout",
perc_re.clone(),
size_re.clone(),
)
});
let stderr_handle = child_proc.stderr.take().map(|stderr| {
spawn_install_stream_reader(
stderr,
runtime_handle.clone(),
log_id.clone(),
task_id.clone(),
"stderr",
perc_re.clone(),
size_re.clone(),
)
});
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
if let Some(join_handle) = stdout_handle {
let _ = join_handle.join();
}
if let Some(join_handle) = stderr_handle {
let _ = join_handle.join();
}
let status_result = if exit_status { "success" } else { "error" };
if status_result == "success" && enable_post_install_flag {
let software_info = essentials_service::get_essentials(&runtime_handle)
.and_then(|repo| repo.essentials.into_iter().find(|s| s.id == task_id));
if let Some(sw) = software_info {
let mut final_steps = None;
if let Some(steps) = sw.post_install {
if !steps.is_empty() {
final_steps = Some(steps);
}
} else if let Some(url) = sw.post_install_url {
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
"Local config not found, fetching remote config...",
"info",
);
let client = reqwest::Client::new();
if let Ok(resp) = client
.get(&url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
{
if resp.status().is_success() {
if let Ok(text) = resp.text().await {
match serde_json::from_str::<Vec<PostInstallStep>>(&text) {
Ok(steps) => {
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
&format!(
"Successfully fetched remote config with {} steps.",
steps.len()
),
"info",
);
final_steps = Some(steps);
}
Err(e) => {
emit_log(
&runtime_handle,
&log_id,
"Post-Install Error",
&format!("JSON Parse Error: {}. Raw Content: {}", e, text),
"error",
);
}
}
}
} else {
emit_log(
&runtime_handle,
&log_id,
"Post-Install Error",
&format!("Remote config HTTP Error: {}", resp.status()),
"error",
);
}
}
}
if let Some(steps) = final_steps {
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"configuring",
1.0,
task_version.clone(),
Some("Starting post-installation configuration".to_string()),
None,
);
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
"Starting post-installation configuration...",
"info",
);
if let Err(e) = execute_post_install(&runtime_handle, &log_id, steps).await {
emit_log(&runtime_handle, &log_id, "Post-Install Error", &e, "error");
} else {
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
"Post-installation configuration completed.",
"success",
);
}
}
}
}
status_result
}
Err(e) => {
emit_log(&runtime_handle, &log_id, "Fatal Error", &e.to_string(), "error");
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"failed",
"spawn_error",
0.0,
task_version.clone(),
Some(e.to_string()),
None,
);
"error"
}
};
let resolved_software_info = if status_result == "success" {
winget_client::get_package_by_id(&runtime_handle, &task_id)
} else {
None
};
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
if status_result == "success" { "completed" } else { "failed" },
status_result,
1.0,
task_version.clone(),
Some(format!("Execution finished: {}", status_result)),
resolved_software_info,
);
emit_log(
&runtime_handle,
&log_id,
"Result",
&format!("Execution finished: {}", status_result),
if status_result == "success" {
"success"
} else {
"error"
},
);
if let Some(path) = temp_manifest_path {
let _ = fs::remove_file(path);
}
}
});
AppState { install_tx: tx, app_handle: handle }
}
#[tauri::command]
pub async fn install_software(
task: InstallTask,
state: State<'_, AppState>,
) -> Result<(), String> {
let log_id = format!("install-{}", task.id);
emit_task_event(
&state.app_handle,
&log_id,
&task.id,
"install",
"queued",
"queued",
0.0,
task.version.clone(),
None,
None,
);
state.install_tx.send(task).await.map_err(|e| e.to_string())
}
fn spawn_install_stream_reader<R: Read + Send + 'static>(
reader: R,
handle: AppHandle,
log_id: String,
task_id: String,
stream_name: &'static str,
perc_re: Regex,
size_re: Regex,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
let reader = BufReader::new(reader);
for line_res in reader.split(b'\r') {
if let Ok(line_bytes) = line_res {
let line_str = String::from_utf8_lossy(&line_bytes).to_string();
let clean_line = line_str.trim();
if clean_line.is_empty() {
continue;
}
if stream_name == "stdout" {
let mut is_progress = false;
if let Some(caps) = perc_re.captures(clean_line) {
if let Ok(p_val) = caps[1].parse::<f32>() {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: log_id.clone(),
software_id: task_id.clone(),
task_type: "install".to_string(),
status: "running".to_string(),
stage: "installing".to_string(),
progress: p_val / 100.0,
target_version: None,
message: None,
software_info: None,
},
);
is_progress = true;
}
} else if let Some(caps) = size_re.captures(clean_line) {
let current = caps[1].parse::<f32>().unwrap_or(0.0);
let total = caps[2].parse::<f32>().unwrap_or(1.0);
if total > 0.0 {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: log_id.clone(),
software_id: task_id.clone(),
task_type: "install".to_string(),
status: "running".to_string(),
stage: "installing".to_string(),
progress: (current / total).min(1.0),
target_version: None,
message: None,
software_info: None,
},
);
is_progress = true;
}
}
if !is_progress && clean_line.chars().count() > 1 {
emit_log(&handle, &log_id, "", clean_line, "info");
}
} else {
emit_log(&handle, &log_id, stream_name, clean_line, "error");
}
}
}
})
}
fn emit_task_event(
handle: &AppHandle,
task_id: &str,
software_id: &str,
task_type: &str,
status: &str,
stage: &str,
progress: f32,
target_version: Option<String>,
message: Option<String>,
software_info: Option<Software>,
) {
task_event_service::emit_task_event(
handle,
task_id,
software_id,
task_type,
status,
stage,
progress,
target_version.clone(),
message,
software_info,
);
}
fn expand_win_path(path: &str) -> PathBuf {
let mut expanded = path.to_string();
let env_vars = [
"AppData",
"LocalAppData",
"ProgramData",
"SystemRoot",
"SystemDrive",
"TEMP",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
];
for var in env_vars {
let re = Regex::new(&format!(r"(?i)%{}%", var)).unwrap();
if re.is_match(&expanded) {
if let Ok(val) = std::env::var(var) {
expanded = re.replace_all(&expanded, val.as_str()).to_string();
}
}
}
PathBuf::from(expanded)
}
async fn execute_post_install(
handle: &AppHandle,
log_id: &str,
steps: Vec<PostInstallStep>,
) -> Result<(), String> {
let steps_len = steps.len();
for (i, step) in steps.into_iter().enumerate() {
let step_prefix = format!("Step {}/{}: ", i + 1, steps_len);
let delay = match &step {
PostInstallStep::RegistryBatch { delay_ms, .. } => *delay_ms,
PostInstallStep::FileCopy { delay_ms, .. } => *delay_ms,
PostInstallStep::FileDelete { delay_ms, .. } => *delay_ms,
PostInstallStep::Command { delay_ms, .. } => *delay_ms,
};
match step {
PostInstallStep::RegistryBatch {
root,
base_path,
values,
..
} => {
emit_log(
handle,
log_id,
"Registry Update",
&format!("{}Applying batch registry settings to {}...", step_prefix, base_path),
"info",
);
let hive = match root.as_str() {
"HKCU" => RegKey::predef(HKEY_CURRENT_USER),
"HKLM" => RegKey::predef(HKEY_LOCAL_MACHINE),
_ => {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Unknown root hive: {}", root),
"error",
);
continue;
}
};
match hive.create_subkey(&base_path) {
Ok((key, _)) => {
for (name, val) in values {
let res = match val.v_type.as_str() {
"String" => key.set_value(&name, &val.data.as_str().unwrap_or_default()),
"Dword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0) as u32)),
"Qword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0))),
"MultiString" => {
let strings: Vec<String> = val
.data
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
key.set_value(&name, &strings)
}
"ExpandString" => {
key.set_value(&name, &val.data.as_str().unwrap_or_default())
}
"Delete" => key.delete_value(&name),
_ => Err(std::io::Error::other("Unsupported type")),
};
if let Err(e) = res {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Failed to apply {}: {}", name, e),
"error",
);
}
}
}
Err(e) => {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Failed to create/open key {}: {}", base_path, e),
"error",
);
}
}
}
PostInstallStep::FileCopy { src, dest, .. } => {
let dest_path = expand_win_path(&dest);
let src_is_url = src.starts_with("http://") || src.starts_with("https://");
if src_is_url {
emit_log(
handle,
log_id,
"File Download",
&format!("{}Downloading {:?} to {:?}...", step_prefix, src, dest_path),
"info",
);
let client = reqwest::Client::new();
match client
.get(&src)
.timeout(std::time::Duration::from_secs(60))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await {
if let Some(parent) = dest_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(&dest_path, bytes) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to write to {:?}: {}", dest_path, e),
"error",
);
} else {
emit_log(
handle,
log_id,
"Success",
"File downloaded and saved successfully.",
"success",
);
}
}
}
Ok(resp) => emit_log(
handle,
log_id,
"Download Error",
&format!("HTTP Status: {}", resp.status()),
"error",
),
Err(e) => emit_log(handle, log_id, "Download Error", &e.to_string(), "error"),
}
} else {
let src_path = expand_win_path(&src);
emit_log(
handle,
log_id,
"File Copy",
&format!("{}Copying {:?} to {:?}...", step_prefix, src_path, dest_path),
"info",
);
if let Some(parent) = dest_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::copy(&src_path, &dest_path) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to copy file: {}", e),
"error",
);
} else {
emit_log(handle, log_id, "Success", "File copied successfully.", "success");
}
}
}
PostInstallStep::FileDelete { path, .. } => {
let full_path = expand_win_path(&path);
emit_log(
handle,
log_id,
"File Delete",
&format!("{}Deleting {:?}...", step_prefix, full_path),
"info",
);
if full_path.exists() {
if let Err(e) = fs::remove_file(&full_path) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to delete file: {}", e),
"error",
);
} else {
emit_log(handle, log_id, "Success", "File deleted successfully.", "success");
}
} else {
emit_log(handle, log_id, "File Info", "File does not exist, skipping.", "info");
}
}
PostInstallStep::Command { run, .. } => {
emit_log(
handle,
log_id,
"Command Execution",
&format!("{}Executing: {}", step_prefix, run),
"info",
);
let output = Command::new("cmd")
.arg("/C")
.raw_arg(&run)
.creation_flags(0x08000000)
.output();
match output {
Ok(out) => {
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
emit_log(handle, log_id, "Command Failed", &err, "error");
} else {
emit_log(handle, log_id, "Success", "Command executed successfully.", "success");
}
}
Err(e) => {
emit_log(handle, log_id, "Execution Error", &e.to_string(), "error");
}
}
}
}
if let Some(ms) = delay {
if ms > 0 {
emit_log(
handle,
log_id,
"Post-Install",
&format!("Waiting for {}ms...", ms),
"info",
);
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
}
}
}
Ok(())
}

View File

@@ -0,0 +1 @@
pub mod install_queue;

View File

@@ -1,24 +1,65 @@
use serde::{Deserialize, Serialize};
use base64::Engine;
use std::fs;
use std::process::Command;
use std::os::windows::process::CommandExt;
use tauri::AppHandle;
use crate::emit_log;
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use crate::services::log_service::emit_log;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RegistryValue {
pub v_type: String, // "String", "Dword", "Qword", "MultiString", "ExpandString"
pub data: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum PostInstallStep {
#[serde(rename = "registry_batch")]
RegistryBatch {
root: String,
base_path: String,
values: HashMap<String, RegistryValue>,
delay_ms: Option<u64>,
},
#[serde(rename = "file_copy")]
FileCopy {
src: String,
dest: String,
delay_ms: Option<u64>,
},
#[serde(rename = "file_delete")]
FileDelete {
path: String,
delay_ms: Option<u64>,
},
#[serde(rename = "command")]
Command {
run: String,
delay_ms: Option<u64>,
},
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Software {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
#[serde(default = "default_status")]
pub status: String, // "idle", "pending", "installing", "success", "error"
pub status: String, // "idle", "pending", "installing", "configuring", "success", "error"
#[serde(default = "default_progress")]
pub progress: f32,
#[serde(default = "default_false")]
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
}
fn default_status() -> String { "idle".to_string() }
@@ -139,84 +180,18 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
let script = r#"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Drawing
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$pkgs = Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsUpdateAvailable }
if ($pkgs) {
$wshShell = New-Object -ComObject WScript.Shell
# 预加载开始菜单快捷方式
$startMenuPaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs",
"$env:AppData\Microsoft\Windows\Start Menu\Programs"
)
$lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File
# 预加载注册表项
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$regItems = Get-ItemProperty $registryPaths
$pkgs | ForEach-Object {
$p = $_
$iconUrl = $null
$foundPath = ""
# 策略 1: 寻找并解析开始菜单快捷方式 (去除箭头关键点)
$matchedLnk = $lnkFiles | Where-Object { $_.BaseName -eq $p.Name -or $p.Name -like "*$($_.BaseName)*" } | Select-Object -First 1
if ($matchedLnk) {
try {
# 解析快捷方式指向的真实目标
$target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath
if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) {
$foundPath = $target
} else {
# 如果目标不可读或是 UWP 快捷方式,仍使用快捷方式文件提取
$foundPath = $matchedLnk.FullName
}
} catch {
$foundPath = $matchedLnk.FullName
}
}
# 策略 2: 注册表 DisplayIcon
if (-not $foundPath) {
$matchedReg = $regItems | Where-Object { $_.DisplayName -eq $p.Name -or $_.PSChildName -eq $p.Id } | Select-Object -First 1
if ($matchedReg.DisplayIcon) {
$foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"')
} elseif ($matchedReg.InstallLocation) {
$loc = $matchedReg.InstallLocation.Trim('"')
if (Test-Path $loc) {
$exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1
if ($exe) { $foundPath = $exe.FullName }
}
}
}
# 提取并转 Base64
if ($foundPath -and (Test-Path $foundPath)) {
try {
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath)
$bitmap = $icon.ToBitmap()
$ms = New-Object System.IO.MemoryStream
$bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$base64 = [Convert]::ToBase64String($ms.ToArray())
$iconUrl = "data:image/png;base64,$base64"
$ms.Dispose(); $bitmap.Dispose(); $icon.Dispose()
} catch {}
}
[PSCustomObject]@{
Name = [string]$p.Name;
Id = [string]$p.Id;
InstalledVersion = [string]$p.InstalledVersion;
AvailableVersions = $p.AvailableVersions;
IconUrl = $iconUrl
Name = [string]$_.Name;
Id = [string]$_.Id;
InstalledVersion = [string]$_.InstalledVersion;
AvailableVersions = $_.AvailableVersions;
IconUrl = $null
}
} | ConvertTo-Json -Compress
} else {
@@ -227,6 +202,132 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
execute_powershell(handle, &log_id, "Fetch Updates", script)
}
pub fn get_software_info(handle: &AppHandle, id: &str) -> Option<Software> {
let log_id = format!("get-info-{}", chrono::Local::now().timestamp_millis());
let script = format!(r#"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$pkg = Get-WinGetPackage -Id "{}" -ErrorAction SilentlyContinue
if ($pkg) {{
[PSCustomObject]@{{
Name = [string]$pkg.Name;
Id = [string]$pkg.Id;
InstalledVersion = [string]$pkg.InstalledVersion;
AvailableVersions = @()
}} | ConvertTo-Json -Compress
}}
"#, id);
let res = execute_powershell(handle, &log_id, "Fetch Single Software Info", &script);
res.into_iter().next()
}
pub fn get_cached_or_extract_icon(handle: &AppHandle, id: &str, name: &str) -> Option<String> {
let cache_key = sanitize_cache_key(id);
let icon_dir = get_icon_cache_dir(handle);
let icon_path = icon_dir.join(format!("{}.png", cache_key));
if let Ok(bytes) = fs::read(&icon_path) {
return Some(format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
));
}
let log_id = format!("icon-{}", cache_key);
emit_log(handle, &log_id, "Icon Lookup", &format!("Resolving icon for {}...", id), "info");
let script = format!(r#"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Drawing
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$packageId = @'
{id}
'@
$packageName = @'
{name}
'@
$foundPath = ""
$wshShell = New-Object -ComObject WScript.Shell
$startMenuPaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs",
"$env:AppData\Microsoft\Windows\Start Menu\Programs"
)
$lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File
$matchedLnk = $lnkFiles | Where-Object {{ $_.BaseName -eq $packageName -or $packageName -like "*$($_.BaseName)*" }} | Select-Object -First 1
if ($matchedLnk) {{
try {{
$target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath
if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) {{
$foundPath = $target
}} else {{
$foundPath = $matchedLnk.FullName
}}
}} catch {{
$foundPath = $matchedLnk.FullName
}}
}}
if (-not $foundPath) {{
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$regItems = Get-ItemProperty $registryPaths
$matchedReg = $regItems | Where-Object {{ $_.DisplayName -eq $packageName -or $_.PSChildName -eq $packageId }} | Select-Object -First 1
if ($matchedReg.DisplayIcon) {{
$foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"')
}} elseif ($matchedReg.InstallLocation) {{
$loc = $matchedReg.InstallLocation.Trim('"')
if (Test-Path $loc) {{
$exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1
if ($exe) {{ $foundPath = $exe.FullName }}
}}
}}
}}
if ($foundPath -and (Test-Path $foundPath)) {{
try {{
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath)
if ($icon) {{
$bitmap = $icon.ToBitmap()
$ms = New-Object System.IO.MemoryStream
$bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
[Convert]::ToBase64String($ms.ToArray())
$ms.Dispose()
$bitmap.Dispose()
$icon.Dispose()
}}
}} catch {{}}
}}
"#, id = id, name = name);
let output = Command::new("powershell")
.args(["-NoProfile", "-Command", &script])
.creation_flags(0x08000000)
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let encoded = stdout.trim_start_matches('\u{feff}').trim();
if encoded.is_empty() {
return None;
}
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).ok()?;
if fs::create_dir_all(&icon_dir).is_err() {
return Some(format!("data:image/png;base64,{}", encoded));
}
let _ = fs::write(&icon_path, &bytes);
Some(format!("data:image/png;base64,{}", encoded))
}
fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec<Software> {
emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
@@ -271,11 +372,23 @@ fn parse_json_output(json_str: String) -> Vec<Software> {
vec![]
}
fn get_icon_cache_dir(handle: &AppHandle) -> PathBuf {
let app_data_dir = handle.path().app_data_dir().unwrap_or_default();
app_data_dir.join("icons")
}
fn sanitize_cache_key(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn map_package(p: WingetPackage) -> Software {
Software {
id: p.id,
name: p.name,
description: None,
category: None,
version: p.installed_version,
available_version: p.available_versions.and_then(|v| v.first().cloned()),
icon_url: p.icon_url,
@@ -283,5 +396,7 @@ fn map_package(p: WingetPackage) -> Software {
progress: 0.0,
use_manifest: false,
manifest_url: None,
post_install: None,
post_install_url: None,
}
}

View File

@@ -10,7 +10,18 @@
<path d="M12 3l1.912 5.885h6.19l-5.007 3.638 1.912 5.885L12 14.77l-5.007 3.638 1.912-5.885-5.007-3.638h6.19z"></path>
</svg>
</span>
装机必备
装机常用
</router-link>
<router-link to="/other-software" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="6" rx="1.5"></rect>
<rect x="3" y="14" width="18" height="6" rx="1.5"></rect>
<path d="M7 7h.01"></path>
<path d="M7 17h.01"></path>
</svg>
</span>
网传有关
</router-link>
<router-link to="/updates" class="nav-item">
<span class="nav-icon">
@@ -57,7 +68,7 @@
width: 240px;
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
padding: 40px 20px;
padding: 40px 20px 5px 20px;
display: flex;
flex-direction: column;
}
@@ -66,7 +77,7 @@
font-size: 20px;
font-weight: 700;
margin-bottom: 40px;
padding-left: 20px;
padding-left: 10px;
color: var(--text-main);
}

View File

@@ -35,7 +35,6 @@
<span class="id-badge">{{ software.id }}</span>
</div>
<div class="version-info">
<!-- 情况 1: 已安装且有推荐/最新版本 -->
<template v-if="software.version">
<span class="version-tag">当前: {{ software.version }}</span>
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
@@ -46,7 +45,6 @@
</span>
</template>
<!-- 情况 2: 未安装 -->
<template v-else>
<span v-if="software.recommended_version" class="version-tag recommended">
推荐: {{ software.recommended_version }}
@@ -64,6 +62,19 @@
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
<div class="action-wrapper">
<!-- 后安装配置开关 -->
<div
v-if="software.status === 'idle' && (software.post_install || software.post_install_url)"
class="post-install-toggle"
@click.stop="$emit('togglePostInstall', software.id)"
:title="software.enablePostInstall ? '已开启安装后自动配置' : '已关闭安装后自动配置'"
>
<span class="toggle-label">自动配置</span>
<div class="toggle-switch" :class="{ 'is-active': software.enablePostInstall }">
<div class="toggle-dot"></div>
</div>
</div>
<button
v-if="software.status === 'idle'"
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
@@ -81,12 +92,15 @@
已安装
</button>
<!-- 等待中状态 -->
<div v-else-if="software.status === 'pending'" class="status-pending">
<span class="wait-text">等待中</span>
</div>
<!-- 安装中状态显示进度环和百分比 -->
<div v-else-if="software.status === 'configuring'" class="status-configuring">
<div class="mini-spinner"></div>
<span class="config-text">正在配置...</span>
</div>
<div v-else-if="software.status === 'installing'" class="progress-status">
<div class="progress-ring-container">
<svg viewBox="0 0 32 32" class="ring-svg">
@@ -133,6 +147,9 @@ const props = defineProps<{
progress: number;
actionLabel?: string;
targetVersion?: string;
post_install?: any;
post_install_url?: string;
enablePostInstall?: boolean;
},
actionLabel?: string,
selectable?: boolean,
@@ -140,7 +157,7 @@ const props = defineProps<{
disabled?: boolean
}>();
const emit = defineEmits(['install', 'toggleSelect']);
const emit = defineEmits(['install', 'toggleSelect', 'togglePostInstall']);
const displayProgress = computed(() => {
if (!props.software.progress) return '准备中';
@@ -288,17 +305,6 @@ const handleCardClick = () => {
font-family: monospace;
}
.description {
font-size: 13px;
color: var(--text-sec);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
max-width: 500px;
}
.version-info {
display: flex;
gap: 8px;
@@ -320,10 +326,64 @@ const handleCardClick = () => {
.card-right {
margin-left: 20px;
min-width: 100px;
}
.action-wrapper {
display: flex;
align-items: center;
gap: 16px;
justify-content: flex-end;
}
.post-install-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
transition: background-color 0.2s;
}
.post-install-toggle:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.toggle-label {
font-size: 12px;
color: var(--text-sec);
font-weight: 500;
}
.toggle-switch {
width: 32px;
height: 18px;
background-color: #E5E5EA;
border-radius: 9px;
position: relative;
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-switch.is-active {
background-color: #34C759;
}
.toggle-dot {
width: 14px;
height: 14px;
background-color: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-switch.is-active .toggle-dot {
transform: translateX(14px);
}
.action-btn {
width: 90px;
height: 34px;
@@ -373,6 +433,30 @@ const handleCardClick = () => {
color: #AEAEB2;
}
.status-configuring {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 90px;
justify-content: center;
color: var(--primary-color);
}
.mini-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 122, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.config-text {
font-size: 11px;
font-weight: 600;
}
.wait-text {
font-size: 12px;
font-weight: 600;

View File

@@ -11,6 +11,10 @@ const router = createRouter({
path: '/essentials',
component: () => import('../views/Essentials.vue')
},
{
path: '/other-software',
component: () => import('../views/OtherSoftware.vue')
},
{
path: '/updates',
component: () => import('../views/Updates.vue')

137
src/store/catalog.ts Normal file
View File

@@ -0,0 +1,137 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import type { AppSettings, DashboardSnapshot, SoftwareListItem, SyncEssentialsResult, UpdateCandidate } from './types'
type EssentialsStatusResponse = [string, SoftwareListItem[]]
export const useCatalogStore = defineStore('catalog', {
state: () => ({
essentials: [] as SoftwareListItem[],
essentialsVersion: '',
updates: [] as UpdateCandidate[],
allSoftware: [] as SoftwareListItem[],
settings: {
repo_url: 'https://karlblue.github.io/winget-repo'
} as AppSettings,
loading: false,
isInitialized: false,
initStatus: '正在检查系统环境...',
lastFetched: 0
}),
actions: {
async ensureEssentialsAvailable() {
const cachedRepo = await invoke('get_essentials') as unknown
if (cachedRepo) return true
try {
await invoke('sync_essentials')
return true
} catch (err) {
console.error('Initial sync failed:', err)
return false
}
},
async initializeApp() {
if (this.isInitialized) return
this.initStatus = '正在加载应用配置...'
try {
this.settings = await invoke('get_settings')
this.initStatus = '正在同步 Winget 模块...'
await invoke('initialize_app')
this.isInitialized = true
} catch {
this.initStatus = '环境配置失败,请检查运行日志'
setTimeout(() => { this.isInitialized = true }, 2000)
}
},
async saveSettings(newSettings: AppSettings) {
await invoke('save_settings', { settings: newSettings })
this.settings = newSettings
},
async syncEssentials() {
this.loading = true
try {
const result = await invoke('sync_essentials') as SyncEssentialsResult
await this.fetchEssentials()
return result
} finally {
this.loading = false
}
},
async fetchEssentials() {
await this.ensureEssentialsAvailable()
let response = await invoke('get_essentials_status') as EssentialsStatusResponse
if (response && Array.isArray(response[1])) {
this.essentialsVersion = response[0] || ''
this.essentials = response[1]
} else {
this.essentials = []
this.essentialsVersion = ''
}
},
async fetchUpdates() {
this.loading = true
try {
const res = await invoke('get_update_candidates')
this.updates = res as UpdateCandidate[]
await this.loadIconsForUpdates()
} finally {
this.loading = false
}
},
async syncDataIfNeeded(force = false) {
const now = Date.now()
const cacheTimeout = 5 * 60 * 1000
if (!force && this.allSoftware.length > 0 && (now - this.lastFetched < cacheTimeout)) {
if (this.essentials.length === 0) await this.fetchEssentials()
return
}
await this.fetchAllData()
},
async fetchAllData() {
this.loading = true
try {
await this.ensureEssentialsAvailable()
const snapshot = await invoke('get_dashboard_snapshot') as DashboardSnapshot
this.applyDashboardSnapshot(snapshot)
await this.loadIconsForUpdates()
this.lastFetched = Date.now()
} finally {
this.loading = false
}
},
applyDashboardSnapshot(snapshot: DashboardSnapshot) {
this.essentialsVersion = snapshot.essentials_version
this.essentials = snapshot.essentials
this.updates = snapshot.updates
this.allSoftware = snapshot.installed_software
},
async loadIconsForUpdates() {
const targets = this.updates.filter(item => !item.icon_url && item.id && item.name)
await Promise.allSettled(targets.map(async (item) => {
const iconUrl = await invoke('get_software_icon', { id: item.id, name: item.name }) as string | null
if (!iconUrl) return
const target = this.updates.find(update => update.id === item.id)
if (target) {
target.icon_url = iconUrl
}
}))
},
findSoftware(id: string) {
return this.essentials.find(s => s.id === id)
|| this.updates.find(s => s.id === id)
|| this.allSoftware.find(s => s.id === id)
}
}
})

View File

@@ -1,322 +1,191 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { defineStore, storeToRefs } from 'pinia'
import { computed } from 'vue'
const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' });
import { useCatalogStore } from './catalog'
import { useTaskRuntimeStore } from './taskRuntime'
// 版本比对工具函数
const compareVersions = (v1: string | null | undefined, v2: string | null | undefined) => {
if (!v1 || !v2) return 0;
if (v1 === v2) return 0;
const cleanV = (v: string) => v.replace(/^v/i, '').split(/[-+]/)[0].split('.');
const p1 = cleanV(v1);
const p2 = cleanV(v2);
const len = Math.max(p1.length, p2.length);
for (let i = 0; i < len; i++) {
const n1 = parseInt(p1[i] || '0', 10);
const n2 = parseInt(p2[i] || '0', 10);
if (n1 < n2) return -1;
if (n1 > n2) return 1;
export const useSoftwareStore = defineStore('software', () => {
const catalog = useCatalogStore()
const taskRuntime = useTaskRuntimeStore()
const {
essentials,
essentialsVersion,
updates,
allSoftware,
settings,
loading,
isInitialized,
initStatus
} = storeToRefs(catalog)
const {
activeTasks,
selectedEssentialIds,
selectedSpecialIds,
selectedUpdateIds,
logs,
postInstallPrefs
} = storeToRefs(taskRuntime)
const mergeSoftwareItem = (item: typeof essentials.value[number]) => {
const task = activeTasks.value[item.id]
const enablePostInstall = postInstallPrefs.value[item.id] !== false
const baseStatus = item.action_label === '已安装' ? 'installed' : 'idle'
return {
...item,
version: item.version ?? undefined,
recommended_version: item.recommended_version ?? undefined,
available_version: item.available_version ?? undefined,
icon_url: item.icon_url ?? undefined,
manifest_url: item.manifest_url ?? undefined,
post_install_url: item.post_install_url ?? undefined,
category: item.category ?? 'general',
actionLabel: item.action_label,
targetVersion: item.target_version ?? undefined,
status: task ? task.status : baseStatus,
progress: task ? task.progress : 0,
enablePostInstall
}
}
return 0;
};
export interface LogEntry {
id: string; // 日志唯一标识
timestamp: string;
command: string;
output: string;
status: 'info' | 'success' | 'error';
}
const mergedEssentials = computed(() => essentials.value.map(mergeSoftwareItem))
export const useSoftwareStore = defineStore('software', {
state: () => ({
essentials: [] as any[],
essentialsVersion: '',
updates: [] as any[],
allSoftware: [] as any[],
selectedEssentialIds: [] as string[],
selectedUpdateIds: [] as string[],
logs: [] as LogEntry[],
settings: {
repo_url: 'https://karlblue.github.io/winget-repo'
},
activeTasks: {} as Record<string, { status: string, progress: number, targetVersion?: string }>,
loading: false,
isInitialized: false,
initStatus: '正在检查系统环境...',
lastFetched: 0,
refreshTimer: null as any,
batchQueue: [] as string[]
}),
getters: {
mergedEssentials: (state) => {
return state.essentials.map(item => {
const installedInfo = state.allSoftware.find(s => s.id.toLowerCase() === item.id.toLowerCase());
const wingetUpdate = state.updates.find(s => s.id.toLowerCase() === item.id.toLowerCase());
const categorizedEssentials = computed(() => {
return mergedEssentials.value.reduce((acc, item) => {
const category = item.category === 'special' ? 'special' : 'general'
acc[category].push(item)
return acc
}, { general: [] as typeof mergedEssentials.value, special: [] as typeof mergedEssentials.value })
})
const task = state.activeTasks[item.id];
const isInstalled = !!installedInfo;
const currentVersion = installedInfo?.version;
const recommendedVersion = item.version; // 清单里的推荐版本
const availableVersion = wingetUpdate?.available_version; // Winget 查到的最新版
const generalEssentials = computed(() => categorizedEssentials.value.general)
const specialEssentials = computed(() => categorizedEssentials.value.special)
let displayStatus = task ? task.status : 'idle';
let actionLabel = '安装';
let targetVersion = recommendedVersion || availableVersion;
const sortedUpdates = computed(() => [...updates.value].map(item => {
const task = activeTasks.value[item.id]
const enablePostInstall = postInstallPrefs.value[item.id] !== false
if (isInstalled) {
// 逻辑:已安装 >= 推荐 -> 已安装(禁用)
// 逻辑:已安装 < 推荐 -> 更新
const comp = compareVersions(currentVersion, recommendedVersion);
if (comp >= 0) {
displayStatus = task ? task.status : 'installed';
actionLabel = '已安装';
targetVersion = undefined; // 禁用安装
} else {
actionLabel = '更新';
targetVersion = recommendedVersion;
}
} else {
actionLabel = '安装';
targetVersion = recommendedVersion || availableVersion;
}
return {
...item,
version: currentVersion,
recommended_version: recommendedVersion,
available_version: availableVersion,
status: displayStatus,
progress: task ? task.progress : 0,
actionLabel,
targetVersion // 传递给视图,用于点击安装
};
});
},
sortedUpdates: (state) => {
return [...state.updates].map(item => {
const task = state.activeTasks[item.id];
return {
...item,
status: task ? task.status : 'idle',
progress: task ? task.progress : 0,
actionLabel: '更新',
targetVersion: item.available_version // 更新页面永远追求最新版
};
}).sort(sortByName);
},
isBusy: (state) => {
return state.loading || Object.values(state.activeTasks).some(task =>
task.status === 'pending' || task.status === 'installing'
);
return {
...item,
version: item.version ?? undefined,
recommended_version: item.recommended_version ?? undefined,
available_version: item.available_version ?? undefined,
icon_url: item.icon_url ?? undefined,
manifest_url: item.manifest_url ?? undefined,
post_install_url: item.post_install_url ?? undefined,
actionLabel: item.action_label,
targetVersion: item.target_version ?? undefined,
status: task ? task.status : 'idle',
progress: task ? task.progress : 0,
enablePostInstall
}
},
actions: {
// ... (initializeApp, saveSettings, syncEssentials stay the same)
async initializeApp() {
if (this.isInitialized) return;
this.initStatus = '正在加载应用配置...';
try {
this.settings = await invoke('get_settings');
this.initStatus = '正在同步 Winget 模块...';
await invoke('initialize_app');
this.isInitialized = true;
} catch (err) {
this.initStatus = '环境配置失败,请检查运行日志';
setTimeout(() => { this.isInitialized = true; }, 2000);
}
},
}).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' })))
async saveSettings(newSettings: any) {
await invoke('save_settings', { settings: newSettings });
this.settings = newSettings;
},
const isBusy = computed(() => loading.value || taskRuntime.isTaskBusy)
async syncEssentials() {
this.loading = true;
try {
await invoke('sync_essentials');
await this.fetchEssentials();
} finally {
this.loading = false;
}
},
const toggleSelection = (id: string, type: 'essential' | 'special' | 'update') => {
if (isBusy.value) return
taskRuntime.toggleSelection(id, type)
}
toggleSelection(id: string, type: 'essential' | 'update') {
if (this.isBusy) return;
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds;
const index = list.indexOf(id);
if (index === -1) list.push(id);
else list.splice(index, 1);
},
selectAll(type: 'essential' | 'update') {
if (type === 'essential') {
const selectable = this.mergedEssentials.filter(s => s.actionLabel !== '已安装');
this.selectedEssentialIds = selectable.map(s => s.id);
} else {
this.selectedUpdateIds = this.updates.map(s => s.id);
}
},
deselectAll(type: 'essential' | 'update') {
if (type === 'essential') this.selectedEssentialIds = [];
else this.selectedUpdateIds = [];
},
invertSelection(type: 'essential' | 'update') {
if (type === 'essential') {
const selectable = this.mergedEssentials.filter(s => s.actionLabel !== '已安装').map(s => s.id);
this.selectedEssentialIds = selectable.filter(id => !this.selectedEssentialIds.includes(id));
} else {
const selectable = this.updates.map(s => s.id);
this.selectedUpdateIds = selectable.filter(id => !this.selectedUpdateIds.includes(id));
}
},
async fetchEssentials() {
let repo = await invoke('get_essentials') as any;
if (!repo) {
try {
await invoke('sync_essentials');
repo = await invoke('get_essentials') as any;
} catch (err) {
console.error('Initial sync failed:', err);
}
}
if (repo) {
this.essentials = repo.essentials;
this.essentialsVersion = repo.version;
} else {
this.essentials = [];
this.essentialsVersion = '';
}
},
async fetchUpdates() {
if (this.isBusy) return;
this.loading = true
try {
const res = await invoke('get_updates')
this.updates = res as any[]
if (this.selectedUpdateIds.length === 0) this.selectAll('update');
} finally {
this.loading = false
}
},
async syncDataIfNeeded(force = false) {
if (this.isBusy) return;
const now = Date.now();
const CACHE_TIMEOUT = 5 * 60 * 1000;
if (!force && this.allSoftware.length > 0 && (now - this.lastFetched < CACHE_TIMEOUT)) {
if (this.essentials.length === 0) await this.fetchEssentials();
return;
}
await this.fetchAllData();
},
async fetchAllData() {
this.loading = true;
try {
await this.fetchEssentials();
const [all, updates] = await Promise.all([
invoke('get_installed_software'),
invoke('get_updates')
]);
this.allSoftware = all as any[];
this.updates = updates as any[];
this.lastFetched = Date.now();
if (this.selectedEssentialIds.length === 0) this.selectAll('essential');
} finally {
this.loading = false;
}
},
async install(id: string, targetVersion?: string) {
const software = this.findSoftware(id)
if (software) {
this.activeTasks[id] = { status: 'pending', progress: 0, targetVersion };
try {
await invoke('install_software', {
task: {
id,
version: targetVersion,
use_manifest: software.use_manifest || false,
manifest_url: software.manifest_url || null
}
})
} catch (err) {
console.error('Invoke install failed:', err);
this.activeTasks[id] = { status: 'error', progress: 0 };
}
}
},
// 注册批量任务
startBatch(ids: string[]) {
this.batchQueue = [...ids];
},
// 引入防抖刷新:仅在批量任务全部处理完后的 2 秒执行全量扫描
scheduleDataRefresh() {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(async () => {
await this.fetchAllData();
// 刷新完成后清理所有已终结(成功或失败)的任务状态快照
Object.keys(this.activeTasks).forEach(id => {
const status = this.activeTasks[id].status;
if (status === 'success' || status === 'error') {
delete this.activeTasks[id];
}
});
this.refreshTimer = null;
}, 2000);
},
findSoftware(id: string) {
return this.essentials.find(s => s.id === id) ||
this.updates.find(s => s.id === id) ||
this.allSoftware.find(s => s.id === id)
},
initListener() {
if ((window as any).__tauri_listener_init) return;
(window as any).__tauri_listener_init = true;
listen('install-status', (event: any) => {
const { id, status, progress } = event.payload
const task = this.activeTasks[id];
this.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion };
// 当任务达到终态(成功或失败)时
if (status === 'success' || status === 'error') {
if (status === 'success') {
this.lastFetched = 0;
// 立即更新勾选状态,提升响应感
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
}
// 检查是否属于正在进行的批量任务
const index = this.batchQueue.indexOf(id);
if (index !== -1) {
this.batchQueue.splice(index, 1);
// 如果这是批量任务中的最后一个,则触发延迟刷新
if (this.batchQueue.length === 0) {
this.scheduleDataRefresh();
}
}
}
})
listen('log-event', (event: any) => {
const payload = event.payload as LogEntry;
const existingLog = this.logs.find(l => l.id === payload.id);
if (existingLog) {
if (payload.output) existingLog.output += '\n' + payload.output;
if (payload.status !== 'info') existingLog.status = payload.status;
} else {
this.logs.unshift(payload);
if (this.logs.length > 100) this.logs.pop();
}
})
const selectAll = (type: 'essential' | 'special' | 'update') => {
if (type === 'essential') {
const selectable = generalEssentials.value.filter(item => item.actionLabel !== '已安装')
taskRuntime.setSelection('essential', selectable.map(item => item.id))
} else if (type === 'special') {
const selectable = specialEssentials.value.filter(item => item.actionLabel !== '已安装')
taskRuntime.setSelection('special', selectable.map(item => item.id))
} else {
taskRuntime.setSelection('update', updates.value.map(item => item.id))
}
}
const deselectAll = (type: 'essential' | 'special' | 'update') => {
taskRuntime.setSelection(type, [])
}
const invertSelection = (type: 'essential' | 'special' | 'update') => {
if (type === 'essential') {
const selectable = generalEssentials.value
.filter(item => item.actionLabel !== '已安装')
.map(item => item.id)
taskRuntime.setSelection(
'essential',
selectable.filter(id => !selectedEssentialIds.value.includes(id))
)
} else if (type === 'special') {
const selectable = specialEssentials.value
.filter(item => item.actionLabel !== '已安装')
.map(item => item.id)
taskRuntime.setSelection(
'special',
selectable.filter(id => !selectedSpecialIds.value.includes(id))
)
} else {
const selectable = updates.value.map(item => item.id)
taskRuntime.setSelection(
'update',
selectable.filter(id => !selectedUpdateIds.value.includes(id))
)
}
}
const fetchUpdates = async () => {
if (isBusy.value) return
await catalog.fetchUpdates()
if (selectedUpdateIds.value.length === 0) selectAll('update')
}
const syncDataIfNeeded = async (force = false) => {
if (isBusy.value) return
await catalog.syncDataIfNeeded(force)
if (selectedEssentialIds.value.length === 0) selectAll('essential')
if (selectedSpecialIds.value.length === 0) selectAll('special')
}
const fetchAllData = async () => {
await catalog.fetchAllData()
if (selectedEssentialIds.value.length === 0) selectAll('essential')
if (selectedSpecialIds.value.length === 0) selectAll('special')
}
return {
essentials,
essentialsVersion,
updates,
allSoftware,
selectedEssentialIds,
selectedSpecialIds,
selectedUpdateIds,
logs,
settings,
activeTasks,
loading,
isInitialized,
initStatus,
mergedEssentials,
generalEssentials,
specialEssentials,
sortedUpdates,
isBusy,
initializeApp: catalog.initializeApp,
saveSettings: catalog.saveSettings,
syncEssentials: catalog.syncEssentials,
fetchEssentials: catalog.fetchEssentials,
fetchUpdates,
syncDataIfNeeded,
fetchAllData,
install: taskRuntime.install,
togglePostInstallPref: taskRuntime.togglePostInstallPref,
startBatch: taskRuntime.startBatch,
scheduleDataRefresh: taskRuntime.scheduleDataRefresh,
findSoftware: catalog.findSoftware,
initListener: taskRuntime.initListener,
toggleSelection,
selectAll,
deselectAll,
invertSelection
}
})

206
src/store/taskRuntime.ts Normal file
View File

@@ -0,0 +1,206 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { useCatalogStore } from './catalog'
import type { ActiveTaskState, LogEntry, TaskEventPayload, TaskRecord } from './types'
export const useTaskRuntimeStore = defineStore('task-runtime', {
state: () => ({
taskRecords: {} as Record<string, TaskRecord>,
selectedEssentialIds: [] as string[],
selectedSpecialIds: [] as string[],
selectedUpdateIds: [] as string[],
logs: [] as LogEntry[],
refreshTimer: null as ReturnType<typeof setTimeout> | null,
batchQueue: [] as string[],
postInstallPrefs: {} as Record<string, boolean>
}),
getters: {
activeTasks: (state): Record<string, ActiveTaskState> => {
return Object.values(state.taskRecords).reduce<Record<string, ActiveTaskState>>((acc, task) => {
acc[task.softwareId] = {
status: mapTaskToLegacyStatus(task),
progress: task.progress,
targetVersion: task.targetVersion
}
return acc
}, {})
},
isTaskBusy(): boolean {
return Object.values(this.taskRecords).some(task =>
task.status === 'queued' || task.status === 'running'
)
}
},
actions: {
toggleSelection(id: string, type: 'essential' | 'special' | 'update') {
const list = type === 'essential'
? this.selectedEssentialIds
: type === 'special'
? this.selectedSpecialIds
: this.selectedUpdateIds
const index = list.indexOf(id)
if (index === -1) list.push(id)
else list.splice(index, 1)
},
setSelection(type: 'essential' | 'special' | 'update', ids: string[]) {
if (type === 'essential') this.selectedEssentialIds = ids
else if (type === 'special') this.selectedSpecialIds = ids
else this.selectedUpdateIds = ids
},
togglePostInstallPref(id: string) {
const current = this.postInstallPrefs[id] !== false
this.postInstallPrefs[id] = !current
},
startBatch(ids: string[]) {
this.batchQueue = [...ids]
},
async install(id: string, targetVersion?: string) {
const catalog = useCatalogStore()
const updateSoftware = catalog.updates.find(item => item.id.toLowerCase() === id.toLowerCase())
const software = updateSoftware ?? catalog.findSoftware(id)
if (!software) return
const enablePostInstall = updateSoftware ? false : this.postInstallPrefs[id] !== false
try {
await invoke('install_software', {
task: {
id,
version: targetVersion,
use_manifest: software.use_manifest || false,
manifest_url: software.manifest_url || null,
enable_post_install: enablePostInstall
}
})
} catch (err) {
console.error('Invoke install failed:', err)
this.taskRecords[`install-${id}`] = {
taskId: `install-${id}`,
softwareId: id,
taskType: 'install',
status: 'failed',
stage: 'invoke_error',
progress: 0,
targetVersion,
message: String(err)
}
}
},
scheduleDataRefresh() {
const catalog = useCatalogStore()
if (this.refreshTimer) clearTimeout(this.refreshTimer)
this.refreshTimer = setTimeout(async () => {
await catalog.fetchAllData()
Object.keys(this.taskRecords).forEach(taskId => {
const status = this.taskRecords[taskId].status
if (status === 'completed' || status === 'failed') {
delete this.taskRecords[taskId]
}
})
this.refreshTimer = null
}, 2000)
},
initListener() {
if ((window as Window & { __tauri_listener_init?: boolean }).__tauri_listener_init) return
;(window as Window & { __tauri_listener_init?: boolean }).__tauri_listener_init = true
listen('task-event', async (event: { payload: TaskEventPayload }) => {
const catalog = useCatalogStore()
const payload = event.payload
const taskRecord: TaskRecord = {
taskId: payload.task_id,
softwareId: payload.software_id,
taskType: payload.task_type,
status: payload.status,
stage: payload.stage,
progress: payload.progress,
targetVersion: payload.target_version ?? undefined,
message: payload.message ?? undefined
}
this.taskRecords[payload.task_id] = taskRecord
if (payload.status === 'completed' || payload.status === 'failed') {
if (payload.task_type === 'install' && payload.status === 'completed') {
const latestInfo = payload.software_info
if (latestInfo) {
const installedIndex = catalog.allSoftware.findIndex(s => s.id.toLowerCase() === payload.software_id.toLowerCase())
if (installedIndex !== -1) {
catalog.allSoftware[installedIndex] = { ...catalog.allSoftware[installedIndex], ...latestInfo }
} else {
catalog.allSoftware.push(latestInfo)
}
const essentialIndex = catalog.essentials.findIndex(s => s.id.toLowerCase() === payload.software_id.toLowerCase())
if (essentialIndex !== -1) {
catalog.essentials[essentialIndex] = {
...catalog.essentials[essentialIndex],
version: latestInfo.version,
available_version: undefined,
action_label: '已安装',
target_version: undefined
}
}
catalog.updates = catalog.updates.filter(item => item.id.toLowerCase() !== payload.software_id.toLowerCase())
}
this.selectedEssentialIds = this.selectedEssentialIds.filter(item => item !== payload.software_id)
this.selectedSpecialIds = this.selectedSpecialIds.filter(item => item !== payload.software_id)
this.selectedUpdateIds = this.selectedUpdateIds.filter(item => item !== payload.software_id)
setTimeout(() => {
if (this.taskRecords[payload.task_id]?.status === 'completed') {
delete this.taskRecords[payload.task_id]
}
}, 3000)
} else if (payload.task_type !== 'install') {
setTimeout(() => {
if (this.taskRecords[payload.task_id]?.status === payload.status) {
delete this.taskRecords[payload.task_id]
}
}, 1500)
}
if (payload.task_type === 'install') {
const index = this.batchQueue.indexOf(payload.software_id)
if (index !== -1) {
this.batchQueue.splice(index, 1)
if (this.batchQueue.length === 0) {
this.scheduleDataRefresh()
}
}
}
}
})
listen('log-event', (event: { payload: LogEntry }) => {
const payload = event.payload
const existingLog = this.logs.find(item => item.id === payload.id)
if (existingLog) {
if (payload.output) existingLog.output += '\n' + payload.output
if (payload.status !== 'info') existingLog.status = payload.status
} else {
this.logs.unshift(payload)
if (this.logs.length > 100) this.logs.pop()
}
})
}
}
})
function mapTaskToLegacyStatus(task: TaskRecord): string {
if (task.status === 'queued') return 'pending'
if (task.status === 'completed') return 'success'
if (task.status === 'failed') return 'error'
if (task.stage === 'configuring') return 'configuring'
return 'installing'
}

76
src/store/types.ts Normal file
View File

@@ -0,0 +1,76 @@
export interface LogEntry {
id: string
timestamp: string
command: string
output: string
status: 'info' | 'success' | 'error'
}
export interface SyncEssentialsResult {
status: 'updated' | 'cache_used'
message: string
}
export interface DashboardSnapshot {
essentials_version: string
essentials: SoftwareListItem[]
updates: UpdateCandidate[]
installed_software: SoftwareListItem[]
}
export interface SoftwareListItem {
id: string
name: string
description?: string
category?: string | null
version?: string | null
recommended_version?: string | null
available_version?: string | null
icon_url?: string | null
use_manifest?: boolean
manifest_url?: string | null
post_install?: unknown
post_install_url?: string | null
actionLabel?: string
action_label?: string
targetVersion?: string | null
target_version?: string | null
}
export interface UpdateCandidate extends SoftwareListItem {
action_label: string
target_version?: string | null
}
export interface ActiveTaskState {
status: string
progress: number
targetVersion?: string
}
export interface TaskRecord {
taskId: string
softwareId: string
taskType: string
status: string
stage: string
progress: number
targetVersion?: string
message?: string
}
export interface TaskEventPayload {
task_id: string
software_id: string
task_type: string
status: string
stage: string
progress: number
target_version?: string | null
message?: string | null
software_info?: SoftwareListItem | null
}
export interface AppSettings {
repo_url: string
}

View File

@@ -1,67 +1,74 @@
<template>
<main class="content">
<header class="content-header">
<div class="header-left">
<h1>装机必备</h1>
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
</div>
<div class="header-actions">
<button
@click="store.syncDataIfNeeded(true)"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在同步...' : '同步状态' }}
</button>
<button
@click="installSelected"
class="primary-btn action-btn"
:disabled="store.loading || store.isBusy || store.selectedEssentialIds.length === 0"
>
安装/更新所选 ({{ store.selectedEssentialIds.length }})
</button>
</div>
</header>
<!-- 固定标头区域 -->
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>装机常用</h1>
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
</div>
<div class="header-actions">
<button
@click="store.syncDataIfNeeded(true)"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在刷新...' : '刷新状态' }}
</button>
<button
@click="installSelected"
class="primary-btn action-btn"
:disabled="store.loading || store.isBusy || store.selectedEssentialIds.length === 0"
>
安装所选 ({{ store.selectedEssentialIds.length }})
</button>
</div>
</header>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取必备软件列表...</p>
</div>
<!-- 可滚动内容区域 -->
<div class="scroll-content">
<div v-if="store.loading && store.generalEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取软件列表...</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.mergedEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedEssentialIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="id => store.toggleSelection(id, 'essential')"
/>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.generalEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedEssentialIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="store.toggleSelection($event, 'essential')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
@@ -74,14 +81,14 @@ import { onMounted, computed } from 'vue';
const store = useSoftwareStore();
const selectableItems = computed(() => {
return store.mergedEssentials.filter(s => s.status !== 'installed');
return store.generalEssentials.filter(s => s.status !== 'installed');
});
const installSelected = () => {
const ids = [...store.selectedEssentialIds];
store.startBatch(ids);
ids.forEach(id => {
const item = store.mergedEssentials.find(s => s.id === id);
const item = store.generalEssentials.find(s => s.id === id);
if (item) {
store.install(id, item.targetVersion);
}
@@ -97,15 +104,30 @@ onMounted(() => {
<style scoped>
.content {
flex: 1;
padding: 40px 60px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden; /* 关键:禁止最外层滚动 */
}
.sticky-header {
padding: 24px 60px 8px 60px;
background-color: #F5F5F7; /* 与 App.vue 背景色保持一致 */
z-index: 10;
flex-shrink: 0;
}
.scroll-content {
flex: 1;
overflow-y: auto;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
margin-bottom: 16px;
}
.header-left {
@@ -136,16 +158,16 @@ onMounted(() => {
align-items: center;
}
/* 批量选择工具栏 */
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02);
background-color: white; /* 这里的工具栏背景设为白色更清晰 */
border-radius: 12px;
margin-bottom: 20px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {

293
src/views/OtherSoftware.vue Normal file
View File

@@ -0,0 +1,293 @@
<template>
<main class="content">
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>网传有关</h1>
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
</div>
<div class="header-actions">
<button
@click="store.syncDataIfNeeded(true)"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ spinning: store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在刷新...' : '刷新状态' }}
</button>
<button
@click="installSelected"
class="primary-btn action-btn"
:disabled="store.loading || store.isBusy || store.selectedSpecialIds.length === 0"
>
安装所选 ({{ store.selectedSpecialIds.length }})
</button>
</div>
</header>
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedSpecialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('special')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('special')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('special')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div class="scroll-content">
<div v-if="store.loading && store.specialEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取软件列表...</p>
</div>
<div v-else-if="store.specialEssentials.length === 0" class="empty-state">
<p>当前清单没有标记为网传有关的项目</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.specialEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedSpecialIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="store.toggleSelection($event, 'special')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import SoftwareCard from '../components/SoftwareCard.vue'
import { useSoftwareStore } from '../store/software'
const store = useSoftwareStore()
const selectableItems = computed(() => {
return store.specialEssentials.filter(item => item.status !== 'installed')
})
const installSelected = () => {
const ids = [...store.selectedSpecialIds]
store.startBatch(ids)
ids.forEach(id => {
const item = store.specialEssentials.find(software => software.id === id)
if (item) {
store.install(id, item.targetVersion)
}
})
}
onMounted(() => {
store.syncDataIfNeeded()
store.initListener()
})
</script>
<style scoped>
.content {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sticky-header {
padding: 24px 60px 8px 60px;
background-color: #F5F5F7;
z-index: 10;
flex-shrink: 0;
}
.scroll-content {
flex: 1;
overflow-y: auto;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.version-badge {
font-size: 13px;
font-weight: 500;
color: var(--text-sec);
background-color: rgba(0, 0, 0, 0.05);
padding: 4px 10px;
border-radius: 20px;
}
.content-header h1 {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-main);
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: white;
border-radius: 12px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {
font-size: 13px;
color: var(--text-sec);
font-weight: 500;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.text-btn {
background: none;
border: none;
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
}
.text-btn:hover:not(:disabled) {
background-color: rgba(0, 122, 255, 0.08);
}
.text-btn:disabled {
color: #AEAEB2;
cursor: not-allowed;
}
.divider {
width: 1px;
height: 12px;
background-color: var(--border-color);
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 12px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
white-space: nowrap;
}
.primary-btn {
background-color: var(--primary-color);
color: white;
box-shadow: var(--btn-shadow);
}
.primary-btn:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.secondary-btn {
background-color: var(--bg-light);
color: var(--text-main);
border: 1px solid var(--border-color);
}
.secondary-btn:hover:not(:disabled) {
background-color: white;
border-color: var(--primary-color);
color: var(--primary-color);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.icon {
display: flex;
align-items: center;
}
.icon.spinning {
animation: spin 1s linear infinite;
}
.software-list {
display: flex;
flex-direction: column;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-sec);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(0, 122, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -12,7 +12,7 @@
<div class="setting-item">
<div class="setting-info">
<label>仓库地址</label>
<p>应用将从该地址同步装机必备软件清单</p>
<p>应用将从该地址同步软件清单</p>
</div>
<div class="setting-action">
<input
@@ -47,8 +47,8 @@
<section class="settings-section">
<h3 class="section-title">关于</h3>
<div class="settings-card about-card">
<p>Windows 软件管理 v0.1.0</p>
<p class="hint">基于 Tauri WinGet 构建的现代化软件管理工具</p>
<p>Windows 软件管理 v{{ version }}</p>
<p class="hint">基于 WinGet 构建的 Windows 软件管理工具</p>
</div>
</section>
</div>
@@ -65,6 +65,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useSoftwareStore } from '../store/software'
import { version } from "../../package.json";
const store = useSoftwareStore()
const tempRepoUrl = ref('')
@@ -106,8 +107,8 @@ const handleSave = async () => {
const handleSync = async () => {
try {
await store.syncEssentials()
showToast('清单同步成功')
const result = await store.syncEssentials()
showToast(result.message, result.status === 'updated' ? 'success' : 'error')
} catch (err) {
showToast('同步失败,请检查网络或地址', 'error')
}

View File

@@ -1,71 +1,78 @@
<template>
<main class="content">
<header class="content-header">
<div class="header-left">
<h1>软件更新</h1>
</div>
<div class="header-actions">
<button
@click="store.fetchUpdates"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在检查...' : '检查更新' }}
</button>
<button
@click="updateSelected"
class="primary-btn action-btn"
:disabled="store.selectedUpdateIds.length === 0 || store.loading || store.isBusy"
>
更新所选 ({{ store.selectedUpdateIds.length }})
</button>
</div>
</header>
<!-- 固定标头区域 -->
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>软件更新</h1>
</div>
<div class="header-actions">
<button
@click="store.fetchUpdates"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在检查...' : '检查更新' }}
</button>
<button
@click="updateSelected"
class="primary-btn action-btn"
:disabled="store.selectedUpdateIds.length === 0 || store.loading || store.isBusy"
>
更新所选 ({{ store.selectedUpdateIds.length }})
</button>
</div>
</header>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('update')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('update')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('update')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('update')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在使用 Winget 扫描可用的更新...</p>
</div>
<!-- 可滚动内容区域 -->
<div class="scroll-content">
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在使用 Winget 扫描可用的更新...</p>
</div>
<div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
<span class="empty-icon"></span>
<p>所有软件已是最新版本</p>
</div>
<div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
<span class="empty-icon"></span>
<p>所有软件已是最新版本</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.sortedUpdates"
:key="item.id"
:software="item"
action-label="更新"
:selectable="true"
:is-selected="store.selectedUpdateIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="id => store.toggleSelection(id, 'update')"
/>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.sortedUpdates"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedUpdateIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="store.toggleSelection($event, 'update')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
@@ -98,15 +105,30 @@ onMounted(() => {
<style scoped>
.content {
flex: 1;
padding: 40px 60px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden; /* 关键:禁止最外层滚动 */
}
.sticky-header {
padding: 24px 60px 8px 60px;
background-color: #F5F5F7;
z-index: 10;
flex-shrink: 0;
}
.scroll-content {
flex: 1;
overflow-y: auto;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
margin-bottom: 16px;
}
.content-header h1 {
@@ -121,16 +143,16 @@ onMounted(() => {
align-items: center;
}
/* 批量选择工具栏 */
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02);
background-color: white;
border-radius: 12px;
margin-bottom: 20px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {