Compare commits

...

14 Commits

Author SHA1 Message Date
Julian Freeman
46c622fd86 optimize fetch time 2026-03-31 18:51:55 -04:00
Julian Freeman
9cdc371c75 fix bug 2026-03-31 18:38:45 -04:00
Julian Freeman
517ee39707 not sync after every install 2026-03-31 18:28:07 -04:00
Julian Freeman
61caeba242 not log long scripta 2026-03-31 17:16:18 -04:00
Julian Freeman
6dde1ea9a7 enable local manifest 2026-03-31 13:37:23 -04:00
Julian Freeman
d775e049d6 block when checking 2026-03-31 13:30:46 -04:00
Julian Freeman
145dad23a5 fix install logix 2026-03-31 13:15:50 -04:00
Julian Freeman
7e550a8d49 add cmd to log 2026-03-31 11:42:07 -04:00
Julian Freeman
cf740b9e3a fix error bug 2026-03-31 08:45:17 -04:00
Julian Freeman
b6248bec45 fix task bugs 2026-03-31 08:37:10 -04:00
Julian Freeman
50489bb9d4 fix download bug 2026-03-31 01:04:32 -04:00
Julian Freeman
6a360dc14b download file 2026-03-30 23:46:57 -04:00
Julian Freeman
7f2dde8c51 fix bug 2026-03-30 23:06:38 -04:00
Julian Freeman
a7b5955540 support custom manifest 2026-03-30 22:31:49 -04:00
6 changed files with 290 additions and 144 deletions

View File

@@ -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);
let mut args = vec![ emit_log(&handle, &log_id, &display_cmd, "Downloading remote manifest...", "info");
"install".to_string(),
"--id".to_string(), id_for_cmd.clone(), let client = reqwest::Client::new();
"-e".to_string(), 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(), "--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);
}
} }
}); });

View File

@@ -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,
} }
} }

View File

@@ -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;

View File

@@ -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,
status: displayStatus, available_version: availableVersion,
actionLabel status: displayStatus,
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();
} }

View File

@@ -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);
}
}); });
}; };

View File

@@ -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);
}
}); });
}; };