From 055a88cc0c117525bccd9998e221df04bd58e3e6 Mon Sep 17 00:00:00 2001 From: Howard302 Date: Thu, 10 Sep 2026 11:41:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=AF=E5=8A=A8=E6=97=B6=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=20.wpfhw=20=E7=9B=AE=E5=BD=95=EF=BC=8C=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E8=AE=BE=E7=BD=AE=E5=B9=B6=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运行时在程序目录下生成 .wpfhw,保存下载路径、上次资源类型和窗口大小;项目图标按 URL 哈希写入 cache/icons,后续搜索直接读本地文件。 Co-authored-by: monkeycode-ai --- .gitignore | 5 ++- wpfhw/App.xaml.cs | 2 +- wpfhw/AppSettings.cs | 54 +++++++++++++++++++++++ wpfhw/AppStorage.cs | 29 +++++++++++++ wpfhw/IconCache.cs | 93 ++++++++++++++++++++++++++++++++++++++++ wpfhw/MainWindow.xaml.cs | 73 ++++++++++++++++++++++++++++--- 6 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 wpfhw/AppSettings.cs create mode 100644 wpfhw/AppStorage.cs create mode 100644 wpfhw/IconCache.cs diff --git a/.gitignore b/.gitignore index 9cdf5fb..c27dd51 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,7 @@ wpfhw/obj/ # 系统文件 Thumbs.db -.DS_Store \ No newline at end of file +.DS_Store + +# 运行时数据(设置、图片缓存) +.wpfhw/ \ No newline at end of file diff --git a/wpfhw/App.xaml.cs b/wpfhw/App.xaml.cs index 0401912..9e20bba 100644 --- a/wpfhw/App.xaml.cs +++ b/wpfhw/App.xaml.cs @@ -6,9 +6,9 @@ public partial class App : System.Windows.Application { protected override void OnStartup(StartupEventArgs e) { + AppStorage.Initialize(); base.OnStartup(e); - // 显示启动画面 var splash = new SplashScreen(); splash.Show(); } diff --git a/wpfhw/AppSettings.cs b/wpfhw/AppSettings.cs new file mode 100644 index 0000000..21a2784 --- /dev/null +++ b/wpfhw/AppSettings.cs @@ -0,0 +1,54 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace wpfhw; + +public class AppSettings +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public string DownloadPath { get; set; } = ""; + public string LastProjectType { get; set; } = "mod"; + public double WindowWidth { get; set; } = 1080; + public double WindowHeight { get; set; } = 720; + + public static AppSettings Load() + { + AppStorage.Initialize(); + + try + { + if (File.Exists(AppStorage.SettingsFile)) + { + string json = File.ReadAllText(AppStorage.SettingsFile); + var loaded = JsonSerializer.Deserialize(json, JsonOptions); + if (loaded != null) return loaded; + } + } + catch + { + } + + return new AppSettings(); + } + + public void Save() + { + AppStorage.Initialize(); + + try + { + string json = JsonSerializer.Serialize(this, JsonOptions); + File.WriteAllText(AppStorage.SettingsFile, json); + } + catch + { + } + } +} diff --git a/wpfhw/AppStorage.cs b/wpfhw/AppStorage.cs new file mode 100644 index 0000000..212d8fa --- /dev/null +++ b/wpfhw/AppStorage.cs @@ -0,0 +1,29 @@ +using System.IO; + +namespace wpfhw; + +public static class AppStorage +{ + public static string RootDirectory { get; private set; } = ""; + public static string SettingsFile { get; private set; } = ""; + public static string CacheDirectory { get; private set; } = ""; + public static string IconCacheDirectory { get; private set; } = ""; + + private static bool _initialized; + + public static void Initialize() + { + if (_initialized) return; + + RootDirectory = Path.Combine(AppContext.BaseDirectory, ".wpfhw"); + SettingsFile = Path.Combine(RootDirectory, "settings.json"); + CacheDirectory = Path.Combine(RootDirectory, "cache"); + IconCacheDirectory = Path.Combine(CacheDirectory, "icons"); + + Directory.CreateDirectory(RootDirectory); + Directory.CreateDirectory(CacheDirectory); + Directory.CreateDirectory(IconCacheDirectory); + + _initialized = true; + } +} diff --git a/wpfhw/IconCache.cs b/wpfhw/IconCache.cs new file mode 100644 index 0000000..cffe384 --- /dev/null +++ b/wpfhw/IconCache.cs @@ -0,0 +1,93 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; + +namespace wpfhw; + +public static class IconCache +{ + private static readonly ConcurrentDictionary> InFlight = new(StringComparer.OrdinalIgnoreCase); + + public static string? TryGetLocalPath(string? url) + { + if (!IsRemoteUrl(url)) return null; + + AppStorage.Initialize(); + string path = GetCachePath(url!); + return File.Exists(path) && new FileInfo(path).Length > 0 ? path : null; + } + + public static Task GetLocalPathAsync(string url, HttpClient http, CancellationToken ct) + { + string? cached = TryGetLocalPath(url); + if (cached != null) return Task.FromResult(cached); + + return InFlight.GetOrAdd(url, u => DownloadAsync(u, http, ct)); + } + + private static async Task DownloadAsync(string url, HttpClient http, CancellationToken ct) + { + AppStorage.Initialize(); + string path = GetCachePath(url); + string temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + + try + { + if (File.Exists(path) && new FileInfo(path).Length > 0) + return path; + + using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + await using (var remote = await response.Content.ReadAsStreamAsync(ct)) + await using (var local = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true)) + { + await remote.CopyToAsync(local, ct); + } + + File.Move(temp, path, overwrite: true); + return path; + } + catch + { + try { if (File.Exists(temp)) File.Delete(temp); } catch { } + throw; + } + finally + { + InFlight.TryRemove(url, out _); + } + } + + public static bool IsRemoteUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) return false; + return url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + } + + private static string GetCachePath(string url) + { + string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(url))).ToLowerInvariant(); + string ext = GetExtension(url); + return Path.Combine(AppStorage.IconCacheDirectory, hash + ext); + } + + private static string GetExtension(string url) + { + try + { + var uri = new Uri(url); + string ext = Path.GetExtension(uri.AbsolutePath); + if (ext is ".png" or ".jpg" or ".jpeg" or ".webp" or ".gif" or ".bmp" or ".ico") + return ext.ToLowerInvariant(); + } + catch + { + } + + return ".png"; + } +} diff --git a/wpfhw/MainWindow.xaml.cs b/wpfhw/MainWindow.xaml.cs index ee9e85d..765ffa7 100644 --- a/wpfhw/MainWindow.xaml.cs +++ b/wpfhw/MainWindow.xaml.cs @@ -32,6 +32,7 @@ public partial class MainWindow : Window }; private readonly HttpClient _httpClient; + private readonly AppSettings _settings; private ModSearchHit? _selectedMod; private List _currentVersions = new(); private string _currentGameVer = ""; @@ -54,6 +55,7 @@ public partial class MainWindow : Window private readonly Dictionary _pendingByEnglish = new(StringComparer.OrdinalIgnoreCase); private static readonly HashSet LoaderTypes = new() { "mod", "modpack" }; + private static readonly HashSet ProjectTypes = new() { "mod", "resourcepack", "shader", "datapack", "modpack" }; public MainWindow() { @@ -64,14 +66,25 @@ public MainWindow() _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( "ModDownloader/1.0 (haodi0302@qq.com; Windows)"); - _currentProjectType = "mod"; - _downloadPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); + _settings = AppSettings.Load(); + _currentProjectType = ProjectTypes.Contains(_settings.LastProjectType) + ? _settings.LastProjectType + : "mod"; + _downloadPath = !string.IsNullOrWhiteSpace(_settings.DownloadPath) && Directory.Exists(_settings.DownloadPath) + ? _settings.DownloadPath + : Environment.GetFolderPath(Environment.SpecialFolder.Desktop); - UpdateNavStyle(navMod); + if (_settings.WindowWidth >= 640) Width = _settings.WindowWidth; + if (_settings.WindowHeight >= 480) Height = _settings.WindowHeight; + + UpdateNavStyle(GetNavButton(_currentProjectType)); UpdateLoaderVisibility(); + UpdateSearchPlaceholder(); + SaveSettings(); - this.Closed += (s, e) => + Closed += (_, _) => { + SaveSettings(); _downloadCts?.Cancel(); _downloadCts?.Dispose(); _searchCts?.Cancel(); @@ -80,6 +93,53 @@ public MainWindow() }; } + private Button GetNavButton(string projectType) => projectType switch + { + "resourcepack" => navResource, + "shader" => navShader, + "datapack" => navData, + "modpack" => navPack, + _ => navMod + }; + + private void SaveSettings() + { + _settings.DownloadPath = _downloadPath; + _settings.LastProjectType = _currentProjectType; + _settings.WindowWidth = Width; + _settings.WindowHeight = Height; + _settings.Save(); + } + + private string ResolveIconUrl(string url) + { + if (!IconCache.IsRemoteUrl(url)) return url; + + string? local = IconCache.TryGetLocalPath(url); + if (local != null) + return new Uri(local).AbsoluteUri; + + _ = CacheIconInBackground(url); + return url; + } + + private async Task CacheIconInBackground(string url) + { + try + { + await IconCache.GetLocalPathAsync(url, _httpClient, CancellationToken.None); + } + catch + { + } + } + + private ModSearchHit WithCachedIcon(ModSearchHit hit) + { + hit.IconUrl = ResolveIconUrl(hit.IconUrl); + return hit; + } + private bool HasLoaders() => LoaderTypes.Contains(_currentProjectType); private void UpdateLoaderVisibility() @@ -312,7 +372,7 @@ private async Task SearchModrinthByEnglishNames(List mcHits, Can MatchEnglishName = mc.EnglishName }; - lstModResult.Items.Add(ApplyTranslation(hit)); + lstModResult.Items.Add(WithCachedIcon(ApplyTranslation(hit))); _totalHits++; } } @@ -381,7 +441,7 @@ private async Task DoModrinthSearch(string keyword, CancellationToken ct) foreach (var m in searchResult.Hits) { TryMatchPendingByEnglish(m); - lstModResult.Items.Add(ApplyTranslation(m)); + lstModResult.Items.Add(WithCachedIcon(ApplyTranslation(m))); } txtStatusMsg.Text = $"找到 {_totalHits} 个结果,第 {_currentOffset / PageSize + 1} 页(中译仅在通过MC百科路径搜索时应用)"; } @@ -710,6 +770,7 @@ private void BtnBrowseDownloadPath_Click(object sender, RoutedEventArgs e) { _downloadPath = dialog.FolderName; txtDownloadPath.Text = _downloadPath; + SaveSettings(); } }