refactor 2

This commit is contained in:
Julian Freeman
2026-04-18 15:58:19 -04:00
parent 2aaa330c9a
commit db377852fc
7 changed files with 276 additions and 71 deletions

View File

@@ -1,6 +1,9 @@
use tauri::AppHandle;
use crate::domain::models::{AppSettings, EssentialsRepo, LogPayload, SyncEssentialsResult};
use crate::domain::models::{
AppSettings, DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, LogPayload, SyncEssentialsResult,
UpdateCandidate,
};
use crate::services::{essentials_service, settings_service, software_state_service};
use crate::winget::Software;
@@ -39,6 +42,21 @@ pub async fn get_updates(app: AppHandle) -> Vec<Software> {
software_state_service::get_updates(app).await
}
#[tauri::command]
pub async fn get_dashboard_snapshot(app: AppHandle) -> DashboardSnapshot {
software_state_service::get_dashboard_snapshot(app).await
}
#[tauri::command]
pub async fn get_essentials_status(app: AppHandle) -> (String, Vec<EssentialsStatusItem>) {
software_state_service::get_essentials_status(app).await
}
#[tauri::command]
pub async fn get_update_candidates(app: AppHandle) -> Vec<UpdateCandidate> {
software_state_service::get_update_candidates(app).await
}
#[tauri::command]
pub async fn get_software_info(app: AppHandle, id: String) -> Option<Software> {
software_state_service::get_software_info(app, id).await

View File

@@ -63,3 +63,44 @@ pub struct ResolvedPostInstall {
pub software: Software,
pub steps: Vec<PostInstallStep>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EssentialsStatusItem {
pub id: String,
pub name: String,
pub description: Option<String>,
pub version: Option<String>,
pub recommended_version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
pub action_label: String,
pub target_version: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct UpdateCandidate {
pub id: String,
pub name: String,
pub description: Option<String>,
pub version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
pub action_label: String,
pub target_version: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct DashboardSnapshot {
pub essentials_version: String,
pub essentials: Vec<EssentialsStatusItem>,
pub updates: Vec<UpdateCandidate>,
pub installed_software: Vec<Software>,
}

View File

@@ -24,6 +24,9 @@ pub fn run() {
commands::app_commands::get_essentials,
commands::app_commands::get_installed_software,
commands::app_commands::get_updates,
commands::app_commands::get_dashboard_snapshot,
commands::app_commands::get_essentials_status,
commands::app_commands::get_update_candidates,
commands::app_commands::get_software_icon,
commands::app_commands::get_software_info,
tasks::install_queue::install_software,

View File

@@ -1,4 +1,5 @@
pub mod essentials_service;
pub mod log_service;
pub mod reconcile_service;
pub mod settings_service;
pub mod software_state_service;

View File

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

View File

@@ -1,6 +1,8 @@
use tauri::AppHandle;
use crate::domain::models::{DashboardSnapshot, EssentialsStatusItem, UpdateCandidate};
use crate::providers::winget_client;
use crate::services::{essentials_service, reconcile_service};
use crate::winget::Software;
pub async fn initialize_app(app: AppHandle) -> Result<bool, String> {
@@ -33,3 +35,28 @@ pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Opti
.await
.unwrap_or(None)
}
pub async fn get_dashboard_snapshot(app: AppHandle) -> DashboardSnapshot {
let repo = essentials_service::get_essentials(&app);
let app_for_installed = app.clone();
let app_for_updates = app.clone();
let installed_handle =
tokio::task::spawn_blocking(move || winget_client::list_installed_packages(&app_for_installed));
let updates_handle =
tokio::task::spawn_blocking(move || winget_client::list_upgrade_candidates(&app_for_updates));
let installed_software = installed_handle.await.unwrap_or_default();
let updates = updates_handle.await.unwrap_or_default();
reconcile_service::build_dashboard_snapshot(repo, installed_software, updates)
}
pub async fn get_essentials_status(app: AppHandle) -> (String, Vec<EssentialsStatusItem>) {
let snapshot = get_dashboard_snapshot(app).await;
(snapshot.essentials_version, snapshot.essentials)
}
pub async fn get_update_candidates(app: AppHandle) -> Vec<UpdateCandidate> {
get_dashboard_snapshot(app).await.updates
}

View File

@@ -2,25 +2,6 @@ import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
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 {
id: string; // 日志唯一标识
timestamp: string;
@@ -34,6 +15,18 @@ interface SyncEssentialsResult {
message: string;
}
interface DashboardSnapshot {
essentials_version: string;
essentials: any[];
updates: any[];
installed_software: any[];
}
interface EssentialsStatusResponse extends Array<any> {
0: string;
1: any[];
}
export const useSoftwareStore = defineStore('software', {
state: () => ({
essentials: [] as any[],
@@ -58,46 +51,14 @@ export const useSoftwareStore = defineStore('software', {
getters: {
mergedEssentials: (state) => {
return state.essentials.map(item => {
const installedInfo = state.allSoftware.find(s => s.id.toLowerCase() === item.id.toLowerCase());
const wingetUpdate = state.updates.find(s => s.id.toLowerCase() === item.id.toLowerCase());
const task = state.activeTasks[item.id];
const isInstalled = !!installedInfo;
const currentVersion = installedInfo?.version;
const recommendedVersion = item.version; // 清单里的推荐版本
const availableVersion = wingetUpdate?.available_version; // Winget 查到的最新版
let displayStatus = task ? task.status : 'idle';
let actionLabel = '安装';
let targetVersion = recommendedVersion || availableVersion;
if (isInstalled) {
const comp = compareVersions(currentVersion, recommendedVersion);
if (comp >= 0) {
displayStatus = task ? task.status : 'installed';
actionLabel = '已安装';
targetVersion = undefined;
} else {
actionLabel = '更新';
targetVersion = recommendedVersion;
}
} else {
actionLabel = '安装';
targetVersion = recommendedVersion || availableVersion;
}
// 获取偏好,默认开启
const enablePostInstall = state.postInstallPrefs[item.id] !== false;
const baseStatus = item.actionLabel === '已安装' ? 'installed' : 'idle';
return {
...item,
version: currentVersion,
recommended_version: recommendedVersion,
available_version: availableVersion,
status: displayStatus,
status: task ? task.status : baseStatus,
progress: task ? task.progress : 0,
actionLabel,
targetVersion,
enablePostInstall
};
});
@@ -110,11 +71,9 @@ export const useSoftwareStore = defineStore('software', {
...item,
status: task ? task.status : 'idle',
progress: task ? task.progress : 0,
actionLabel: '更新',
targetVersion: item.available_version,
enablePostInstall
};
}).sort(sortByName);
}).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' }));
},
isBusy: (state) => {
return state.loading || Object.values(state.activeTasks).some(task =>
@@ -183,18 +142,18 @@ export const useSoftwareStore = defineStore('software', {
},
async fetchEssentials() {
let repo = await invoke('get_essentials') as any;
if (!repo) {
let response = await invoke('get_essentials_status') as EssentialsStatusResponse;
if ((!response || !response[1]) && !(await invoke('get_essentials') as any)) {
try {
await invoke('sync_essentials');
repo = await invoke('get_essentials') as any;
response = await invoke('get_essentials_status') as EssentialsStatusResponse;
} catch (err) {
console.error('Initial sync failed:', err);
}
}
if (repo) {
this.essentials = repo.essentials;
this.essentialsVersion = repo.version;
if (response && Array.isArray(response[1])) {
this.essentialsVersion = response[0] || '';
this.essentials = response[1];
} else {
this.essentials = [];
this.essentialsVersion = '';
@@ -204,7 +163,7 @@ export const useSoftwareStore = defineStore('software', {
if (this.isBusy) return;
this.loading = true
try {
const res = await invoke('get_updates')
const res = await invoke('get_update_candidates')
this.updates = res as any[]
await this.loadIconsForUpdates()
if (this.selectedUpdateIds.length === 0) this.selectAll('update');
@@ -225,13 +184,11 @@ export const useSoftwareStore = defineStore('software', {
async fetchAllData() {
this.loading = true;
try {
await this.fetchEssentials();
const [all, updates] = await Promise.all([
invoke('get_installed_software'),
invoke('get_updates')
]);
this.allSoftware = all as any[];
this.updates = updates as any[];
const snapshot = await invoke('get_dashboard_snapshot') as DashboardSnapshot;
this.essentialsVersion = snapshot.essentials_version;
this.essentials = snapshot.essentials;
this.allSoftware = snapshot.installed_software;
this.updates = snapshot.updates;
await this.loadIconsForUpdates()
this.lastFetched = Date.now();
if (this.selectedEssentialIds.length === 0) this.selectAll('essential');