Compare commits

..

12 Commits

Author SHA1 Message Date
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
11 changed files with 732 additions and 176 deletions

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

14
src-tauri/Cargo.lock generated
View File

@@ -859,7 +859,7 @@ dependencies = [
"rustc_version", "rustc_version",
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg 0.55.0",
] ]
[[package]] [[package]]
@@ -5117,6 +5117,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokio", "tokio",
"winreg 0.56.0",
] ]
[[package]] [[package]]
@@ -5592,6 +5593,17 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"

View File

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

View File

@@ -8,8 +8,10 @@ use std::path::PathBuf;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tauri::{AppHandle, Manager, State, Emitter}; use tauri::{AppHandle, Manager, State, Emitter};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use winget::{Software, list_installed_software, list_updates, ensure_winget_dependencies}; use winget::{Software, list_installed_software, list_updates, ensure_winget_dependencies, PostInstallStep};
use regex::Regex; use regex::Regex;
use winreg::RegKey;
use winreg::enums::*;
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct AppSettings { pub struct AppSettings {
@@ -29,8 +31,12 @@ pub struct InstallTask {
#[serde(default)] #[serde(default)]
pub use_manifest: bool, pub use_manifest: bool,
pub manifest_url: Option<String>, pub manifest_url: Option<String>,
#[serde(default = "default_true")]
pub enable_post_install: bool,
} }
fn default_true() -> bool { true }
impl Default for AppSettings { impl Default for AppSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -114,7 +120,6 @@ async fn sync_essentials(app: AppHandle) -> Result<bool, String> {
Ok(response) => { Ok(response) => {
if response.status().is_success() { if response.status().is_success() {
let content = response.text().await.map_err(|e| e.to_string())?; 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); let validation: Result<EssentialsRepo, _> = serde_json::from_str(&content);
if validation.is_ok() { if validation.is_ok() {
let path = get_essentials_path(&app); let path = get_essentials_path(&app);
@@ -175,11 +180,171 @@ async fn install_software(
state.install_tx.send(task).await.map_err(|e| e.to_string()) state.install_tx.send(task).await.map_err(|e| e.to_string())
} }
#[tauri::command]
async fn get_software_info(app: AppHandle, id: String) -> Option<Software> {
tokio::task::spawn_blocking(move || winget::get_software_info(&app, &id)).await.unwrap_or(None)
}
#[tauri::command] #[tauri::command]
fn get_logs_history() -> Vec<LogPayload> { fn get_logs_history() -> Vec<LogPayload> {
vec![] vec![]
} }
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::new(std::io::ErrorKind::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(())
}
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
struct InstallProgress { struct InstallProgress {
id: String, id: String,
@@ -204,10 +369,10 @@ pub fn run() {
let task_version = task.version.clone(); let task_version = task.version.clone();
let use_manifest = task.use_manifest; let use_manifest = task.use_manifest;
let manifest_url = task.manifest_url.clone(); let manifest_url = task.manifest_url.clone();
let enable_post_install_flag = task.enable_post_install;
let log_id = format!("install-{}", task_id); let log_id = format!("install-{}", task_id);
// 1. 发送正在安装状态
let _ = handle.emit("install-status", InstallProgress { let _ = handle.emit("install-status", InstallProgress {
id: task_id.clone(), id: task_id.clone(),
status: "installing".to_string(), status: "installing".to_string(),
@@ -291,7 +456,6 @@ pub fn run() {
Ok(mut child_proc) => { Ok(mut child_proc) => {
if let Some(stdout) = child_proc.stdout.take() { if let Some(stdout) = child_proc.stdout.take() {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
// 使用 split(b'\r') 是为了捕捉 PowerShell 中的动态进度行
for line_res in reader.split(b'\r') { for line_res in reader.split(b'\r') {
if let Ok(line_bytes) = line_res { if let Ok(line_bytes) = line_res {
let line_str = String::from_utf8_lossy(&line_bytes).to_string(); let line_str = String::from_utf8_lossy(&line_bytes).to_string();
@@ -328,7 +492,58 @@ pub fn run() {
} }
} }
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false); let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
if exit_status { "success" } else { "error" } let status_result = if exit_status { "success" } else { "error" };
if status_result == "success" && enable_post_install_flag {
let essentials = get_essentials(handle.clone());
let software_info = essentials.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(&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(&handle, &log_id, "Post-Install", &format!("Successfully fetched remote config with {} steps.", steps.len()), "info");
final_steps = Some(steps);
},
Err(e) => {
emit_log(&handle, &log_id, "Post-Install Error", &format!("JSON Parse Error: {}. Raw Content: {}", e, text), "error");
}
}
}
} else {
emit_log(&handle, &log_id, "Post-Install Error", &format!("Remote config HTTP Error: {}", resp.status()), "error");
}
}
}
if let Some(steps) = final_steps {
let _ = handle.emit("install-status", InstallProgress {
id: task_id.clone(),
status: "configuring".to_string(),
progress: 1.0,
});
emit_log(&handle, &log_id, "Post-Install", "Starting post-installation configuration...", "info");
if let Err(e) = execute_post_install(&handle, &log_id, steps).await {
emit_log(&handle, &log_id, "Post-Install Error", &e, "error");
} else {
emit_log(&handle, &log_id, "Post-Install", "Post-installation configuration completed.", "success");
}
}
}
}
status_result
}, },
Err(e) => { Err(e) => {
emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error"); emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error");
@@ -336,16 +551,13 @@ pub fn run() {
} }
}; };
// 2. 发送最终完成/失败状态
let _ = handle.emit("install-status", InstallProgress { let _ = handle.emit("install-status", InstallProgress {
id: task_id.clone(), id: task_id.clone(),
status: status_result.to_string(), status: status_result.to_string(),
progress: 1.0, progress: 1.0,
}); });
emit_log(&handle, &log_id, "Result", &format!("Execution finished: {}", status_result), if status_result == "success" { "success" } else { "error" }); 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 { if let Some(path) = temp_manifest_path {
let _ = fs::remove_file(path); let _ = fs::remove_file(path);
} }
@@ -362,6 +574,7 @@ pub fn run() {
get_essentials, get_essentials,
get_installed_software, get_installed_software,
get_updates, get_updates,
get_software_info,
install_software, install_software,
get_logs_history get_logs_history
]) ])

View File

@@ -1,9 +1,44 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process::Command; use std::process::Command;
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use std::collections::HashMap;
use tauri::AppHandle; use tauri::AppHandle;
use crate::emit_log; use crate::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)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Software { pub struct Software {
pub id: String, pub id: String,
@@ -13,12 +48,14 @@ pub struct Software {
pub available_version: Option<String>, pub available_version: Option<String>,
pub icon_url: Option<String>, pub icon_url: Option<String>,
#[serde(default = "default_status")] #[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")] #[serde(default = "default_progress")]
pub progress: f32, pub progress: f32,
#[serde(default = "default_false")] #[serde(default = "default_false")]
pub use_manifest: bool, pub use_manifest: bool,
pub manifest_url: Option<String>, pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
} }
fn default_status() -> String { "idle".to_string() } fn default_status() -> String { "idle".to_string() }
@@ -227,6 +264,27 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
execute_powershell(handle, &log_id, "Fetch Updates", script) 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()
}
fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec<Software> { 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"); emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
@@ -283,5 +341,7 @@ fn map_package(p: WingetPackage) -> Software {
progress: 0.0, progress: 0.0,
use_manifest: false, use_manifest: false,
manifest_url: None, manifest_url: None,
post_install: None,
post_install_url: None,
} }
} }

View File

@@ -57,7 +57,7 @@
width: 240px; width: 240px;
background-color: var(--sidebar-bg); background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
padding: 40px 20px; padding: 40px 20px 5px 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -66,7 +66,7 @@
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
margin-bottom: 40px; margin-bottom: 40px;
padding-left: 20px; padding-left: 10px;
color: var(--text-main); color: var(--text-main);
} }

View File

@@ -35,7 +35,6 @@
<span class="id-badge">{{ software.id }}</span> <span class="id-badge">{{ software.id }}</span>
</div> </div>
<div class="version-info"> <div class="version-info">
<!-- 情况 1: 已安装且有推荐/最新版本 -->
<template v-if="software.version"> <template v-if="software.version">
<span class="version-tag">当前: {{ software.version }}</span> <span class="version-tag">当前: {{ software.version }}</span>
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended"> <span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
@@ -46,7 +45,6 @@
</span> </span>
</template> </template>
<!-- 情况 2: 未安装 -->
<template v-else> <template v-else>
<span v-if="software.recommended_version" class="version-tag recommended"> <span v-if="software.recommended_version" class="version-tag recommended">
推荐: {{ software.recommended_version }} 推荐: {{ software.recommended_version }}
@@ -64,6 +62,19 @@
<div class="card-right" v-if="actionLabel || software.status !== 'idle'"> <div class="card-right" v-if="actionLabel || software.status !== 'idle'">
<div class="action-wrapper"> <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 <button
v-if="software.status === 'idle'" v-if="software.status === 'idle'"
@click.stop="$emit('install', software.id, (software as any).targetVersion)" @click.stop="$emit('install', software.id, (software as any).targetVersion)"
@@ -81,12 +92,15 @@
已安装 已安装
</button> </button>
<!-- 等待中状态 -->
<div v-else-if="software.status === 'pending'" class="status-pending"> <div v-else-if="software.status === 'pending'" class="status-pending">
<span class="wait-text">等待中</span> <span class="wait-text">等待中</span>
</div> </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 v-else-if="software.status === 'installing'" class="progress-status">
<div class="progress-ring-container"> <div class="progress-ring-container">
<svg viewBox="0 0 32 32" class="ring-svg"> <svg viewBox="0 0 32 32" class="ring-svg">
@@ -133,6 +147,9 @@ const props = defineProps<{
progress: number; progress: number;
actionLabel?: string; actionLabel?: string;
targetVersion?: string; targetVersion?: string;
post_install?: any;
post_install_url?: string;
enablePostInstall?: boolean;
}, },
actionLabel?: string, actionLabel?: string,
selectable?: boolean, selectable?: boolean,
@@ -140,7 +157,7 @@ const props = defineProps<{
disabled?: boolean disabled?: boolean
}>(); }>();
const emit = defineEmits(['install', 'toggleSelect']); const emit = defineEmits(['install', 'toggleSelect', 'togglePostInstall']);
const displayProgress = computed(() => { const displayProgress = computed(() => {
if (!props.software.progress) return '准备中'; if (!props.software.progress) return '准备中';
@@ -288,17 +305,6 @@ const handleCardClick = () => {
font-family: monospace; 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 { .version-info {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -320,10 +326,64 @@ const handleCardClick = () => {
.card-right { .card-right {
margin-left: 20px; margin-left: 20px;
min-width: 100px; min-width: 100px;
}
.action-wrapper {
display: flex; display: flex;
align-items: center;
gap: 16px;
justify-content: flex-end; 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 { .action-btn {
width: 90px; width: 90px;
height: 34px; height: 34px;
@@ -373,6 +433,30 @@ const handleCardClick = () => {
color: #AEAEB2; 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 { .wait-text {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;

View File

@@ -47,7 +47,8 @@ export const useSoftwareStore = defineStore('software', {
initStatus: '正在检查系统环境...', initStatus: '正在检查系统环境...',
lastFetched: 0, lastFetched: 0,
refreshTimer: null as any, refreshTimer: null as any,
batchQueue: [] as string[] batchQueue: [] as string[],
postInstallPrefs: {} as Record<string, boolean> // 记录用户对每个软件后安装配置的偏好
}), }),
getters: { getters: {
mergedEssentials: (state) => { mergedEssentials: (state) => {
@@ -66,13 +67,11 @@ export const useSoftwareStore = defineStore('software', {
let targetVersion = recommendedVersion || availableVersion; let targetVersion = recommendedVersion || availableVersion;
if (isInstalled) { if (isInstalled) {
// 逻辑:已安装 >= 推荐 -> 已安装(禁用)
// 逻辑:已安装 < 推荐 -> 更新
const comp = compareVersions(currentVersion, recommendedVersion); const comp = compareVersions(currentVersion, recommendedVersion);
if (comp >= 0) { if (comp >= 0) {
displayStatus = task ? task.status : 'installed'; displayStatus = task ? task.status : 'installed';
actionLabel = '已安装'; actionLabel = '已安装';
targetVersion = undefined; // 禁用安装 targetVersion = undefined;
} else { } else {
actionLabel = '更新'; actionLabel = '更新';
targetVersion = recommendedVersion; targetVersion = recommendedVersion;
@@ -82,6 +81,9 @@ export const useSoftwareStore = defineStore('software', {
targetVersion = recommendedVersion || availableVersion; targetVersion = recommendedVersion || availableVersion;
} }
// 获取偏好,默认开启
const enablePostInstall = state.postInstallPrefs[item.id] !== false;
return { return {
...item, ...item,
version: currentVersion, version: currentVersion,
@@ -90,19 +92,22 @@ export const useSoftwareStore = defineStore('software', {
status: displayStatus, status: displayStatus,
progress: task ? task.progress : 0, progress: task ? task.progress : 0,
actionLabel, actionLabel,
targetVersion // 传递给视图,用于点击安装 targetVersion,
enablePostInstall
}; };
}); });
}, },
sortedUpdates: (state) => { sortedUpdates: (state) => {
return [...state.updates].map(item => { return [...state.updates].map(item => {
const task = state.activeTasks[item.id]; const task = state.activeTasks[item.id];
const enablePostInstall = state.postInstallPrefs[item.id] !== false;
return { return {
...item, ...item,
status: task ? task.status : 'idle', status: task ? task.status : 'idle',
progress: task ? task.progress : 0, progress: task ? task.progress : 0,
actionLabel: '更新', actionLabel: '更新',
targetVersion: item.available_version // 更新页面永远追求最新版 targetVersion: item.available_version,
enablePostInstall
}; };
}).sort(sortByName); }).sort(sortByName);
}, },
@@ -113,7 +118,6 @@ export const useSoftwareStore = defineStore('software', {
} }
}, },
actions: { actions: {
// ... (initializeApp, saveSettings, syncEssentials stay the same)
async initializeApp() { async initializeApp() {
if (this.isInitialized) return; if (this.isInitialized) return;
this.initStatus = '正在加载应用配置...'; this.initStatus = '正在加载应用配置...';
@@ -230,6 +234,9 @@ export const useSoftwareStore = defineStore('software', {
async install(id: string, targetVersion?: string) { async install(id: string, targetVersion?: string) {
const software = this.findSoftware(id) const software = this.findSoftware(id)
if (software) { if (software) {
// 根据偏好决定是否开启后安装配置
const enablePostInstall = this.postInstallPrefs[id] !== false;
this.activeTasks[id] = { status: 'pending', progress: 0, targetVersion }; this.activeTasks[id] = { status: 'pending', progress: 0, targetVersion };
try { try {
await invoke('install_software', { await invoke('install_software', {
@@ -237,7 +244,8 @@ export const useSoftwareStore = defineStore('software', {
id, id,
version: targetVersion, version: targetVersion,
use_manifest: software.use_manifest || false, use_manifest: software.use_manifest || false,
manifest_url: software.manifest_url || null manifest_url: software.manifest_url || null,
enable_post_install: enablePostInstall
} }
}) })
} catch (err) { } catch (err) {
@@ -247,26 +255,26 @@ export const useSoftwareStore = defineStore('software', {
} }
}, },
// 注册批量任务 togglePostInstallPref(id: string) {
const current = this.postInstallPrefs[id] !== false;
this.postInstallPrefs[id] = !current;
},
startBatch(ids: string[]) { startBatch(ids: string[]) {
this.batchQueue = [...ids]; this.batchQueue = [...ids];
}, },
// 引入防抖刷新:仅在批量任务全部处理完后的 2 秒执行全量扫描
scheduleDataRefresh() { scheduleDataRefresh() {
if (this.refreshTimer) clearTimeout(this.refreshTimer); if (this.refreshTimer) clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(async () => { this.refreshTimer = setTimeout(async () => {
await this.fetchAllData(); await this.fetchAllData();
// 刷新完成后清理所有已终结(成功或失败)的任务状态快照
Object.keys(this.activeTasks).forEach(id => { Object.keys(this.activeTasks).forEach(id => {
const status = this.activeTasks[id].status; const status = this.activeTasks[id].status;
if (status === 'success' || status === 'error') { if (status === 'success' || status === 'error') {
delete this.activeTasks[id]; delete this.activeTasks[id];
} }
}); });
this.refreshTimer = null; this.refreshTimer = null;
}, 2000); }, 2000);
}, },
@@ -280,25 +288,41 @@ export const useSoftwareStore = defineStore('software', {
if ((window as any).__tauri_listener_init) return; if ((window as any).__tauri_listener_init) return;
(window as any).__tauri_listener_init = true; (window as any).__tauri_listener_init = true;
listen('install-status', (event: any) => { listen('install-status', async (event: any) => {
const { id, status, progress } = event.payload const { id, status, progress } = event.payload
const task = this.activeTasks[id]; const task = this.activeTasks[id];
this.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion }; this.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion };
// 当任务达到终态(成功或失败)时
if (status === 'success' || status === 'error') { if (status === 'success' || status === 'error') {
if (status === 'success') { if (status === 'success') {
this.lastFetched = 0; try {
// 立即更新勾选状态,提升响应感 const latestInfo = await invoke('get_software_info', { id }) as any;
if (latestInfo) {
const index = this.allSoftware.findIndex(s => s.id.toLowerCase() === id.toLowerCase());
if (index !== -1) {
this.allSoftware[index] = { ...this.allSoftware[index], ...latestInfo };
} else {
this.allSoftware.push(latestInfo);
}
}
} catch (err) {
console.error('Partial refresh failed:', err);
}
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id); this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id); this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
setTimeout(() => {
if (this.activeTasks[id]?.status === 'success') {
delete this.activeTasks[id];
}
}, 3000);
} }
// 检查是否属于正在进行的批量任务
const index = this.batchQueue.indexOf(id); const index = this.batchQueue.indexOf(id);
if (index !== -1) { if (index !== -1) {
this.batchQueue.splice(index, 1); this.batchQueue.splice(index, 1);
// 如果这是批量任务中的最后一个,则触发延迟刷新
if (this.batchQueue.length === 0) { if (this.batchQueue.length === 0) {
this.scheduleDataRefresh(); this.scheduleDataRefresh();
} }

View File

@@ -1,67 +1,74 @@
<template> <template>
<main class="content"> <main class="content">
<header class="content-header"> <!-- 固定标头区域 -->
<div class="header-left"> <div class="sticky-header">
<h1>装机必备</h1> <header class="content-header">
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span> <div class="header-left">
</div> <h1>装机必备</h1>
<div class="header-actions"> <span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
<button </div>
@click="store.syncDataIfNeeded(true)" <div class="header-actions">
class="secondary-btn action-btn" <button
:disabled="store.loading || store.isBusy" @click="store.syncDataIfNeeded(true)"
> class="secondary-btn action-btn"
<span class="icon" :class="{ 'spinning': store.loading }"> :disabled="store.loading || store.isBusy"
<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> <span class="icon" :class="{ 'spinning': store.loading }">
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path> <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="M3 22v-6h6"></path> <path d="M21 2v6h-6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path> <path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
</svg> <path d="M3 22v-6h6"></path>
</span> <path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
{{ store.loading ? '正在刷新...' : '刷新状态' }} </svg>
</button> </span>
<button {{ store.loading ? '正在刷新...' : '刷新状态' }}
@click="installSelected" </button>
class="primary-btn action-btn" <button
:disabled="store.loading || store.isBusy || store.selectedEssentialIds.length === 0" @click="installSelected"
> class="primary-btn action-btn"
安装所选 ({{ store.selectedEssentialIds.length }}) :disabled="store.loading || store.isBusy || store.selectedEssentialIds.length === 0"
</button> >
</div> 安装所选 ({{ store.selectedEssentialIds.length }})
</header> </button>
</div>
</header>
<!-- 批量选择控制栏 --> <!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="selectableItems.length > 0"> <div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left"> <div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} </span> <span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} </span>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button> <button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div> <div class="divider"></div>
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button> <button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div> <div class="divider"></div>
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button> <button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div> </div>
</div> </div>
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state"> <!-- 可滚动内容区域 -->
<div class="spinner"></div> <div class="scroll-content">
<p>正在读取必备软件列表...</p> <div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
</div> <div class="spinner"></div>
<p>正在读取必备软件列表...</p>
</div>
<div v-else class="software-list"> <div v-else class="software-list">
<SoftwareCard <SoftwareCard
v-for="item in store.mergedEssentials" v-for="item in store.mergedEssentials"
:key="item.id" :key="item.id"
:software="item" :software="item"
:action-label="item.actionLabel" :action-label="item.actionLabel"
:selectable="true" :selectable="true"
:is-selected="store.selectedEssentialIds.includes(item.id)" :is-selected="store.selectedEssentialIds.includes(item.id)"
:disabled="store.isBusy" :disabled="store.isBusy"
@install="store.install" @install="store.install"
@toggle-select="id => store.toggleSelection(id, 'essential')" @toggle-select="store.toggleSelection($event, 'essential')"
/> @toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div> </div>
</main> </main>
</template> </template>
@@ -97,15 +104,30 @@ onMounted(() => {
<style scoped> <style scoped>
.content { .content {
flex: 1; 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; overflow-y: auto;
padding: 8px 60px 32px 60px;
} }
.content-header { .content-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 24px; margin-bottom: 16px;
} }
.header-left { .header-left {
@@ -136,16 +158,16 @@ onMounted(() => {
align-items: center; align-items: center;
} }
/* 批量选择工具栏 */
.selection-toolbar { .selection-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 20px; padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02); background-color: white; /* 这里的工具栏背景设为白色更清晰 */
border-radius: 12px; border-radius: 12px;
margin-bottom: 20px; margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03); border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
} }
.selection-count { .selection-count {

View File

@@ -47,8 +47,8 @@
<section class="settings-section"> <section class="settings-section">
<h3 class="section-title">关于</h3> <h3 class="section-title">关于</h3>
<div class="settings-card about-card"> <div class="settings-card about-card">
<p>Windows 软件管理 v{{ version }}</p> <p>Windows 软件管理 v{{ version }}</p>
<p class="hint">基于 Tauri WinGet 构建的 Windows 软件管理工具</p> <p class="hint">基于 WinGet 构建的 Windows 软件管理工具</p>
</div> </div>
</section> </section>
</div> </div>

View File

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