Compare commits
14 Commits
5717b94c90
...
46c622fd86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c622fd86 | ||
|
|
9cdc371c75 | ||
|
|
517ee39707 | ||
|
|
61caeba242 | ||
|
|
6dde1ea9a7 | ||
|
|
d775e049d6 | ||
|
|
145dad23a5 | ||
|
|
7e550a8d49 | ||
|
|
cf740b9e3a | ||
|
|
b6248bec45 | ||
|
|
50489bb9d4 | ||
|
|
6a360dc14b | ||
|
|
7f2dde8c51 | ||
|
|
a7b5955540 |
@@ -26,6 +26,9 @@ pub struct EssentialsRepo {
|
|||||||
pub struct InstallTask {
|
pub struct InstallTask {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_manifest: bool,
|
||||||
|
pub manifest_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -165,8 +168,11 @@ async fn get_updates(app: AppHandle) -> Vec<Software> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn install_software(id: String, version: Option<String>, state: State<'_, AppState>) -> Result<(), String> {
|
async fn install_software(
|
||||||
state.install_tx.send(InstallTask { id, version }).await.map_err(|e| e.to_string())
|
task: InstallTask,
|
||||||
|
state: State<'_, AppState>
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.install_tx.send(task).await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -194,41 +200,85 @@ pub fn run() {
|
|||||||
let size_re = Regex::new(r"([\d\.]+)\s*[a-zA-Z]+\s*/\s*([\d\.]+)\s*[a-zA-Z]+").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 {
|
while let Some(task) = rx.recv().await {
|
||||||
let id = task.id;
|
let task_id = task.id.clone();
|
||||||
let version = task.version;
|
let task_version = task.version.clone();
|
||||||
|
let use_manifest = task.use_manifest;
|
||||||
|
let manifest_url = task.manifest_url.clone();
|
||||||
|
|
||||||
let log_id = format!("install-{}", id);
|
let log_id = format!("install-{}", task_id);
|
||||||
|
|
||||||
|
// 1. 发送正在安装状态
|
||||||
let _ = handle.emit("install-status", InstallProgress {
|
let _ = handle.emit("install-status", InstallProgress {
|
||||||
id: id.clone(),
|
id: task_id.clone(),
|
||||||
status: "installing".to_string(),
|
status: "installing".to_string(),
|
||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let display_cmd = match &version {
|
let mut args = vec!["install".to_string()];
|
||||||
Some(v) => format!("Winget Install: {} (v{})", id, v),
|
let display_cmd: String;
|
||||||
None => format!("Winget Install: {}", id),
|
let mut temp_manifest_path: Option<PathBuf> = None;
|
||||||
};
|
|
||||||
emit_log(&handle, &log_id, &display_cmd, "Starting...", "info");
|
|
||||||
|
|
||||||
let id_for_cmd = id.clone();
|
if use_manifest && manifest_url.is_some() {
|
||||||
let h = handle.clone();
|
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 mut args = vec![
|
let client = reqwest::Client::new();
|
||||||
"install".to_string(),
|
match client.get(&url).timeout(std::time::Duration::from_secs(15)).send().await {
|
||||||
"--id".to_string(), id_for_cmd.clone(),
|
Ok(resp) if resp.status().is_success() => {
|
||||||
"-e".to_string(),
|
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(),
|
"--silent".to_string(),
|
||||||
"--accept-package-agreements".to_string(),
|
"--accept-package-agreements".to_string(),
|
||||||
"--accept-source-agreements".to_string(),
|
"--accept-source-agreements".to_string(),
|
||||||
"--disable-interactivity".to_string(),
|
"--disable-interactivity".to_string(),
|
||||||
];
|
]);
|
||||||
|
|
||||||
if let Some(v) = version {
|
let full_command = format!("winget {}", args.join(" "));
|
||||||
if !v.is_empty() {
|
emit_log(&handle, &log_id, &display_cmd, &format!("Executing: {}\n---", full_command), "info");
|
||||||
args.push("--version".to_string());
|
|
||||||
args.push(v);
|
let h = handle.clone();
|
||||||
}
|
let current_id = task_id.clone();
|
||||||
}
|
|
||||||
|
|
||||||
let child = Command::new("winget")
|
let child = Command::new("winget")
|
||||||
.args(&args)
|
.args(&args)
|
||||||
@@ -241,31 +291,29 @@ 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();
|
||||||
let clean_line = line_str.trim();
|
let clean_line = line_str.trim();
|
||||||
|
|
||||||
if clean_line.is_empty() { continue; }
|
if clean_line.is_empty() { continue; }
|
||||||
|
|
||||||
let mut is_progress = false;
|
let mut is_progress = false;
|
||||||
|
|
||||||
if let Some(caps) = perc_re.captures(clean_line) {
|
if let Some(caps) = perc_re.captures(clean_line) {
|
||||||
if let Ok(p_val) = caps[1].parse::<f32>() {
|
if let Ok(p_val) = caps[1].parse::<f32>() {
|
||||||
let _ = h.emit("install-status", InstallProgress {
|
let _ = h.emit("install-status", InstallProgress {
|
||||||
id: id_for_cmd.clone(),
|
id: current_id.clone(),
|
||||||
status: "installing".to_string(),
|
status: "installing".to_string(),
|
||||||
progress: p_val / 100.0,
|
progress: p_val / 100.0,
|
||||||
});
|
});
|
||||||
is_progress = true;
|
is_progress = true;
|
||||||
}
|
}
|
||||||
}
|
} else if let Some(caps) = size_re.captures(clean_line) {
|
||||||
else if let Some(caps) = size_re.captures(clean_line) {
|
|
||||||
let current = caps[1].parse::<f32>().unwrap_or(0.0);
|
let current = caps[1].parse::<f32>().unwrap_or(0.0);
|
||||||
let total = caps[2].parse::<f32>().unwrap_or(1.0);
|
let total = caps[2].parse::<f32>().unwrap_or(1.0);
|
||||||
if total > 0.0 {
|
if total > 0.0 {
|
||||||
let _ = h.emit("install-status", InstallProgress {
|
let _ = h.emit("install-status", InstallProgress {
|
||||||
id: id_for_cmd.clone(),
|
id: current_id.clone(),
|
||||||
status: "installing".to_string(),
|
status: "installing".to_string(),
|
||||||
progress: (current / total).min(1.0),
|
progress: (current / total).min(1.0),
|
||||||
});
|
});
|
||||||
@@ -273,14 +321,12 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 净化日志:过滤进度行、单字符动画行以及退格符
|
|
||||||
if !is_progress && clean_line.chars().count() > 1 {
|
if !is_progress && clean_line.chars().count() > 1 {
|
||||||
emit_log(&h, &log_id, "", clean_line, "info");
|
emit_log(&h, &log_id, "", clean_line, "info");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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" }
|
if exit_status { "success" } else { "error" }
|
||||||
},
|
},
|
||||||
@@ -290,13 +336,19 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 2. 发送最终完成/失败状态
|
||||||
let _ = handle.emit("install-status", InstallProgress {
|
let _ = handle.emit("install-status", InstallProgress {
|
||||||
id: 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 {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ pub struct Software {
|
|||||||
pub status: String, // "idle", "pending", "installing", "success", "error"
|
pub status: String, // "idle", "pending", "installing", "success", "error"
|
||||||
#[serde(default = "default_progress")]
|
#[serde(default = "default_progress")]
|
||||||
pub progress: f32,
|
pub progress: f32,
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub use_manifest: bool,
|
||||||
|
pub manifest_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_status() -> String { "idle".to_string() }
|
fn default_status() -> String { "idle".to_string() }
|
||||||
fn default_progress() -> f32 { 0.0 }
|
fn default_progress() -> f32 { 0.0 }
|
||||||
|
fn default_false() -> bool { false }
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
@@ -64,6 +68,7 @@ pub fn ensure_winget_dependencies(handle: &AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
Install-Module -Name Microsoft.WinGet.Client -Force -AllowClobber -Scope CurrentUser -Confirm:$false
|
Install-Module -Name Microsoft.WinGet.Client -Force -AllowClobber -Scope CurrentUser -Confirm:$false
|
||||||
winget source update --accept-source-agreements
|
winget source update --accept-source-agreements
|
||||||
|
winget settings --enable LocalManifestFiles
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let output = Command::new("powershell")
|
let output = Command::new("powershell")
|
||||||
@@ -223,7 +228,7 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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, "Executing PowerShell...", "info");
|
emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
|
||||||
|
|
||||||
let output = Command::new("powershell")
|
let output = Command::new("powershell")
|
||||||
.args(["-NoProfile", "-Command", script])
|
.args(["-NoProfile", "-Command", script])
|
||||||
@@ -276,5 +281,7 @@ fn map_package(p: WingetPackage) -> Software {
|
|||||||
icon_url: p.icon_url,
|
icon_url: p.icon_url,
|
||||||
status: "idle".to_string(),
|
status: "idle".to_string(),
|
||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
|
use_manifest: false,
|
||||||
|
manifest_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'installed-mode': software.status === 'installed',
|
'installed-mode': software.status === 'installed',
|
||||||
'is-selected': isSelected && software.status !== 'installed',
|
'is-selected': isSelected && software.status !== 'installed',
|
||||||
'is-busy': software.status === 'pending' || software.status === 'installing'
|
'is-busy': software.status === 'pending' || software.status === 'installing',
|
||||||
|
'is-disabled': disabled && software.status === 'idle'
|
||||||
}"
|
}"
|
||||||
@click="handleCardClick"
|
@click="handleCardClick"
|
||||||
>
|
>
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
class="checkbox"
|
class="checkbox"
|
||||||
:class="{
|
:class="{
|
||||||
'checked': isSelected,
|
'checked': isSelected,
|
||||||
'disabled': software.status === 'installed' || software.status === 'pending' || software.status === 'installing'
|
'disabled': disabled || software.status === 'installed' || software.status === 'pending' || software.status === 'installing'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span v-if="isSelected">✓</span>
|
<span v-if="isSelected">✓</span>
|
||||||
@@ -34,29 +35,29 @@
|
|||||||
<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: 已安装软件 (包含待更新状态) -->
|
<!-- 情况 1: 已安装且有推荐/最新版本 -->
|
||||||
<template v-if="isInstalled">
|
<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 && isVersionLower(software.version, software.recommended_version)"
|
|
||||||
class="version-tag recommended"
|
|
||||||
>
|
|
||||||
推荐: {{ software.recommended_version }}
|
推荐: {{ software.recommended_version }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
<span v-if="software.available_version && !software.recommended_version" class="version-tag available">
|
||||||
|
最新: {{ software.available_version }}
|
||||||
<!-- 情况 2: 未安装软件 -->
|
|
||||||
<template v-else>
|
|
||||||
<span class="version-tag recommended">
|
|
||||||
推荐: {{ software.recommended_version || '最新版' }}
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 情况 3: WinGet 检测到的仓库最新版本 -->
|
<!-- 情况 2: 未安装 -->
|
||||||
<span class="version-tag available" v-if="software.available_version">
|
<template v-else>
|
||||||
最新: {{ software.available_version }}
|
<span v-if="software.recommended_version" class="version-tag recommended">
|
||||||
</span>
|
推荐: {{ software.recommended_version }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="version-tag recommended">
|
||||||
|
推荐: 最新版
|
||||||
|
</span>
|
||||||
|
<span v-if="software.available_version" class="version-tag available">
|
||||||
|
最新: {{ software.available_version }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,8 +66,9 @@
|
|||||||
<div class="action-wrapper">
|
<div class="action-wrapper">
|
||||||
<button
|
<button
|
||||||
v-if="software.status === 'idle'"
|
v-if="software.status === 'idle'"
|
||||||
@click.stop="$emit('install', software.id)"
|
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
|
||||||
class="action-btn install-btn"
|
class="action-btn install-btn"
|
||||||
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
{{ actionLabel }}
|
{{ actionLabel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -102,9 +104,14 @@
|
|||||||
<span class="check-icon">✓</span> 已完成
|
<span class="check-icon">✓</span> 已完成
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="software.status === 'error'" class="status-error">
|
<button
|
||||||
❌ 失败
|
v-else-if="software.status === 'error'"
|
||||||
</div>
|
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
|
||||||
|
class="action-btn retry-btn"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,14 +126,18 @@ const props = defineProps<{
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
recommended_version?: string;
|
||||||
available_version?: string;
|
available_version?: string;
|
||||||
icon_url?: string;
|
icon_url?: string;
|
||||||
status: string;
|
status: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
actionLabel?: string;
|
||||||
|
targetVersion?: string;
|
||||||
},
|
},
|
||||||
actionLabel?: string,
|
actionLabel?: string,
|
||||||
selectable?: boolean,
|
selectable?: boolean,
|
||||||
isSelected?: boolean
|
isSelected?: boolean,
|
||||||
|
disabled?: boolean
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits(['install', 'toggleSelect']);
|
const emit = defineEmits(['install', 'toggleSelect']);
|
||||||
@@ -136,35 +147,6 @@ const displayProgress = computed(() => {
|
|||||||
return Math.round(props.software.progress * 100) + '%';
|
return Math.round(props.software.progress * 100) + '%';
|
||||||
});
|
});
|
||||||
|
|
||||||
const isInstalled = computed(() => {
|
|
||||||
return props.software.status === 'installed' ||
|
|
||||||
(props.software.status === 'idle' && props.actionLabel === '更新') ||
|
|
||||||
(props.software.status === 'idle' && !props.actionLabel && props.software.installed_version);
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVersionLower = (current: string | undefined | null, target: string | undefined | null) => {
|
|
||||||
if (!current || !target) return false;
|
|
||||||
if (current === target) return false;
|
|
||||||
|
|
||||||
// 简易的版本比对逻辑:按点分割比对数字
|
|
||||||
const v1 = current.split('.');
|
|
||||||
const v2 = target.split('.');
|
|
||||||
const len = Math.max(v1.length, v2.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const n1 = parseInt(v1[i] || '0', 10);
|
|
||||||
const n2 = parseInt(v2[i] || '0', 10);
|
|
||||||
if (isNaN(n1) || isNaN(n2)) {
|
|
||||||
// 如果是非数字字符串比对,直接返回字符串比对结果
|
|
||||||
if (v1[i] !== v2[i]) return v1[i] < v2[i];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (n1 < n2) return true;
|
|
||||||
if (n1 > n2) return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const placeholderColor = computed(() => {
|
const placeholderColor = computed(() => {
|
||||||
const colors = ['#FF9500', '#FF3B30', '#34C759', '#007AFF', '#5856D6', '#AF52DE'];
|
const colors = ['#FF9500', '#FF3B30', '#34C759', '#007AFF', '#5856D6', '#AF52DE'];
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -211,6 +193,11 @@ const handleCardClick = () => {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.software-card.is-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.selection-area {
|
.selection-area {
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -361,6 +348,16 @@ const handleCardClick = () => {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
background-color: rgba(255, 59, 48, 0.05);
|
||||||
|
color: #FF3B30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:hover {
|
||||||
|
background-color: #FF3B30;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.installed-btn {
|
.installed-btn {
|
||||||
background-color: #F2F2F7;
|
background-color: #F2F2F7;
|
||||||
color: #AEAEB2;
|
color: #AEAEB2;
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { listen } from '@tauri-apps/api/event'
|
|||||||
|
|
||||||
const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' });
|
const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' });
|
||||||
|
|
||||||
|
// 版本比对工具函数
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
id: string; // 日志唯一标识
|
id: string; // 日志唯一标识
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -24,51 +41,79 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
settings: {
|
settings: {
|
||||||
repo_url: 'https://karlblue.github.io/winget-repo'
|
repo_url: 'https://karlblue.github.io/winget-repo'
|
||||||
},
|
},
|
||||||
|
activeTasks: {} as Record<string, { status: string, progress: number, targetVersion?: string }>,
|
||||||
loading: false,
|
loading: false,
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
initStatus: '正在检查系统环境...',
|
initStatus: '正在检查系统环境...',
|
||||||
lastFetched: 0
|
lastFetched: 0,
|
||||||
|
refreshTimer: null as any,
|
||||||
|
batchQueue: [] as string[]
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
// ... (mergedEssentials, sortedUpdates, sortedAllSoftware, isBusy getters stay the same)
|
|
||||||
mergedEssentials: (state) => {
|
mergedEssentials: (state) => {
|
||||||
return state.essentials.map(item => {
|
return state.essentials.map(item => {
|
||||||
const installedInfo = state.allSoftware.find(s => s.id.toLowerCase() === item.id.toLowerCase());
|
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 task = state.activeTasks[item.id];
|
||||||
const isInstalled = !!installedInfo;
|
const isInstalled = !!installedInfo;
|
||||||
const hasUpdate = state.updates.some(s => s.id.toLowerCase() === item.id.toLowerCase());
|
const currentVersion = installedInfo?.version;
|
||||||
|
const recommendedVersion = item.version; // 清单里的推荐版本
|
||||||
|
const availableVersion = wingetUpdate?.available_version; // Winget 查到的最新版
|
||||||
|
|
||||||
let displayStatus = item.status;
|
let displayStatus = task ? task.status : 'idle';
|
||||||
let actionLabel = '安装';
|
let actionLabel = '安装';
|
||||||
|
let targetVersion = recommendedVersion || availableVersion;
|
||||||
// 统一字段:version 始终代表当前安装的版本,recommended_version 代表清单推荐的版本
|
|
||||||
const currentVersion = installedInfo ? installedInfo.version : null;
|
|
||||||
const recommendedVersion = item.version;
|
|
||||||
|
|
||||||
if (isInstalled) {
|
if (isInstalled) {
|
||||||
if (hasUpdate) {
|
// 逻辑:已安装 >= 推荐 -> 已安装(禁用)
|
||||||
actionLabel = '更新';
|
// 逻辑:已安装 < 推荐 -> 更新
|
||||||
} else if (displayStatus === 'idle') {
|
const comp = compareVersions(currentVersion, recommendedVersion);
|
||||||
displayStatus = 'installed';
|
if (comp >= 0) {
|
||||||
|
displayStatus = task ? task.status : 'installed';
|
||||||
actionLabel = '已安装';
|
actionLabel = '已安装';
|
||||||
|
targetVersion = undefined; // 禁用安装
|
||||||
|
} else {
|
||||||
|
actionLabel = '更新';
|
||||||
|
targetVersion = recommendedVersion;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
actionLabel = '安装';
|
||||||
|
targetVersion = recommendedVersion || availableVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
version: currentVersion,
|
version: currentVersion,
|
||||||
recommended_version: recommendedVersion,
|
recommended_version: recommendedVersion,
|
||||||
|
available_version: availableVersion,
|
||||||
status: displayStatus,
|
status: displayStatus,
|
||||||
actionLabel
|
progress: task ? task.progress : 0,
|
||||||
|
actionLabel,
|
||||||
|
targetVersion // 传递给视图,用于点击安装
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
sortedUpdates: (state) => [...state.updates].sort(sortByName),
|
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) => {
|
isBusy: (state) => {
|
||||||
const allItems = [...state.essentials, ...state.updates, ...state.allSoftware];
|
return state.loading || Object.values(state.activeTasks).some(task =>
|
||||||
return allItems.some(item => item.status === 'pending' || item.status === 'installing');
|
task.status === 'pending' || task.status === 'installing'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
// ... (initializeApp, saveSettings, syncEssentials stay the same)
|
||||||
async initializeApp() {
|
async initializeApp() {
|
||||||
if (this.isInitialized) return;
|
if (this.isInitialized) return;
|
||||||
this.initStatus = '正在加载应用配置...';
|
this.initStatus = '正在加载应用配置...';
|
||||||
@@ -98,7 +143,6 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ... (Selection methods stay the same)
|
|
||||||
toggleSelection(id: string, type: 'essential' | 'update') {
|
toggleSelection(id: string, type: 'essential' | 'update') {
|
||||||
if (this.isBusy) return;
|
if (this.isBusy) return;
|
||||||
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds;
|
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds;
|
||||||
@@ -108,7 +152,7 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
},
|
},
|
||||||
selectAll(type: 'essential' | 'update') {
|
selectAll(type: 'essential' | 'update') {
|
||||||
if (type === 'essential') {
|
if (type === 'essential') {
|
||||||
const selectable = this.mergedEssentials.filter(s => s.status !== 'installed');
|
const selectable = this.mergedEssentials.filter(s => s.actionLabel !== '已安装');
|
||||||
this.selectedEssentialIds = selectable.map(s => s.id);
|
this.selectedEssentialIds = selectable.map(s => s.id);
|
||||||
} else {
|
} else {
|
||||||
this.selectedUpdateIds = this.updates.map(s => s.id);
|
this.selectedUpdateIds = this.updates.map(s => s.id);
|
||||||
@@ -120,7 +164,7 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
},
|
},
|
||||||
invertSelection(type: 'essential' | 'update') {
|
invertSelection(type: 'essential' | 'update') {
|
||||||
if (type === 'essential') {
|
if (type === 'essential') {
|
||||||
const selectable = this.mergedEssentials.filter(s => s.status !== 'installed').map(s => s.id);
|
const selectable = this.mergedEssentials.filter(s => s.actionLabel !== '已安装').map(s => s.id);
|
||||||
this.selectedEssentialIds = selectable.filter(id => !this.selectedEssentialIds.includes(id));
|
this.selectedEssentialIds = selectable.filter(id => !this.selectedEssentialIds.includes(id));
|
||||||
} else {
|
} else {
|
||||||
const selectable = this.updates.map(s => s.id);
|
const selectable = this.updates.map(s => s.id);
|
||||||
@@ -130,8 +174,6 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
|
|
||||||
async fetchEssentials() {
|
async fetchEssentials() {
|
||||||
let repo = await invoke('get_essentials') as any;
|
let repo = await invoke('get_essentials') as any;
|
||||||
|
|
||||||
// 如果本地没有文件,则尝试联网获取一次
|
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
try {
|
try {
|
||||||
await invoke('sync_essentials');
|
await invoke('sync_essentials');
|
||||||
@@ -140,7 +182,6 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
console.error('Initial sync failed:', err);
|
console.error('Initial sync failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repo) {
|
if (repo) {
|
||||||
this.essentials = repo.essentials;
|
this.essentials = repo.essentials;
|
||||||
this.essentialsVersion = repo.version;
|
this.essentialsVersion = repo.version;
|
||||||
@@ -173,15 +214,11 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
async fetchAllData() {
|
async fetchAllData() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
// 先确保加载了必备清单(内部处理本地缺失逻辑)
|
|
||||||
await this.fetchEssentials();
|
await this.fetchEssentials();
|
||||||
|
|
||||||
// 然后同步本地软件安装/更新状态,不再强制联网下载 JSON
|
|
||||||
const [all, updates] = await Promise.all([
|
const [all, updates] = await Promise.all([
|
||||||
invoke('get_installed_software'),
|
invoke('get_installed_software'),
|
||||||
invoke('get_updates')
|
invoke('get_updates')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.allSoftware = all as any[];
|
this.allSoftware = all as any[];
|
||||||
this.updates = updates as any[];
|
this.updates = updates as any[];
|
||||||
this.lastFetched = Date.now();
|
this.lastFetched = Date.now();
|
||||||
@@ -190,13 +227,50 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async install(id: string) {
|
async install(id: string, targetVersion?: string) {
|
||||||
const software = this.findSoftware(id)
|
const software = this.findSoftware(id)
|
||||||
if (software) {
|
if (software) {
|
||||||
software.status = 'pending';
|
this.activeTasks[id] = { status: 'pending', progress: 0, targetVersion };
|
||||||
await invoke('install_software', { id, version: software.version })
|
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) {
|
findSoftware(id: string) {
|
||||||
return this.essentials.find(s => s.id === id) ||
|
return this.essentials.find(s => s.id === id) ||
|
||||||
this.updates.find(s => s.id === id) ||
|
this.updates.find(s => s.id === id) ||
|
||||||
@@ -208,33 +282,37 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
|
|
||||||
listen('install-status', (event: any) => {
|
listen('install-status', (event: any) => {
|
||||||
const { id, status, progress } = event.payload
|
const { id, status, progress } = event.payload
|
||||||
const software = this.findSoftware(id)
|
const task = this.activeTasks[id];
|
||||||
if (software) {
|
this.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion };
|
||||||
software.status = status
|
|
||||||
software.progress = progress
|
// 当任务达到终态(成功或失败)时
|
||||||
}
|
if (status === 'success' || status === 'error') {
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
this.lastFetched = 0;
|
this.lastFetched = 0;
|
||||||
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
|
// 立即更新勾选状态,提升响应感
|
||||||
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 日志监听:根据 ID 追加内容
|
|
||||||
listen('log-event', (event: any) => {
|
listen('log-event', (event: any) => {
|
||||||
const payload = event.payload as LogEntry;
|
const payload = event.payload as LogEntry;
|
||||||
const existingLog = this.logs.find(l => l.id === payload.id);
|
const existingLog = this.logs.find(l => l.id === payload.id);
|
||||||
|
|
||||||
if (existingLog) {
|
if (existingLog) {
|
||||||
// 如果是增量更新
|
if (payload.output) existingLog.output += '\n' + payload.output;
|
||||||
if (payload.output) {
|
if (payload.status !== 'info') existingLog.status = payload.status;
|
||||||
existingLog.output += '\n' + payload.output;
|
|
||||||
}
|
|
||||||
if (payload.status !== 'info') {
|
|
||||||
existingLog.status = payload.status;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 如果是新日志
|
|
||||||
this.logs.unshift(payload);
|
this.logs.unshift(payload);
|
||||||
if (this.logs.length > 100) this.logs.pop();
|
if (this.logs.length > 100) this.logs.pop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
: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"
|
||||||
@install="store.install"
|
@install="store.install"
|
||||||
@toggle-select="id => store.toggleSelection(id, 'essential')"
|
@toggle-select="id => store.toggleSelection(id, 'essential')"
|
||||||
/>
|
/>
|
||||||
@@ -77,8 +78,13 @@ const selectableItems = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const installSelected = () => {
|
const installSelected = () => {
|
||||||
store.selectedEssentialIds.forEach(id => {
|
const ids = [...store.selectedEssentialIds];
|
||||||
store.install(id);
|
store.startBatch(ids);
|
||||||
|
ids.forEach(id => {
|
||||||
|
const item = store.mergedEssentials.find(s => s.id === id);
|
||||||
|
if (item) {
|
||||||
|
store.install(id, item.targetVersion);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
action-label="更新"
|
action-label="更新"
|
||||||
:selectable="true"
|
:selectable="true"
|
||||||
:is-selected="store.selectedUpdateIds.includes(item.id)"
|
:is-selected="store.selectedUpdateIds.includes(item.id)"
|
||||||
|
:disabled="store.isBusy"
|
||||||
@install="store.install"
|
@install="store.install"
|
||||||
@toggle-select="id => store.toggleSelection(id, 'update')"
|
@toggle-select="id => store.toggleSelection(id, 'update')"
|
||||||
/>
|
/>
|
||||||
@@ -77,8 +78,13 @@ import { onMounted } from 'vue';
|
|||||||
const store = useSoftwareStore();
|
const store = useSoftwareStore();
|
||||||
|
|
||||||
const updateSelected = () => {
|
const updateSelected = () => {
|
||||||
store.selectedUpdateIds.forEach(id => {
|
const ids = [...store.selectedUpdateIds];
|
||||||
store.install(id);
|
store.startBatch(ids);
|
||||||
|
ids.forEach(id => {
|
||||||
|
const item = store.sortedUpdates.find(s => s.id === id);
|
||||||
|
if (item && item.targetVersion) {
|
||||||
|
store.install(id, item.targetVersion);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user