mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-28 21:34:28 +08:00
0.7.9.1
This commit is contained in:
@@ -43,4 +43,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
||||
}
|
||||
|
||||
@@ -390,7 +390,17 @@ public sealed class ComponentRegistry
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true)
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
"智教Hub",
|
||||
"Image",
|
||||
"Info",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free)
|
||||
};
|
||||
|
||||
return new ComponentRegistry(builtIn);
|
||||
|
||||
@@ -977,5 +977,19 @@
|
||||
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
||||
"single_instance.notice.button": "OK",
|
||||
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
|
||||
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
|
||||
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?",
|
||||
"zhijiaohub.settings.source": "Image Source",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
|
||||
"zhijiaohub.settings.sectl": "SECTL Gallery",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
|
||||
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
|
||||
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
|
||||
"zhijiaohub.settings.mirror_source_desc": "If images load slowly or fail, try using mirror acceleration. Mirror acceleration speeds up GitHub access through third-party proxy services.",
|
||||
"zhijiaohub.settings.refresh": "Refresh Settings",
|
||||
"zhijiaohub.settings.auto_refresh": "Auto Refresh",
|
||||
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
|
||||
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
|
||||
"zhijiaohub.settings.about": "About",
|
||||
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
|
||||
}
|
||||
|
||||
@@ -971,5 +971,19 @@
|
||||
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||
"single_instance.notice.button": "确定",
|
||||
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
|
||||
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件,您需要立即重启应用。\n\n是否立即重启?"
|
||||
}
|
||||
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件,您需要立即重启应用。\n\n是否立即重启?",
|
||||
"zhijiaohub.settings.source": "图片源",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland 图库",
|
||||
"zhijiaohub.settings.sectl": "SECTL 图库",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。",
|
||||
"zhijiaohub.settings.mirror_source": "镜像加速",
|
||||
"zhijiaohub.settings.mirror_direct": "直连(GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "镜像加速(推荐)",
|
||||
"zhijiaohub.settings.mirror_source_desc": "如果图片加载缓慢或失败,请尝试使用镜像加速。镜像加速通过第三方代理服务加速 GitHub 访问。",
|
||||
"zhijiaohub.settings.refresh": "刷新设置",
|
||||
"zhijiaohub.settings.auto_refresh": "自动刷新",
|
||||
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
|
||||
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
|
||||
"zhijiaohub.settings.about": "关于",
|
||||
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。"
|
||||
}
|
||||
|
||||
@@ -73,6 +73,17 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
|
||||
|
||||
// 智教Hub组件配置
|
||||
public string ZhiJiaoHubSource { get; set; } = ZhiJiaoHubSources.ClassIsland;
|
||||
|
||||
public string ZhiJiaoHubMirrorSource { get; set; } = ZhiJiaoHubMirrorSources.Direct;
|
||||
|
||||
public bool ZhiJiaoHubAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int ZhiJiaoHubAutoRefreshIntervalMinutes { get; set; } = 30;
|
||||
|
||||
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
@@ -107,3 +118,56 @@ public sealed class ComponentSettingsSnapshot
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
// 智教Hub数据源常量
|
||||
public static class ZhiJiaoHubSources
|
||||
{
|
||||
public const string ClassIsland = "classisland";
|
||||
public const string Sectl = "sectl";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
return value?.ToLowerInvariant() switch
|
||||
{
|
||||
"sectl" => Sectl,
|
||||
_ => ClassIsland
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 智教Hub镜像加速源常量
|
||||
public static class ZhiJiaoHubMirrorSources
|
||||
{
|
||||
public const string Direct = "direct";
|
||||
public const string GhProxy = "gh-proxy";
|
||||
|
||||
public const string GhProxyBaseUrl = "https://gh-proxy.com/";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
return string.Equals(value, GhProxy, StringComparison.OrdinalIgnoreCase)
|
||||
? GhProxy
|
||||
: Direct;
|
||||
}
|
||||
|
||||
public static string ApplyMirror(string url, string? mirrorSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
if (!string.Equals(Normalize(mirrorSource), GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.StartsWith("https://raw.githubusercontent.com/", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GhProxyBaseUrl.TrimEnd('/') + "/" + url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,12 @@ public static class DesktopComponentEditorRegistryFactory
|
||||
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
|
||||
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
|
||||
]
|
||||
}))
|
||||
})),
|
||||
[BuiltInComponentIds.DesktopZhiJiaoHub] = new(
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
context => new ZhiJiaoHubComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d)
|
||||
};
|
||||
|
||||
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
||||
|
||||
@@ -107,7 +107,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||
{
|
||||
@@ -141,7 +142,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
|
||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||
? parsedTagVersion.ToString(3)
|
||||
? FormatVersionText(parsedTagVersion)
|
||||
: release.TagName;
|
||||
|
||||
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
|
||||
@@ -180,7 +181,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||
{
|
||||
@@ -216,7 +218,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
|
||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||
? parsedTagVersion.ToString(3)
|
||||
? FormatVersionText(parsedTagVersion)
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
@@ -740,8 +742,18 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
{
|
||||
var major = Math.Max(0, version.Major);
|
||||
var minor = Math.Max(0, version.Minor);
|
||||
var build = Math.Max(0, version.Build);
|
||||
return new Version(major, minor, build);
|
||||
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
|
||||
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
|
||||
return revision > 0
|
||||
? new Version(major, minor, build, revision)
|
||||
: new Version(major, minor, build);
|
||||
}
|
||||
|
||||
private static string FormatVersionText(Version version)
|
||||
{
|
||||
return version.Revision > 0
|
||||
? version.ToString(4)
|
||||
: version.ToString(3);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
|
||||
@@ -52,6 +52,22 @@ public sealed record ExchangeRateQuery(
|
||||
string? TargetCurrency = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record ZhiJiaoHubQuery(
|
||||
string? Source = null,
|
||||
int? ImageIndex = null,
|
||||
bool ForceRefresh = false,
|
||||
string? MirrorSource = null);
|
||||
|
||||
public sealed record ZhiJiaoHubImageItem(
|
||||
string Name,
|
||||
string Url,
|
||||
int Index);
|
||||
|
||||
public sealed record ZhiJiaoHubSnapshot(
|
||||
IReadOnlyList<ZhiJiaoHubImageItem> Images,
|
||||
int CurrentIndex,
|
||||
string Source);
|
||||
|
||||
public sealed record RecommendationQueryResult<T>(
|
||||
bool Success,
|
||||
T? Data,
|
||||
@@ -285,6 +301,14 @@ public sealed record RecommendationApiOptions
|
||||
public int DefaultBaiduHotSearchCount { get; init; } = 4;
|
||||
|
||||
public int DefaultStcn24ForumPostCount { get; init; } = 4;
|
||||
|
||||
public string ClassIslandHubApiUrl { get; init; } = "https://api.github.com/repos/ClassIsland/classisland-hub/contents/images";
|
||||
|
||||
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/images";
|
||||
|
||||
public string ClassIslandHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/ClassIsland/classisland-hub/main/images/{0}";
|
||||
|
||||
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/images/{0}";
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
@@ -325,5 +349,19 @@ public interface IRecommendationInfoService
|
||||
ExchangeRateQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
|
||||
ZhiJiaoHubQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
|
||||
string source,
|
||||
string mirrorSource,
|
||||
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source);
|
||||
|
||||
bool HasZhiJiaoHubLocalCache(string source);
|
||||
|
||||
void ClearCache();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -53,6 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
Dictionary<string, decimal> Rates,
|
||||
DateTimeOffset ExpireAt,
|
||||
DateTimeOffset FetchedAt);
|
||||
private sealed record ZhiJiaoHubCacheEntry(ZhiJiaoHubSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record ArtworkCandidate(
|
||||
string Title,
|
||||
string? Artist,
|
||||
@@ -80,6 +81,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, ZhiJiaoHubCacheEntry> _zhiJiaoHubCacheBySource =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private int _dailyNewsRotationCursor;
|
||||
|
||||
static RecommendationDataService()
|
||||
@@ -94,7 +97,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
_options = options ?? new RecommendationApiOptions();
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
// 配置 HttpClientHandler 以支持所有 TLS 版本
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
|
||||
System.Security.Authentication.SslProtocols.Tls13,
|
||||
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = _options.RequestTimeout
|
||||
};
|
||||
@@ -128,6 +139,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
_dailyWordCache = null;
|
||||
_stcn24ForumPostsCacheBySource.Clear();
|
||||
_exchangeRateCacheByBaseCurrency.Clear();
|
||||
_zhiJiaoHubCacheBySource.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3194,4 +3206,254 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
? text
|
||||
: $"{text[..maxLength]}...";
|
||||
}
|
||||
|
||||
// 智教Hub相关方法
|
||||
public async Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
|
||||
ZhiJiaoHubQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = query ?? new ZhiJiaoHubQuery();
|
||||
var source = ZhiJiaoHubSources.Normalize(normalizedQuery.Source);
|
||||
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(normalizedQuery.MirrorSource);
|
||||
var cacheKey = $"{source}|{mirrorSource}";
|
||||
|
||||
if (!normalizedQuery.ForceRefresh && TryGetZhiJiaoHubFromCache(cacheKey, out var cached))
|
||||
{
|
||||
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(cached);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await FetchZhiJiaoHubSnapshotAsync(source, mirrorSource, cancellationToken);
|
||||
SetZhiJiaoHubCache(cacheKey, snapshot);
|
||||
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ZhiJiaoHubSnapshot> FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var (owner, repo, path) = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
|
||||
_ => ("ClassIsland", "classisland-hub", "images")
|
||||
};
|
||||
|
||||
var contentsUrl = $"https://api.github.com/repos/{owner}/{repo}/contents/{path}";
|
||||
|
||||
// 如果使用镜像加速,代理 GitHub API 请求
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var images = await FetchImagesFromContentsApi(owner, repo, path, contentsUrl, mirrorSource, cancellationToken);
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("未找到图片文件");
|
||||
}
|
||||
|
||||
// 随机打乱图片顺序
|
||||
var random = new Random();
|
||||
var shuffled = images.OrderBy(_ => random.Next()).ToList();
|
||||
|
||||
// 重新设置索引
|
||||
for (int i = 0; i < shuffled.Count; i++)
|
||||
{
|
||||
var item = shuffled[i];
|
||||
shuffled[i] = item with { Index = i };
|
||||
}
|
||||
|
||||
return new ZhiJiaoHubSnapshot(shuffled, 0, source);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.Message.Contains("403") || ex.Message.Contains("rate limit"))
|
||||
{
|
||||
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new HttpRequestException($"获取图片列表失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(string owner, string repo, string path, string contentsUrl, string mirrorSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, contentsUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
|
||||
request.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json");
|
||||
request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if ((int)response.StatusCode == 403)
|
||||
{
|
||||
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||
}
|
||||
throw new HttpRequestException($"API 返回错误: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||
}
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
|
||||
{
|
||||
var errorMessage = messageNode.GetString();
|
||||
throw new InvalidOperationException($"GitHub API 错误: {errorMessage}");
|
||||
}
|
||||
throw new InvalidOperationException("Invalid response format from GitHub API.");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
var type = ReadString(item, "type");
|
||||
if (type != "file")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = ReadString(item, "name");
|
||||
var downloadUrl = ReadString(item, "download_url");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只处理图片文件
|
||||
var extension = Path.GetExtension(name).ToLowerInvariant();
|
||||
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解码文件名
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
decodedName = Path.GetFileNameWithoutExtension(decodedName);
|
||||
|
||||
// 构造图片 URL
|
||||
string imageUrl;
|
||||
if (!string.IsNullOrWhiteSpace(downloadUrl))
|
||||
{
|
||||
imageUrl = downloadUrl;
|
||||
}
|
||||
else
|
||||
{
|
||||
imageUrl = $"https://raw.githubusercontent.com/{owner}/{repo}/main/{path}/{Uri.EscapeDataString(name)}";
|
||||
}
|
||||
|
||||
// 应用镜像加速到图片 URL
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
|
||||
index++;
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_zhiJiaoHubCacheBySource.TryGetValue(cacheKey, out var cacheEntry) &&
|
||||
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
snapshot = cacheEntry.Snapshot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetZhiJiaoHubCache(string cacheKey, ZhiJiaoHubSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
// 使用较长的缓存时间(1小时),因为图片列表不常变化
|
||||
_zhiJiaoHubCacheBySource[cacheKey] = new ZhiJiaoHubCacheEntry(
|
||||
snapshot,
|
||||
DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(1)));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ZhiJiaoHubCacheService _zhiJiaoHubCacheService = new();
|
||||
|
||||
public async Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
|
||||
string source,
|
||||
string mirrorSource,
|
||||
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
|
||||
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
|
||||
|
||||
try
|
||||
{
|
||||
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
|
||||
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Data == null)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(
|
||||
false,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
result.ErrorMessage ?? "Failed to fetch image list");
|
||||
}
|
||||
|
||||
return await _zhiJiaoHubCacheService.SyncImagesAsync(
|
||||
normalizedSource,
|
||||
result.Data.Images,
|
||||
normalizedMirror,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source)
|
||||
{
|
||||
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
|
||||
return _zhiJiaoHubCacheService.LoadLocalSnapshot(normalizedSource);
|
||||
}
|
||||
|
||||
public bool HasZhiJiaoHubLocalCache(string source)
|
||||
{
|
||||
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
|
||||
return _zhiJiaoHubCacheService.HasLocalCache(normalizedSource);
|
||||
}
|
||||
}
|
||||
|
||||
359
LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
Normal file
359
LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record ZhiJiaoHubLocalImageItem(
|
||||
string Name,
|
||||
string OriginalUrl,
|
||||
string LocalPath,
|
||||
int Index);
|
||||
|
||||
public sealed record ZhiJiaoHubLocalSnapshot(
|
||||
IReadOnlyList<ZhiJiaoHubLocalImageItem> Images,
|
||||
string Source,
|
||||
DateTimeOffset LastUpdated,
|
||||
int TotalCount);
|
||||
|
||||
public sealed record ZhiJiaoHubSyncResult(
|
||||
bool Success,
|
||||
ZhiJiaoHubLocalSnapshot? Snapshot,
|
||||
int DownloadedCount,
|
||||
int SkippedCount,
|
||||
int FailedCount,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
public sealed class ZhiJiaoHubCacheService : IDisposable
|
||||
{
|
||||
private static readonly HttpClient DownloadClient;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _cacheDirectory;
|
||||
private readonly string _manifestPath;
|
||||
private readonly object _manifestLock = new();
|
||||
private bool _isDisposed;
|
||||
|
||||
static ZhiJiaoHubCacheService()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
|
||||
};
|
||||
|
||||
DownloadClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
DownloadClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop/1.0");
|
||||
}
|
||||
|
||||
public ZhiJiaoHubCacheService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dataDirectory = Path.Combine(appData, "LanMountainDesktop", "cache", "zhijiaohub");
|
||||
_cacheDirectory = dataDirectory;
|
||||
_manifestPath = Path.Combine(dataDirectory, "manifest.json");
|
||||
}
|
||||
|
||||
public string CacheDirectory => _cacheDirectory;
|
||||
|
||||
public bool HasLocalCache(string source)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (!File.Exists(_manifestPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
return manifest?.Entries?.ContainsKey(source) == true &&
|
||||
manifest.Entries[source].Images.Count > 0 &&
|
||||
Directory.Exists(GetSourceDirectory(source));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ZhiJiaoHubLocalSnapshot? LoadLocalSnapshot(string source)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (!File.Exists(_manifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
var images = entry.Images
|
||||
.Where(img => File.Exists(Path.Combine(sourceDir, img.LocalFileName)))
|
||||
.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
|
||||
img.Name,
|
||||
img.OriginalUrl,
|
||||
Path.Combine(sourceDir, img.LocalFileName),
|
||||
idx))
|
||||
.ToList();
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ZhiJiaoHubLocalSnapshot(
|
||||
images,
|
||||
source,
|
||||
entry.LastUpdated,
|
||||
images.Count);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ZhiJiaoHubSyncResult> SyncImagesAsync(
|
||||
string source,
|
||||
IReadOnlyList<ZhiJiaoHubImageItem> remoteImages,
|
||||
string mirrorSource,
|
||||
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (remoteImages == null || remoteImages.Count == 0)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, "No images to sync");
|
||||
}
|
||||
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var downloadedCount = 0;
|
||||
var skippedCount = 0;
|
||||
var failedCount = 0;
|
||||
var localImages = new List<CachedImageInfo>();
|
||||
|
||||
var existingFiles = new HashSet<string>(
|
||||
Directory.Exists(sourceDir)
|
||||
? Directory.GetFiles(sourceDir, "*.jpg").Concat(Directory.GetFiles(sourceDir, "*.png")).Concat(Directory.GetFiles(sourceDir, "*.gif"))
|
||||
: Array.Empty<string>(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < remoteImages.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var remoteImage = remoteImages[i];
|
||||
var fileName = GetSafeFileName(remoteImage.Name, remoteImage.Url);
|
||||
var localPath = Path.Combine(sourceDir, fileName);
|
||||
|
||||
progress?.Report((i + 1, remoteImages.Count, $"Downloading {remoteImage.Name}..."));
|
||||
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
skippedCount++;
|
||||
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var downloadUrl = ResolveDownloadUrl(remoteImage.Url, mirrorSource);
|
||||
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var fileStream = File.Create(localPath);
|
||||
await response.Content.CopyToAsync(fileStream, cancellationToken);
|
||||
|
||||
downloadedCount++;
|
||||
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (localImages.Count == 0)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(false, null, downloadedCount, skippedCount, failedCount, "All downloads failed");
|
||||
}
|
||||
|
||||
SaveManifest(source, localImages);
|
||||
|
||||
var snapshot = new ZhiJiaoHubLocalSnapshot(
|
||||
localImages.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
|
||||
img.Name,
|
||||
img.OriginalUrl,
|
||||
Path.Combine(sourceDir, img.LocalFileName),
|
||||
idx)).ToList(),
|
||||
source,
|
||||
DateTimeOffset.UtcNow,
|
||||
localImages.Count);
|
||||
|
||||
return new ZhiJiaoHubSyncResult(true, snapshot, downloadedCount, skippedCount, failedCount);
|
||||
}
|
||||
|
||||
public void ClearCache(string? source = null)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (source != null)
|
||||
{
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
if (Directory.Exists(sourceDir))
|
||||
{
|
||||
Directory.Delete(sourceDir, true);
|
||||
}
|
||||
|
||||
if (File.Exists(_manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
if (manifest?.Entries != null && manifest.Entries.ContainsKey(source))
|
||||
{
|
||||
manifest.Entries.Remove(source);
|
||||
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Directory.Exists(_cacheDirectory))
|
||||
{
|
||||
Directory.Delete(_cacheDirectory, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSourceDirectory(string source)
|
||||
{
|
||||
return Path.Combine(_cacheDirectory, source.ToLowerInvariant().Replace(" ", "-"));
|
||||
}
|
||||
|
||||
private static string GetSafeFileName(string name, string url)
|
||||
{
|
||||
var ext = Path.GetExtension(new Uri(url).AbsolutePath);
|
||||
if (string.IsNullOrEmpty(ext) || ext.Length > 5)
|
||||
{
|
||||
ext = ".jpg";
|
||||
}
|
||||
|
||||
var safeName = string.Concat(name.Split(Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(safeName))
|
||||
{
|
||||
safeName = Guid.NewGuid().ToString("N")[..8];
|
||||
}
|
||||
|
||||
return $"{safeName}{ext}";
|
||||
}
|
||||
|
||||
private static string ResolveDownloadUrl(string originalUrl, string mirrorSource)
|
||||
{
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + originalUrl;
|
||||
}
|
||||
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
private void SaveManifest(string source, List<CachedImageInfo> images)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
CacheManifest manifest;
|
||||
if (File.Exists(_manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions) ?? new CacheManifest();
|
||||
}
|
||||
catch
|
||||
{
|
||||
manifest = new CacheManifest();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
manifest = new CacheManifest();
|
||||
}
|
||||
|
||||
manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
|
||||
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
}
|
||||
|
||||
private sealed class CacheManifest
|
||||
{
|
||||
public Dictionary<string, CacheEntry> Entries { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public List<CachedImageInfo> Images { get; set; }
|
||||
public DateTimeOffset LastUpdated { get; set; }
|
||||
|
||||
public CacheEntry(List<CachedImageInfo> images, DateTimeOffset lastUpdated)
|
||||
{
|
||||
Images = images;
|
||||
LastUpdated = lastUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CachedImageInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string OriginalUrl { get; set; }
|
||||
public string LocalFileName { get; set; }
|
||||
|
||||
public CachedImageInfo(string name, string originalUrl, string localFileName)
|
||||
{
|
||||
Name = name;
|
||||
OriginalUrl = originalUrl;
|
||||
LocalFileName = localFileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.ZhiJiaoHubComponentEditor">
|
||||
<StackPanel Spacing="16">
|
||||
<!-- 数据源选择 -->
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="SourceLabelTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox x:Name="SourceComboBox"
|
||||
Classes="component-editor-select"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="OnSourceSelectionChanged">
|
||||
<ComboBoxItem x:Name="ClassIslandItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="classisland" />
|
||||
<ComboBoxItem x:Name="SectlItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="sectl" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="SourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 镜像加速源选择 -->
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="MirrorSourceLabelTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox x:Name="MirrorSourceComboBox"
|
||||
Classes="component-editor-select"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="OnMirrorSourceSelectionChanged">
|
||||
<ComboBoxItem x:Name="DirectMirrorItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="direct" />
|
||||
<ComboBoxItem x:Name="GhProxyMirrorItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="gh-proxy" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="MirrorSourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 自动刷新设置 -->
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock x:Name="RefreshSettingsLabelTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
|
||||
<!-- 自动刷新开关 -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||
Classes="component-editor-primary-text" />
|
||||
<TextBlock x:Name="AutoRefreshDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
FontSize="12" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch x:Name="AutoRefreshToggle"
|
||||
Grid.Column="1"
|
||||
IsCheckedChanged="OnAutoRefreshChanged" />
|
||||
</Grid>
|
||||
|
||||
<!-- 刷新间隔 -->
|
||||
<StackPanel x:Name="IntervalPanel"
|
||||
Spacing="8">
|
||||
<TextBlock x:Name="IntervalLabelTextBlock"
|
||||
Classes="component-editor-primary-text" />
|
||||
<NumericUpDown x:Name="IntervalNumeric"
|
||||
Classes="component-editor-numeric"
|
||||
Minimum="5"
|
||||
Maximum="1440"
|
||||
Increment="5"
|
||||
ValueChanged="OnIntervalValueChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 说明信息 -->
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Name="AboutLabelTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock x:Name="AboutDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
private bool _suppressEvents;
|
||||
|
||||
public ZhiJiaoHubComponentEditor()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public ZhiJiaoHubComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
ApplyLocalization();
|
||||
LoadState();
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
// 标题
|
||||
SourceLabelTextBlock.Text = L("zhijiaohub.settings.source", "图片源");
|
||||
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
|
||||
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
|
||||
|
||||
// 数据源描述
|
||||
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。");
|
||||
|
||||
// 镜像加速源
|
||||
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
|
||||
DirectMirrorItem.Content = L("zhijiaohub.settings.mirror_direct", "直连(GitHub)");
|
||||
GhProxyMirrorItem.Content = L("zhijiaohub.settings.mirror_ghproxy", "镜像加速(推荐)");
|
||||
MirrorSourceDescriptionTextBlock.Text = L("zhijiaohub.settings.mirror_source_desc",
|
||||
"如果图片加载缓慢或失败,请尝试使用镜像加速。镜像加速通过第三方代理服务加速 GitHub 访问。");
|
||||
|
||||
// 刷新设置
|
||||
RefreshSettingsLabelTextBlock.Text = L("zhijiaohub.settings.refresh", "刷新设置");
|
||||
AutoRefreshLabelTextBlock.Text = L("zhijiaohub.settings.auto_refresh", "自动刷新");
|
||||
AutoRefreshDescriptionTextBlock.Text = L("zhijiaohub.settings.auto_refresh_desc",
|
||||
"定期自动刷新图片列表。");
|
||||
IntervalLabelTextBlock.Text = L("zhijiaohub.settings.interval", "刷新间隔(分钟)");
|
||||
|
||||
// 关于
|
||||
AboutLabelTextBlock.Text = L("zhijiaohub.settings.about", "关于");
|
||||
AboutDescriptionTextBlock.Text = L("zhijiaohub.settings.about_desc",
|
||||
"智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。");
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
_suppressEvents = true;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
|
||||
// 数据源
|
||||
var source = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||
SourceComboBox.SelectedItem = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => SectlItem,
|
||||
_ => ClassIslandItem
|
||||
};
|
||||
|
||||
// 镜像加速源
|
||||
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(snapshot.ZhiJiaoHubMirrorSource);
|
||||
MirrorSourceComboBox.SelectedItem = mirrorSource switch
|
||||
{
|
||||
ZhiJiaoHubMirrorSources.GhProxy => GhProxyMirrorItem,
|
||||
_ => DirectMirrorItem
|
||||
};
|
||||
|
||||
// 自动刷新
|
||||
AutoRefreshToggle.IsChecked = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
|
||||
// 刷新间隔
|
||||
var interval = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||
IntervalNumeric.Value = interval;
|
||||
IntervalPanel.IsVisible = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var source = SourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? ZhiJiaoHubSources.Normalize(tag)
|
||||
: ZhiJiaoHubSources.ClassIsland;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ZhiJiaoHubSource = source;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubSource));
|
||||
}
|
||||
|
||||
private void OnMirrorSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var mirrorSource = MirrorSourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? ZhiJiaoHubMirrorSources.Normalize(tag)
|
||||
: ZhiJiaoHubMirrorSources.Direct;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ZhiJiaoHubMirrorSource = mirrorSource;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubMirrorSource));
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var isEnabled = AutoRefreshToggle.IsChecked ?? true;
|
||||
IntervalPanel.IsVisible = isEnabled;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ZhiJiaoHubAutoRefreshEnabled = isEnabled;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubAutoRefreshEnabled));
|
||||
}
|
||||
|
||||
private void OnIntervalValueChanged(object? sender, NumericUpDownValueChangedEventArgs e)
|
||||
{
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var interval = (int)Math.Clamp(IntervalNumeric.Value ?? 30, 5, 1440);
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes = interval;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubAutoRefreshIntervalMinutes));
|
||||
}
|
||||
}
|
||||
@@ -471,7 +471,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.HolidayCalendar,
|
||||
"component.holiday_calendar",
|
||||
() => new HolidayCalendarWidget())
|
||||
() => new HolidayCalendarWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
"component.zhijiao_hub",
|
||||
() => new ZhiJiaoHubWidget())
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
97
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml
Normal file
97
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml
Normal file
@@ -0,0 +1,97 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="96"
|
||||
d:DesignHeight="96"
|
||||
x:Class="LanMountainDesktop.Views.Components.ZhiJiaoHubWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Background="#1A1A1A">
|
||||
<Grid x:Name="MainGrid">
|
||||
<!-- 图片显示 -->
|
||||
<Image x:Name="CurrentImage"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<!-- 左下角渐变遮罩 -->
|
||||
<Border x:Name="GradientOverlay"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Stretch"
|
||||
Height="60">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#00000000" />
|
||||
<GradientStop Offset="1" Color="#CC000000" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<!-- 图片名称 -->
|
||||
<TextBlock x:Name="ImageNameTextBlock"
|
||||
Text=""
|
||||
Foreground="#FFFFFF"
|
||||
FontSize="11"
|
||||
FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="10,0,10,8" />
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<StackPanel x:Name="LoadingPanel"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8"
|
||||
IsVisible="False">
|
||||
<TextBlock x:Name="LoadingTextBlock"
|
||||
Text="加载中..."
|
||||
Foreground="#AAAAAA"
|
||||
FontSize="12" />
|
||||
<ProgressBar x:Name="LoadingProgressBar"
|
||||
IsIndeterminate="True"
|
||||
Width="60"
|
||||
Height="2"
|
||||
Foreground="#4A9EFF" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<TextBlock x:Name="ErrorTextBlock"
|
||||
Text=""
|
||||
Foreground="#FF6666"
|
||||
FontSize="10"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="10"
|
||||
IsVisible="False" />
|
||||
|
||||
<!-- 指示器 -->
|
||||
<Border x:Name="IndicatorBorder"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,6,0"
|
||||
Background="Transparent">
|
||||
<StackPanel x:Name="IndicatorPanel"
|
||||
Orientation="Vertical"
|
||||
Spacing="4">
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 触摸/鼠标捕获层 -->
|
||||
<Border x:Name="InputCaptureBorder"
|
||||
Background="Transparent"
|
||||
PointerPressed="OnPointerPressed"
|
||||
PointerMoved="OnPointerMoved"
|
||||
PointerReleased="OnPointerReleased"
|
||||
PointerWheelChanged="OnPointerWheelChanged" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
744
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs
Normal file
744
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs
Normal file
@@ -0,0 +1,744 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ZhiJiaoHubWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
IRecommendationInfoAwareComponentWidget,
|
||||
IComponentSettingsContextAware,
|
||||
IComponentPlacementContextAware
|
||||
{
|
||||
private const double BaseCellSize = 48d;
|
||||
private const double SwipeThreshold = 50;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new();
|
||||
|
||||
private IRecommendationInfoService _recommendationService;
|
||||
private IComponentSettingsAccessor? _componentSettingsAccessor;
|
||||
private ISettingsService _appSettingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
|
||||
private string _source = ZhiJiaoHubSources.ClassIsland;
|
||||
private string _mirrorSource = ZhiJiaoHubMirrorSources.Direct;
|
||||
private string _componentId = BuiltInComponentIds.DesktopZhiJiaoHub;
|
||||
private string _placementId = string.Empty;
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isSyncing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private int _pendingImageIndex = 0;
|
||||
|
||||
private IReadOnlyList<ZhiJiaoHubLocalImageItem> _localImages = [];
|
||||
private int _currentImageIndex = 0;
|
||||
|
||||
private readonly Dictionary<int, Bitmap> _imageCache = new();
|
||||
private readonly object _cacheLock = new();
|
||||
private const int MaxCacheSize = 5;
|
||||
|
||||
private bool _isDragging;
|
||||
private Point _dragStartPoint;
|
||||
private double _dragOffset;
|
||||
private int _lastSwipeDirection = 0;
|
||||
|
||||
public ZhiJiaoHubWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
return;
|
||||
}
|
||||
|
||||
_recommendationService = new RecommendationDataService();
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyLoadingState();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
|
||||
LoadSettings();
|
||||
_ = InitializeOrSyncAsync();
|
||||
UpdateTimers();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
_refreshCts?.Cancel();
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
foreach (var bitmap in _imageCache.Values)
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
_imageCache.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = _currentCellSize / BaseCellSize;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 4, 24));
|
||||
|
||||
var fontSize = Math.Clamp(11 * scale, 9, 18);
|
||||
ImageNameTextBlock.FontSize = fontSize;
|
||||
LoadingTextBlock.FontSize = Math.Clamp(12 * scale, 10, 16);
|
||||
ErrorTextBlock.FontSize = Math.Clamp(10 * scale, 8, 14);
|
||||
|
||||
GradientOverlay.Height = Math.Clamp(60 * scale, 30, 100);
|
||||
|
||||
ImageNameTextBlock.Margin = new Thickness(
|
||||
Math.Clamp(10 * scale, 5, 20),
|
||||
0,
|
||||
Math.Clamp(10 * scale, 5, 20),
|
||||
Math.Clamp(8 * scale, 4, 16));
|
||||
|
||||
IndicatorBorder.Margin = new Thickness(0, 0, Math.Clamp(6 * scale, 3, 12), 0);
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||
{
|
||||
_componentId = context.ComponentId;
|
||||
_placementId = context.PlacementId ?? string.Empty;
|
||||
_componentSettingsAccessor = context.ComponentSettingsAccessor;
|
||||
|
||||
LoadSettings();
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = InitializeOrSyncAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = componentId;
|
||||
_placementId = placementId ?? string.Empty;
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
LoadSettings();
|
||||
UpdateTimers();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = InitializeOrSyncAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
if (snapshot is not null)
|
||||
{
|
||||
_source = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||
_mirrorSource = ZhiJiaoHubMirrorSources.Normalize(snapshot.ZhiJiaoHubMirrorSource);
|
||||
_autoRefreshEnabled = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
_pendingImageIndex = snapshot.ZhiJiaoHubCurrentImageIndex;
|
||||
|
||||
var intervalMinutes = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveCurrentImageIndex()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>()
|
||||
?? new ComponentSettingsSnapshot();
|
||||
snapshot.ZhiJiaoHubCurrentImageIndex = _currentImageIndex;
|
||||
_componentSettingsAccessor?.SaveSnapshot(snapshot, [nameof(ComponentSettingsSnapshot.ZhiJiaoHubCurrentImageIndex)]);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTimers()
|
||||
{
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeOrSyncAsync()
|
||||
{
|
||||
if (_isSyncing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isSyncing = true;
|
||||
_refreshCts?.Cancel();
|
||||
_refreshCts = new CancellationTokenSource();
|
||||
var ct = _refreshCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
var localSnapshot = _recommendationService.LoadZhiJiaoHubLocalSnapshot(_source);
|
||||
|
||||
if (localSnapshot != null && localSnapshot.Images.Count > 0)
|
||||
{
|
||||
_localImages = localSnapshot.Images;
|
||||
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _localImages.Count - 1));
|
||||
_pendingImageIndex = 0;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
UpdateIndicators();
|
||||
_ = LoadAndDisplayCurrentImageAsync();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
LoadingTextBlock.Text = "首次同步图片...";
|
||||
ApplyLoadingState();
|
||||
});
|
||||
|
||||
var progress = new Progress<(int Current, int Total, string Status)>(p =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
LoadingTextBlock.Text = $"同步中 {p.Current}/{p.Total}";
|
||||
});
|
||||
});
|
||||
|
||||
var syncResult = await _recommendationService.SyncZhiJiaoHubImagesAsync(
|
||||
_source,
|
||||
_mirrorSource,
|
||||
progress,
|
||||
ct);
|
||||
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncResult.Success && syncResult.Snapshot != null)
|
||||
{
|
||||
_localImages = syncResult.Snapshot.Images;
|
||||
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _localImages.Count - 1));
|
||||
_pendingImageIndex = 0;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
UpdateIndicators();
|
||||
_ = LoadAndDisplayCurrentImageAsync();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ApplyErrorState(syncResult.ErrorMessage ?? "同步失败");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ApplyErrorState($"初始化失败: {ex.Message}");
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
if (_isSyncing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isSyncing = true;
|
||||
|
||||
try
|
||||
{
|
||||
var progress = new Progress<(int Current, int Total, string Status)>(p =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
LoadingTextBlock.Text = $"更新中 {p.Current}/{p.Total}";
|
||||
});
|
||||
});
|
||||
|
||||
var syncResult = await _recommendationService.SyncZhiJiaoHubImagesAsync(
|
||||
_source,
|
||||
_mirrorSource,
|
||||
progress,
|
||||
CancellationToken.None);
|
||||
|
||||
if (syncResult.Success && syncResult.Snapshot != null && syncResult.DownloadedCount > 0)
|
||||
{
|
||||
_localImages = syncResult.Snapshot.Images;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (_currentImageIndex >= _localImages.Count)
|
||||
{
|
||||
_currentImageIndex = 0;
|
||||
SaveCurrentImageIndex();
|
||||
}
|
||||
|
||||
UpdateIndicators();
|
||||
_ = LoadAndDisplayCurrentImageAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAndDisplayCurrentImageAsync(int direction = 0)
|
||||
{
|
||||
if (_localImages.Count == 0)
|
||||
{
|
||||
ApplyErrorState("暂无图片");
|
||||
return;
|
||||
}
|
||||
|
||||
var imageItem = _localImages[_currentImageIndex];
|
||||
|
||||
try
|
||||
{
|
||||
Bitmap? cachedBitmap = null;
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_imageCache.TryGetValue(_currentImageIndex, out cachedBitmap);
|
||||
}
|
||||
|
||||
if (cachedBitmap != null)
|
||||
{
|
||||
CurrentImage.Source = cachedBitmap;
|
||||
ImageNameTextBlock.Text = imageItem.Name;
|
||||
ApplyContentVisibleState();
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(imageItem.LocalPath))
|
||||
{
|
||||
ApplyErrorState("图片文件不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
await using var fileStream = File.OpenRead(imageItem.LocalPath);
|
||||
var bitmap = new Bitmap(fileStream);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_imageCache.Count >= MaxCacheSize)
|
||||
{
|
||||
CleanupFarthestCacheUnsafe();
|
||||
}
|
||||
_imageCache[_currentImageIndex] = bitmap;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
CurrentImage.Source = bitmap;
|
||||
ImageNameTextBlock.Text = imageItem.Name;
|
||||
ApplyContentVisibleState();
|
||||
});
|
||||
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ApplyErrorState($"图片加载失败: {ex.Message}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreloadAdjacentImagesAsync(int direction = 0)
|
||||
{
|
||||
if (_localImages.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var indicesToPreload = new List<int>();
|
||||
var currentIndex = _currentImageIndex;
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (direction <= 0)
|
||||
{
|
||||
var nextIndex = (currentIndex + 1) % _localImages.Count;
|
||||
if (!_imageCache.ContainsKey(nextIndex))
|
||||
{
|
||||
indicesToPreload.Add(nextIndex);
|
||||
}
|
||||
|
||||
var nextNextIndex = (currentIndex + 2) % _localImages.Count;
|
||||
if (!_imageCache.ContainsKey(nextNextIndex) && indicesToPreload.Count < 3)
|
||||
{
|
||||
indicesToPreload.Add(nextNextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (direction >= 0)
|
||||
{
|
||||
var prevIndex = (currentIndex - 1 + _localImages.Count) % _localImages.Count;
|
||||
if (!_imageCache.ContainsKey(prevIndex))
|
||||
{
|
||||
indicesToPreload.Add(prevIndex);
|
||||
}
|
||||
|
||||
var prevPrevIndex = (currentIndex - 2 + _localImages.Count) % _localImages.Count;
|
||||
if (!_imageCache.ContainsKey(prevPrevIndex) && indicesToPreload.Count < 3)
|
||||
{
|
||||
indicesToPreload.Add(prevPrevIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (indicesToPreload.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var preloadTasks = indicesToPreload.Select(async index =>
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_imageCache.ContainsKey(index))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_imageCache.Count >= MaxCacheSize)
|
||||
{
|
||||
CleanupFarthestCacheUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
var imageItem = _localImages[index];
|
||||
if (!File.Exists(imageItem.LocalPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var fileStream = File.OpenRead(imageItem.LocalPath);
|
||||
var bitmap = new Bitmap(fileStream);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (!_imageCache.ContainsKey(index))
|
||||
{
|
||||
_imageCache[index] = bitmap;
|
||||
}
|
||||
else
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
await Task.WhenAll(preloadTasks);
|
||||
}
|
||||
|
||||
private void CleanupFarthestCacheUnsafe()
|
||||
{
|
||||
if (_imageCache.Count == 0) return;
|
||||
|
||||
var farthestKey = -1;
|
||||
var maxDistance = -1;
|
||||
var currentIndex = _currentImageIndex;
|
||||
var imageCount = _localImages.Count;
|
||||
|
||||
foreach (var key in _imageCache.Keys)
|
||||
{
|
||||
if (key == currentIndex) continue;
|
||||
|
||||
var forwardDistance = (key - currentIndex + imageCount) % imageCount;
|
||||
var backwardDistance = (currentIndex - key + imageCount) % imageCount;
|
||||
var distance = Math.Min(forwardDistance, backwardDistance);
|
||||
|
||||
if (distance > maxDistance)
|
||||
{
|
||||
maxDistance = distance;
|
||||
farthestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (farthestKey >= 0)
|
||||
{
|
||||
if (_imageCache.TryGetValue(farthestKey, out var bitmap))
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
_imageCache.Remove(farthestKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_localImages.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDragging = true;
|
||||
_dragStartPoint = e.GetPosition(this);
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isDragging || _localImages.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetPosition(this);
|
||||
_dragOffset = currentPoint.Y - _dragStartPoint.Y;
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!_isDragging)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDragging = false;
|
||||
|
||||
if (Math.Abs(_dragOffset) > SwipeThreshold)
|
||||
{
|
||||
if (_dragOffset > 0)
|
||||
{
|
||||
_lastSwipeDirection = 1;
|
||||
SwitchToPrevImage();
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastSwipeDirection = -1;
|
||||
SwitchToNextImage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
||||
{
|
||||
if (_localImages.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Delta.Y > 0)
|
||||
{
|
||||
_lastSwipeDirection = 1;
|
||||
SwitchToPrevImage();
|
||||
}
|
||||
else if (e.Delta.Y < 0)
|
||||
{
|
||||
_lastSwipeDirection = -1;
|
||||
SwitchToNextImage();
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void SwitchToPrevImage()
|
||||
{
|
||||
if (_localImages.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentImageIndex = (_currentImageIndex - 1 + _localImages.Count) % _localImages.Count;
|
||||
SaveCurrentImageIndex();
|
||||
UpdateIndicators();
|
||||
|
||||
if (TryDisplayCachedImage(_currentImageIndex))
|
||||
{
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(_lastSwipeDirection));
|
||||
return;
|
||||
}
|
||||
|
||||
_ = LoadAndDisplayCurrentImageAsync(_lastSwipeDirection);
|
||||
}
|
||||
|
||||
private void SwitchToNextImage()
|
||||
{
|
||||
if (_localImages.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentImageIndex = (_currentImageIndex + 1) % _localImages.Count;
|
||||
SaveCurrentImageIndex();
|
||||
UpdateIndicators();
|
||||
|
||||
if (TryDisplayCachedImage(_currentImageIndex))
|
||||
{
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(_lastSwipeDirection));
|
||||
return;
|
||||
}
|
||||
|
||||
_ = LoadAndDisplayCurrentImageAsync(_lastSwipeDirection);
|
||||
}
|
||||
|
||||
private bool TryDisplayCachedImage(int index)
|
||||
{
|
||||
if (_localImages.Count == 0 || index < 0 || index >= _localImages.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Bitmap? cachedBitmap = null;
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_imageCache.TryGetValue(index, out cachedBitmap);
|
||||
}
|
||||
|
||||
if (cachedBitmap != null)
|
||||
{
|
||||
var imageItem = _localImages[index];
|
||||
CurrentImage.Source = cachedBitmap;
|
||||
ImageNameTextBlock.Text = imageItem.Name;
|
||||
ApplyContentVisibleState();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
CurrentImage.IsVisible = false;
|
||||
ImageNameTextBlock.IsVisible = false;
|
||||
GradientOverlay.IsVisible = false;
|
||||
ErrorTextBlock.IsVisible = false;
|
||||
LoadingPanel.IsVisible = true;
|
||||
}
|
||||
|
||||
private void ApplyContentVisibleState()
|
||||
{
|
||||
LoadingPanel.IsVisible = false;
|
||||
ErrorTextBlock.IsVisible = false;
|
||||
CurrentImage.IsVisible = true;
|
||||
ImageNameTextBlock.IsVisible = true;
|
||||
GradientOverlay.IsVisible = true;
|
||||
}
|
||||
|
||||
private void ApplyErrorState(string message)
|
||||
{
|
||||
CurrentImage.IsVisible = false;
|
||||
ImageNameTextBlock.IsVisible = false;
|
||||
GradientOverlay.IsVisible = false;
|
||||
LoadingPanel.IsVisible = false;
|
||||
ErrorTextBlock.Text = message;
|
||||
ErrorTextBlock.IsVisible = true;
|
||||
}
|
||||
|
||||
private void UpdateIndicators()
|
||||
{
|
||||
IndicatorPanel.Children.Clear();
|
||||
|
||||
if (_localImages.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var maxIndicators = Math.Min(_localImages.Count, 7);
|
||||
for (int i = 0; i < maxIndicators; i++)
|
||||
{
|
||||
var isActive = i == _currentImageIndex % maxIndicators;
|
||||
var indicator = new Border
|
||||
{
|
||||
Width = isActive ? 6 : 4,
|
||||
Height = isActive ? 6 : 4,
|
||||
CornerRadius = new CornerRadius(3),
|
||||
Background = isActive
|
||||
? new SolidColorBrush(Colors.White)
|
||||
: new SolidColorBrush(Color.FromArgb(128, 255, 255, 255))
|
||||
};
|
||||
IndicatorPanel.Children.Add(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isAttached && _autoRefreshEnabled)
|
||||
{
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1481,6 +1481,15 @@ public partial class MainWindow
|
||||
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopZhiJiaoHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// ZhiJiao Hub allows free resize but starts at 2x2
|
||||
// Allow any aspect ratio, minimum 2x2
|
||||
var width = Math.Max(2, span.WidthCells);
|
||||
var height = Math.Max(2, span.HeightCells);
|
||||
return (width, height);
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
|
||||
@@ -443,10 +443,13 @@ public partial class MainWindow
|
||||
currentVersion = new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
var normalizedVersion = new Version(
|
||||
Math.Max(0, currentVersion.Major),
|
||||
Math.Max(0, currentVersion.Minor),
|
||||
Math.Max(0, currentVersion.Build));
|
||||
var major = Math.Max(0, currentVersion.Major);
|
||||
var minor = Math.Max(0, currentVersion.Minor);
|
||||
var build = Math.Max(0, currentVersion.Build >= 0 ? currentVersion.Build : 0);
|
||||
var revision = Math.Max(0, currentVersion.Revision >= 0 ? currentVersion.Revision : 0);
|
||||
var normalizedVersion = revision > 0
|
||||
? new Version(major, minor, build, revision)
|
||||
: new Version(major, minor, build);
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
async () =>
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -537,11 +538,25 @@ public sealed class PluginLoader
|
||||
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
|
||||
{
|
||||
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
|
||||
|
||||
// 检查是否可以跳过解压(缓存有效)
|
||||
if (ShouldSkipExtraction(packagePath, extractionDirectory))
|
||||
{
|
||||
AppLogger.Info(
|
||||
"PluginLoader",
|
||||
$"Skipping extraction for '{packagePath}'. Cache is up-to-date.");
|
||||
return extractionDirectory;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"PluginLoader",
|
||||
$"Extracting package '{packagePath}' to '{extractionDirectory}'.");
|
||||
RecreateDirectory(extractionDirectory);
|
||||
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
|
||||
|
||||
// 保存解压元数据用于后续缓存检查
|
||||
SaveExtractionMetadata(packagePath, extractionDirectory);
|
||||
|
||||
return extractionDirectory;
|
||||
}
|
||||
|
||||
@@ -608,6 +623,85 @@ public sealed class PluginLoader
|
||||
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
|
||||
}
|
||||
|
||||
private bool ShouldSkipExtraction(string packagePath, string extractionDirectory)
|
||||
{
|
||||
// 如果解压目录不存在,必须解压
|
||||
if (!Directory.Exists(extractionDirectory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查元数据文件是否存在
|
||||
var metadataPath = Path.Combine(extractionDirectory, ".extraction-metadata.json");
|
||||
if (!File.Exists(metadataPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var packageInfo = new FileInfo(packagePath);
|
||||
var metadata = ReadExtractionMetadata(metadataPath);
|
||||
|
||||
// 如果包文件修改时间晚于解压时间,需要重新解压
|
||||
// 同时检查文件大小是否匹配
|
||||
return packageInfo.Length == metadata.PackageSize &&
|
||||
packageInfo.LastWriteTimeUtc <= metadata.ExtractedAt;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PluginLoader",
|
||||
$"Failed to read extraction metadata for '{packagePath}'. Will re-extract.",
|
||||
ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveExtractionMetadata(string packagePath, string extractionDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var packageInfo = new FileInfo(packagePath);
|
||||
var metadata = new ExtractionMetadata
|
||||
{
|
||||
PackagePath = Path.GetFullPath(packagePath),
|
||||
ExtractedAt = DateTime.UtcNow,
|
||||
PackageSize = packageInfo.Length,
|
||||
PackageLastWriteTime = packageInfo.LastWriteTimeUtc
|
||||
};
|
||||
|
||||
var metadataPath = Path.Combine(extractionDirectory, ".extraction-metadata.json");
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
File.WriteAllText(metadataPath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PluginLoader",
|
||||
$"Failed to save extraction metadata for '{packagePath}'.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static ExtractionMetadata ReadExtractionMetadata(string metadataPath)
|
||||
{
|
||||
var json = File.ReadAllText(metadataPath);
|
||||
return JsonSerializer.Deserialize<ExtractionMetadata>(json)
|
||||
?? throw new InvalidOperationException("Failed to deserialize extraction metadata.");
|
||||
}
|
||||
|
||||
private sealed class ExtractionMetadata
|
||||
{
|
||||
public string PackagePath { get; set; } = string.Empty;
|
||||
public DateTime ExtractedAt { get; set; }
|
||||
public long PackageSize { get; set; }
|
||||
public DateTime PackageLastWriteTime { get; set; }
|
||||
}
|
||||
|
||||
private static ReadOnlyDictionary<string, object?> CreateReadOnlyProperties(
|
||||
IReadOnlyDictionary<string, object?>? properties)
|
||||
{
|
||||
|
||||
@@ -497,10 +497,13 @@ internal sealed class AirAppMarketIndexDocument
|
||||
return false;
|
||||
}
|
||||
|
||||
version = new Version(
|
||||
Math.Max(0, parsed.Major),
|
||||
Math.Max(0, parsed.Minor),
|
||||
Math.Max(0, parsed.Build));
|
||||
var major = Math.Max(0, parsed.Major);
|
||||
var minor = Math.Max(0, parsed.Minor);
|
||||
var build = Math.Max(0, parsed.Build >= 0 ? parsed.Build : 0);
|
||||
var revision = Math.Max(0, parsed.Revision >= 0 ? parsed.Revision : 0);
|
||||
version = revision > 0
|
||||
? new Version(major, minor, build, revision)
|
||||
: new Version(major, minor, build);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user