mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26ff11b16b | ||
|
|
b83cfb47b0 | ||
|
|
a0bb83c743 | ||
|
|
af2e7b4f2f | ||
|
|
798124e500 | ||
|
|
95ecb06668 | ||
|
|
ac7e8db516 | ||
|
|
8ded721f46 | ||
|
|
a559325f5a | ||
|
|
b60368527f | ||
|
|
c8c3f51bff |
91
AGENTS.md
Normal file
91
AGENTS.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# LanMountainDesktop AI Guide
|
||||||
|
|
||||||
|
本文件是 AI 助手进入本仓库时的第一入口。面向 Codex、Cursor、Trae 等工具,目标是减少重复探索,快速定位权威文档、关键目录和执行约束。
|
||||||
|
|
||||||
|
## 1. 项目目标与仓库边界
|
||||||
|
|
||||||
|
- 本仓库是阑山桌面桌面宿主、宿主侧插件运行时、Plugin SDK、共享契约与基础外观/设置能力的权威来源。
|
||||||
|
- 不要把插件市场元数据、开发者门户或官方示例插件实现当作本仓库内容维护。
|
||||||
|
- 市场和生态材料属于兄弟仓库 `LanAirApp`。
|
||||||
|
- 官方示例插件属于独立仓库 `LanMountainDesktop.SamplePlugin`。
|
||||||
|
|
||||||
|
边界详情看:
|
||||||
|
|
||||||
|
- `docs/ECOSYSTEM_BOUNDARIES.md`
|
||||||
|
- `docs/ARCHITECTURE.md`
|
||||||
|
|
||||||
|
## 2. 关键目录地图
|
||||||
|
|
||||||
|
- `LanMountainDesktop/`: 主宿主应用,包含 UI、服务、组件系统、主题与插件运行时接入
|
||||||
|
- `LanMountainDesktop/ComponentSystem/`: 内置组件定义、注册、扩展加载
|
||||||
|
- `LanMountainDesktop/plugins/`: 宿主侧插件运行时、安装与 market 集成
|
||||||
|
- `LanMountainDesktop/Views/` and `ViewModels/`: UI 页面、窗口与视图模型
|
||||||
|
- `LanMountainDesktop/Services/`: 设置、遥测、启动、持久化、业务服务
|
||||||
|
- `LanMountainDesktop.PluginSdk/`: 插件 SDK 公共接口和默认打包行为
|
||||||
|
- `LanMountainDesktop.Shared.Contracts/`: 宿主/插件共享契约
|
||||||
|
- `LanMountainDesktop.Tests/`: 宿主与 SDK 测试
|
||||||
|
- `.trae/specs/`: feature 级规格、任务拆解和验收清单
|
||||||
|
|
||||||
|
更详细映射看 `docs/ai/CODEBASE_MAP.md`。
|
||||||
|
|
||||||
|
## 3. 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet restore
|
||||||
|
dotnet build LanMountainDesktop.slnx -c Debug
|
||||||
|
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||||
|
dotnet test LanMountainDesktop.slnx -c Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
插件本地包生成:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/Pack-PluginPackages.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 改动前后必做检查
|
||||||
|
|
||||||
|
改动前:
|
||||||
|
|
||||||
|
- 先确认需求是否已经在 `.trae/specs/` 中存在
|
||||||
|
- 先确认产品、架构、专题规范分别以哪份文档为准
|
||||||
|
- 避免沿用旧根目录产品文档中的过时事实
|
||||||
|
|
||||||
|
改动后:
|
||||||
|
|
||||||
|
- 至少检查构建和与改动相关的测试
|
||||||
|
- 如果行为、流程、边界或命令变化,更新对应文档
|
||||||
|
- 如果是新功能或行为调整,补齐或更新 `.trae/specs/<feature>/`
|
||||||
|
|
||||||
|
## 5. 高频区域注意事项
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md` 与 `docs/CORNER_RADIUS_SPEC.md`
|
||||||
|
- 设置页相关改动通常同时落在 `Views/`、`ViewModels/`、`Services/` 和 `.trae/specs/`
|
||||||
|
- UI 启动与窗口生命周期主线在 `Program.cs` 和 `App.axaml.cs`
|
||||||
|
|
||||||
|
### 插件
|
||||||
|
|
||||||
|
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
|
||||||
|
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
|
||||||
|
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
|
||||||
|
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||||
|
|
||||||
|
### 设置与主题
|
||||||
|
|
||||||
|
- 设置持久化和 scope 变化优先检查 `LanMountainDesktop.Settings.Core/`
|
||||||
|
- 外观、圆角、主题资源优先检查 `LanMountainDesktop.Appearance/` 与专题规范
|
||||||
|
|
||||||
|
## 6. 权威来源
|
||||||
|
|
||||||
|
- 产品定位:`docs/PRODUCT.md`
|
||||||
|
- 架构与模块职责:`docs/ARCHITECTURE.md`
|
||||||
|
- 运行、构建、测试、打包:`docs/DEVELOPMENT.md`
|
||||||
|
- feature 规格:`.trae/specs/`
|
||||||
|
- 视觉规范:`docs/VISUAL_SPEC.md`
|
||||||
|
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
|
||||||
|
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
|
||||||
|
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||||
|
|
||||||
|
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
<RepositoryType>git</RepositoryType>
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
|||||||
257
LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs
Normal file
257
LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ComponentPreviewImageServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var executionOrder = new List<string>();
|
||||||
|
var activeCount = 0;
|
||||||
|
var maxActiveCount = 0;
|
||||||
|
|
||||||
|
Task<ComponentPreviewImageEntry> Queue(string componentTypeId)
|
||||||
|
{
|
||||||
|
var key = ComponentPreviewKey.ForComponentType(componentTypeId, widthCells: 2, heightCells: 2);
|
||||||
|
return service.QueueGenerationAsync(
|
||||||
|
key,
|
||||||
|
visualSignature: $"sig:{componentTypeId}",
|
||||||
|
async _ =>
|
||||||
|
{
|
||||||
|
var activeNow = Interlocked.Increment(ref activeCount);
|
||||||
|
maxActiveCount = Math.Max(maxActiveCount, activeNow);
|
||||||
|
lock (executionOrder)
|
||||||
|
{
|
||||||
|
executionOrder.Add(componentTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(40);
|
||||||
|
Interlocked.Decrement(ref activeCount);
|
||||||
|
return CreateImage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = Queue("Clock");
|
||||||
|
var second = Queue("Weather");
|
||||||
|
var third = Queue("Calendar");
|
||||||
|
|
||||||
|
await Task.WhenAll(first, second, third);
|
||||||
|
|
||||||
|
Assert.Equal(1, maxActiveCount);
|
||||||
|
Assert.Equal(["Clock", "Weather", "Calendar"], executionOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueGenerationAsync_DeduplicatesConcurrentRequestsForSameKey()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var generationCount = 0;
|
||||||
|
var bitmap = CreateImage();
|
||||||
|
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
Task<IImage?> Generation(CancellationToken _)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref generationCount);
|
||||||
|
return completion.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||||
|
var second = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
|
||||||
|
completion.SetResult(bitmap);
|
||||||
|
var entry = await first;
|
||||||
|
|
||||||
|
Assert.Equal(1, generationCount);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Ready, entry.State);
|
||||||
|
Assert.Same(bitmap, entry.Bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Invalidate_ResetsSingleKeyToPending()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var image = CreateDisposableImage();
|
||||||
|
var stored = service.Store(key, image, "clock-sig");
|
||||||
|
var previousRevision = stored.Revision;
|
||||||
|
|
||||||
|
var result = service.Invalidate(key);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Pending, stored.State);
|
||||||
|
Assert.Null(stored.Bitmap);
|
||||||
|
Assert.True(image.IsDisposed);
|
||||||
|
Assert.True(stored.Revision > previousRevision);
|
||||||
|
Assert.Equal("clock-sig", stored.VisualSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemovePlacementPreviews_RemovesOnlyMatchingPlacementEntries()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
|
||||||
|
var removedClock = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2);
|
||||||
|
var removedWeather = ComponentPreviewKey.ForPlacementInstance("Weather", "desk-1", widthCells: 4, heightCells: 2);
|
||||||
|
var keptPlacement = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-2", widthCells: 2, heightCells: 2);
|
||||||
|
var keptType = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var removedClockImage = CreateDisposableImage();
|
||||||
|
var removedWeatherImage = CreateDisposableImage();
|
||||||
|
var keptPlacementImage = CreateDisposableImage();
|
||||||
|
var keptTypeImage = CreateDisposableImage();
|
||||||
|
|
||||||
|
service.Store(removedClock, removedClockImage, "sig-a");
|
||||||
|
service.Store(removedWeather, removedWeatherImage, "sig-b");
|
||||||
|
service.Store(keptPlacement, keptPlacementImage, "sig-c");
|
||||||
|
service.Store(keptType, keptTypeImage, "sig-d");
|
||||||
|
|
||||||
|
var removedCount = service.RemovePlacementPreviews("desk-1");
|
||||||
|
|
||||||
|
Assert.Equal(2, removedCount);
|
||||||
|
Assert.False(service.TryGetEntry(removedClock, out _));
|
||||||
|
Assert.False(service.TryGetEntry(removedWeather, out _));
|
||||||
|
Assert.True(service.TryGetEntry(keptPlacement, out _));
|
||||||
|
Assert.True(service.TryGetEntry(keptType, out _));
|
||||||
|
Assert.True(removedClockImage.IsDisposed);
|
||||||
|
Assert.True(removedWeatherImage.IsDisposed);
|
||||||
|
Assert.False(keptPlacementImage.IsDisposed);
|
||||||
|
Assert.False(keptTypeImage.IsDisposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidateVisualSignature_InvalidatesEveryMatchingEntry()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
const string matchingSignature = "shared-sig";
|
||||||
|
const string otherSignature = "other-sig";
|
||||||
|
|
||||||
|
var first = service.Store(
|
||||||
|
ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2),
|
||||||
|
CreateImage(),
|
||||||
|
matchingSignature);
|
||||||
|
var second = service.Store(
|
||||||
|
ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2),
|
||||||
|
CreateImage(),
|
||||||
|
matchingSignature);
|
||||||
|
var third = service.Store(
|
||||||
|
ComponentPreviewKey.ForComponentType("Weather", widthCells: 2, heightCells: 1),
|
||||||
|
CreateImage(),
|
||||||
|
otherSignature);
|
||||||
|
|
||||||
|
var invalidatedCount = service.InvalidateVisualSignature(matchingSignature);
|
||||||
|
|
||||||
|
Assert.Equal(2, invalidatedCount);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Pending, first.State);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Pending, second.State);
|
||||||
|
Assert.Null(first.Bitmap);
|
||||||
|
Assert.Null(second.Bitmap);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Ready, third.State);
|
||||||
|
Assert.NotNull(third.Bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Store_ReplacingBitmap_DisposesPreviousBitmap_WhenInstanceChanges()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var first = CreateDisposableImage();
|
||||||
|
var second = CreateDisposableImage();
|
||||||
|
|
||||||
|
service.Store(key, first, "sig-a");
|
||||||
|
service.Store(key, second, "sig-b");
|
||||||
|
|
||||||
|
Assert.True(first.IsDisposed);
|
||||||
|
Assert.False(second.IsDisposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Store_ReplacingBitmap_DoesNotDispose_WhenSameInstanceReused()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var image = CreateDisposableImage();
|
||||||
|
|
||||||
|
service.Store(key, image, "sig-a");
|
||||||
|
service.Store(key, image, "sig-b");
|
||||||
|
|
||||||
|
Assert.False(image.IsDisposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StoreFailure_DisposesExistingBitmap()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var image = CreateDisposableImage();
|
||||||
|
|
||||||
|
service.Store(key, image, "sig-a");
|
||||||
|
var entry = service.StoreFailure(key, "sig-a", "failed");
|
||||||
|
|
||||||
|
Assert.True(image.IsDisposed);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Failed, entry.State);
|
||||||
|
Assert.Null(entry.Bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueGenerationAsync_DisposesStaleGeneratedBitmap_WhenEntryWasInvalidated()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var stale = CreateDisposableImage();
|
||||||
|
|
||||||
|
var generationTask = service.QueueGenerationAsync(key, "sig-a", _ => completion.Task);
|
||||||
|
_ = service.Invalidate(key);
|
||||||
|
completion.SetResult(stale);
|
||||||
|
var entry = await generationTask;
|
||||||
|
|
||||||
|
Assert.True(stale.IsDisposed);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Pending, entry.State);
|
||||||
|
Assert.Null(entry.Bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IImage CreateImage() => new TestImage();
|
||||||
|
private static DisposableTestImage CreateDisposableImage() => new();
|
||||||
|
|
||||||
|
private sealed class TestImage : IImage
|
||||||
|
{
|
||||||
|
public Size Size => new(1, 1);
|
||||||
|
|
||||||
|
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||||
|
{
|
||||||
|
_ = context;
|
||||||
|
_ = sourceRect;
|
||||||
|
_ = destRect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DisposableTestImage : IImage, IDisposable
|
||||||
|
{
|
||||||
|
public Size Size => new(1, 1);
|
||||||
|
|
||||||
|
public bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IsDisposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||||
|
{
|
||||||
|
_ = context;
|
||||||
|
_ = sourceRect;
|
||||||
|
_ = destRect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<FontFamily x:Key="AppFontFamily">avares://LanMountainDesktop/Assets/Fonts#MiSans</FontFamily>
|
<FontFamily x:Key="AppFontFamily">avares://LanMountainDesktop/Assets/Fonts#MiSans</FontFamily>
|
||||||
|
<FontFamily x:Key="AppFontFamilyJP">avares://LanMountainDesktop/Assets/Fonts#MiSans JP</FontFamily>
|
||||||
|
<FontFamily x:Key="AppFontFamilyKR">avares://LanMountainDesktop/Assets/Fonts#MiSans KR</FontFamily>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/NavigationStyles.axaml" />
|
||||||
|
|
||||||
<Style Selector="Window">
|
<Style Selector="Window">
|
||||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public partial class App : Application
|
|||||||
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
||||||
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
private readonly FontFamilyService _fontFamilyService = new();
|
||||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||||
@@ -448,6 +449,21 @@ public partial class App : Application
|
|||||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||||
Thread.CurrentThread.CurrentCulture = culture;
|
Thread.CurrentThread.CurrentCulture = culture;
|
||||||
Thread.CurrentThread.CurrentUICulture = culture;
|
Thread.CurrentThread.CurrentUICulture = culture;
|
||||||
|
|
||||||
|
ApplyLanguageSpecificFont(languageCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLanguageSpecificFont(string languageCode)
|
||||||
|
{
|
||||||
|
var fontFamily = _fontFamilyService.GetFontFamilyForLanguage(languageCode);
|
||||||
|
if (Resources.TryGetValue("AppFontFamily", out var currentFont) &&
|
||||||
|
currentFont is FontFamily currentFontFamily &&
|
||||||
|
currentFontFamily.Name == fontFamily.Name)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Resources["AppFontFamily"] = fontFamily;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ActivateMainWindow()
|
private void ActivateMainWindow()
|
||||||
|
|||||||
1
LanMountainDesktop/Assets/bilibili.svg
Normal file
1
LanMountainDesktop/Assets/bilibili.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
LanMountainDesktop/Assets/juya_avatar.jpg
Normal file
BIN
LanMountainDesktop/Assets/juya_avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
1
LanMountainDesktop/Assets/wechat.svg
Normal file
1
LanMountainDesktop/Assets/wechat.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
|
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
|
||||||
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
||||||
public const string DesktopIfengNews = "DesktopIfengNews";
|
public const string DesktopIfengNews = "DesktopIfengNews";
|
||||||
|
public const string DesktopJuyaNews = "DesktopJuyaNews";
|
||||||
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
||||||
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
|
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
|
||||||
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
||||||
|
|||||||
@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 4,
|
MinHeightCells: 4,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopJuyaNews,
|
||||||
|
"橘鸦早报",
|
||||||
|
"News",
|
||||||
|
"Info",
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 4,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||||
"Bilibili Hot Search",
|
"Bilibili Hot Search",
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120);
|
private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120);
|
||||||
private static readonly Easing StandardEasing = new CubicEaseOut();
|
private static readonly Easing StandardEasing = new CubicEaseOut();
|
||||||
|
|
||||||
|
private readonly Image _previewImage;
|
||||||
|
private readonly Border _previewOverlay;
|
||||||
|
private readonly Border _fallbackCard;
|
||||||
private readonly Border _accentDot;
|
private readonly Border _accentDot;
|
||||||
private readonly TextBlock _titleTextBlock;
|
private readonly TextBlock _titleTextBlock;
|
||||||
private readonly TextBlock _detailTextBlock;
|
private readonly TextBlock _detailTextBlock;
|
||||||
@@ -33,6 +36,9 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
|
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
|
||||||
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
|
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
|
||||||
|
|
||||||
|
private bool _hasPreviewImage;
|
||||||
|
private bool _isInvalid;
|
||||||
|
|
||||||
public DesktopEditGhostView()
|
public DesktopEditGhostView()
|
||||||
{
|
{
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||||
@@ -47,27 +53,12 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
RenderTransform = _scaleTransform;
|
RenderTransform = _scaleTransform;
|
||||||
Transitions = new Transitions
|
Transitions = new Transitions
|
||||||
{
|
{
|
||||||
new DoubleTransition
|
CreateOpacityTransition(FastDuration)
|
||||||
{
|
|
||||||
Property = Visual.OpacityProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
_scaleTransform.Transitions = new Transitions
|
_scaleTransform.Transitions = new Transitions
|
||||||
{
|
{
|
||||||
new DoubleTransition
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||||
{
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||||
Property = ScaleTransform.ScaleXProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
},
|
|
||||||
new DoubleTransition
|
|
||||||
{
|
|
||||||
Property = ScaleTransform.ScaleYProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_accentDot = new Border
|
_accentDot = new Border
|
||||||
@@ -119,6 +110,18 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
Child = _badgeTextBlock
|
Child = _badgeTextBlock
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_previewImage = new Image
|
||||||
|
{
|
||||||
|
Stretch = Stretch.UniformToFill,
|
||||||
|
IsVisible = false
|
||||||
|
};
|
||||||
|
|
||||||
|
_previewOverlay = new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#1A000000")),
|
||||||
|
IsVisible = false
|
||||||
|
};
|
||||||
|
|
||||||
var headerPanel = new StackPanel
|
var headerPanel = new StackPanel
|
||||||
{
|
{
|
||||||
Orientation = Orientation.Horizontal,
|
Orientation = Orientation.Horizontal,
|
||||||
@@ -140,7 +143,7 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var rootGrid = new Grid
|
var fallbackGrid = new Grid
|
||||||
{
|
{
|
||||||
RowDefinitions = new RowDefinitions
|
RowDefinitions = new RowDefinitions
|
||||||
{
|
{
|
||||||
@@ -149,16 +152,31 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
},
|
},
|
||||||
RowSpacing = 8
|
RowSpacing = 8
|
||||||
};
|
};
|
||||||
rootGrid.Children.Add(contentPanel);
|
fallbackGrid.Children.Add(contentPanel);
|
||||||
rootGrid.Children.Add(_badgeBorder);
|
fallbackGrid.Children.Add(_badgeBorder);
|
||||||
Grid.SetRow(contentPanel, 0);
|
Grid.SetRow(contentPanel, 0);
|
||||||
Grid.SetRow(_badgeBorder, 1);
|
Grid.SetRow(_badgeBorder, 1);
|
||||||
_badgeBorder.Margin = new Thickness(0, 2, 0, 0);
|
_badgeBorder.Margin = new Thickness(0, 2, 0, 0);
|
||||||
|
|
||||||
Child = rootGrid;
|
_fallbackCard = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Child = fallbackGrid
|
||||||
|
};
|
||||||
|
|
||||||
|
Child = new Grid
|
||||||
|
{
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_previewImage,
|
||||||
|
_previewOverlay,
|
||||||
|
_fallbackCard
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
UpdatePreviewMetrics(180, 120);
|
UpdatePreviewMetrics(180, 120);
|
||||||
UpdateContent(null, null, null);
|
UpdateContent(null, null, null);
|
||||||
|
ApplyShellChrome();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateContent(string? title, string? detail, string? badgeText)
|
public void UpdateContent(string? title, string? detail, string? badgeText)
|
||||||
@@ -170,14 +188,32 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
_badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText);
|
_badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetPreviewImage(IImage? image)
|
||||||
|
{
|
||||||
|
_previewImage.Source = image;
|
||||||
|
_hasPreviewImage = image is not null;
|
||||||
|
_previewImage.IsVisible = _hasPreviewImage;
|
||||||
|
_previewOverlay.IsVisible = false;
|
||||||
|
_fallbackCard.IsVisible = !_hasPreviewImage;
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdatePreviewMetrics(double width, double height)
|
public void UpdatePreviewMetrics(double width, double height)
|
||||||
{
|
{
|
||||||
var normalizedWidth = Math.Max(1, width);
|
var normalizedWidth = Math.Max(1, width);
|
||||||
var normalizedHeight = Math.Max(1, height);
|
var normalizedHeight = Math.Max(1, height);
|
||||||
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
|
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
|
||||||
|
|
||||||
CornerRadius = new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
|
CornerRadius = _hasPreviewImage
|
||||||
Padding = new Thickness(
|
? new CornerRadius(Math.Clamp(minSide * 0.14, 14, 24))
|
||||||
|
: new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
|
||||||
|
Padding = _hasPreviewImage
|
||||||
|
? new Thickness(
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4))
|
||||||
|
: new Thickness(
|
||||||
Math.Clamp(minSide * 0.10, 10, 18),
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
Math.Clamp(minSide * 0.10, 10, 18),
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
Math.Clamp(minSide * 0.10, 10, 18),
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
@@ -200,30 +236,48 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
|
|
||||||
public void SetInvalid(bool isInvalid)
|
public void SetInvalid(bool isInvalid)
|
||||||
{
|
{
|
||||||
|
_isInvalid = isInvalid;
|
||||||
|
|
||||||
if (isInvalid)
|
if (isInvalid)
|
||||||
{
|
{
|
||||||
Background = _invalidBackgroundBrush;
|
|
||||||
BorderBrush = _invalidBorderBrush;
|
|
||||||
_accentDot.Background = _invalidAccentBrush;
|
_accentDot.Background = _invalidAccentBrush;
|
||||||
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
|
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
|
||||||
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
|
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
|
||||||
_titleTextBlock.Foreground = _invalidBorderBrush;
|
_titleTextBlock.Foreground = _invalidBorderBrush;
|
||||||
_detailTextBlock.Foreground = _invalidBorderBrush;
|
_detailTextBlock.Foreground = _invalidBorderBrush;
|
||||||
_badgeTextBlock.Foreground = _invalidBorderBrush;
|
_badgeTextBlock.Foreground = _invalidBorderBrush;
|
||||||
|
if (!_hasPreviewImage)
|
||||||
|
{
|
||||||
|
Background = _invalidBackgroundBrush;
|
||||||
|
BorderBrush = _invalidBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
Opacity = 0.9;
|
Opacity = 0.9;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Background = _normalBackgroundBrush;
|
|
||||||
BorderBrush = _normalBorderBrush;
|
|
||||||
_accentDot.Background = _normalAccentBrush;
|
_accentDot.Background = _normalAccentBrush;
|
||||||
_badgeBorder.Background = _normalBadgeBackgroundBrush;
|
_badgeBorder.Background = _normalBadgeBackgroundBrush;
|
||||||
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
|
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
|
||||||
_titleTextBlock.Foreground = _normalTextBrush;
|
_titleTextBlock.Foreground = _normalTextBrush;
|
||||||
_detailTextBlock.Foreground = _normalMutedTextBrush;
|
_detailTextBlock.Foreground = _normalMutedTextBrush;
|
||||||
_badgeTextBlock.Foreground = _normalTextBrush;
|
_badgeTextBlock.Foreground = _normalTextBrush;
|
||||||
|
if (!_hasPreviewImage)
|
||||||
|
{
|
||||||
|
Background = _normalBackgroundBrush;
|
||||||
|
BorderBrush = _normalBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
Opacity = 1.0;
|
Opacity = 1.0;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void SetRestingScale(double scale)
|
public void SetRestingScale(double scale)
|
||||||
{
|
{
|
||||||
@@ -238,4 +292,67 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
_scaleTransform.ScaleX = clampedScale;
|
_scaleTransform.ScaleX = clampedScale;
|
||||||
_scaleTransform.ScaleY = clampedScale;
|
_scaleTransform.ScaleY = clampedScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool HasPreviewImage => _hasPreviewImage;
|
||||||
|
|
||||||
|
internal void SetScaleTransitionDuration(TimeSpan duration)
|
||||||
|
{
|
||||||
|
_scaleTransform.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, duration),
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, duration)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void SetOpacityTransitionDuration(TimeSpan duration)
|
||||||
|
{
|
||||||
|
Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(duration)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyShellChrome()
|
||||||
|
{
|
||||||
|
if (_hasPreviewImage)
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent;
|
||||||
|
BorderBrush = Brushes.Transparent;
|
||||||
|
BorderThickness = new Thickness(0);
|
||||||
|
BoxShadow = BoxShadows.Parse("0 14 32 #1A000000");
|
||||||
|
Opacity = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxShadow = default;
|
||||||
|
if (_isInvalid)
|
||||||
|
{
|
||||||
|
Background = _invalidBackgroundBrush;
|
||||||
|
BorderBrush = _invalidBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
Opacity = 0.9;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Background = _normalBackgroundBrush;
|
||||||
|
BorderBrush = _normalBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
Opacity = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Property = property,
|
||||||
|
Duration = duration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DoubleTransition CreateOpacityTransition(TimeSpan duration) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Property = Visual.OpacityProperty,
|
||||||
|
Duration = duration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ internal enum DesktopEditGhostVisualStyle
|
|||||||
internal sealed class DesktopEditOverlayPresenter
|
internal sealed class DesktopEditOverlayPresenter
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan FastDuration = FluttermotionToken.Fast;
|
private static readonly TimeSpan FastDuration = FluttermotionToken.Fast;
|
||||||
|
private static readonly TimeSpan PickupDuration = TimeSpan.FromMilliseconds(160);
|
||||||
|
private static readonly TimeSpan CommitSettleDuration = TimeSpan.FromMilliseconds(160);
|
||||||
|
private static readonly TimeSpan CancelSettleDuration = TimeSpan.FromMilliseconds(120);
|
||||||
private static readonly Easing StandardEasing = new CubicEaseOut();
|
private static readonly Easing StandardEasing = new CubicEaseOut();
|
||||||
|
|
||||||
private readonly Canvas _root;
|
private readonly Canvas _root;
|
||||||
@@ -31,10 +34,10 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
private bool _isVisible;
|
private bool _isVisible;
|
||||||
private int _dismissVersion;
|
private int _dismissVersion;
|
||||||
|
|
||||||
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF4F8EF7"));
|
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
|
||||||
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF6B6B"));
|
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF3B30"));
|
||||||
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#224F8EF7"));
|
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#140A84FF"));
|
||||||
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#22FF6B6B"));
|
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30"));
|
||||||
|
|
||||||
public DesktopEditOverlayPresenter()
|
public DesktopEditOverlayPresenter()
|
||||||
{
|
{
|
||||||
@@ -66,18 +69,8 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
};
|
};
|
||||||
_candidateScale.Transitions = new Transitions
|
_candidateScale.Transitions = new Transitions
|
||||||
{
|
{
|
||||||
new DoubleTransition
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||||
{
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||||
Property = ScaleTransform.ScaleXProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
},
|
|
||||||
new DoubleTransition
|
|
||||||
{
|
|
||||||
Property = ScaleTransform.ScaleYProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
||||||
@@ -98,12 +91,7 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
|
|
||||||
_root.Transitions = new Transitions
|
_root.Transitions = new Transitions
|
||||||
{
|
{
|
||||||
new DoubleTransition
|
CreateOpacityTransition(FastDuration)
|
||||||
{
|
|
||||||
Property = Visual.OpacityProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +120,11 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
_ghostView.UpdateContent(title, detail, badge);
|
_ghostView.UpdateContent(title, detail, badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetPreviewImage(IImage? image)
|
||||||
|
{
|
||||||
|
_ghostView.SetPreviewImage(image);
|
||||||
|
}
|
||||||
|
|
||||||
public void SetInvalid(bool isInvalid)
|
public void SetInvalid(bool isInvalid)
|
||||||
{
|
{
|
||||||
_isInvalid = isInvalid;
|
_isInvalid = isInvalid;
|
||||||
@@ -146,12 +139,40 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
_root.IsVisible = true;
|
_root.IsVisible = true;
|
||||||
_root.Opacity = 0;
|
_root.Opacity = 0;
|
||||||
_ghostView.Opacity = 0;
|
_ghostView.Opacity = 0;
|
||||||
var initialGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.02 : 0.985;
|
var imageMode = _ghostView.HasPreviewImage;
|
||||||
var targetGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.06 : 1;
|
var initialGhostScale = 0.985;
|
||||||
|
var targetGhostScale = 1.0;
|
||||||
|
|
||||||
|
if (visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary)
|
||||||
|
{
|
||||||
|
initialGhostScale = 1.02;
|
||||||
|
targetGhostScale = 1.06;
|
||||||
|
}
|
||||||
|
else if (imageMode)
|
||||||
|
{
|
||||||
|
initialGhostScale = 0.992;
|
||||||
|
targetGhostScale = 1.03;
|
||||||
|
}
|
||||||
|
|
||||||
|
_root.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(PickupDuration)
|
||||||
|
};
|
||||||
|
_ghostView.SetOpacityTransitionDuration(PickupDuration);
|
||||||
|
_ghostView.SetScaleTransitionDuration(PickupDuration);
|
||||||
|
_candidateScale.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration),
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration)
|
||||||
|
};
|
||||||
|
_candidateOutline.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(PickupDuration)
|
||||||
|
};
|
||||||
_ghostView.SetRestingScale(initialGhostScale);
|
_ghostView.SetRestingScale(initialGhostScale);
|
||||||
_candidateOutline.Opacity = 0;
|
_candidateOutline.Opacity = 0;
|
||||||
_candidateScale.ScaleX = 0.96;
|
_candidateScale.ScaleX = 0.97;
|
||||||
_candidateScale.ScaleY = 0.96;
|
_candidateScale.ScaleY = 0.97;
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
@@ -182,6 +203,7 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
_candidateScale.ScaleX = 0.96;
|
_candidateScale.ScaleX = 0.96;
|
||||||
_candidateScale.ScaleY = 0.96;
|
_candidateScale.ScaleY = 0.96;
|
||||||
_ghostView.SetRestingScale(0.96);
|
_ghostView.SetRestingScale(0.96);
|
||||||
|
_ghostView.SetPreviewImage(null);
|
||||||
_root.IsVisible = false;
|
_root.IsVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,11 +226,29 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
|
|
||||||
var version = ++_dismissVersion;
|
var version = ++_dismissVersion;
|
||||||
_isVisible = false;
|
_isVisible = false;
|
||||||
|
var settleDuration = isCancel ? CancelSettleDuration : CommitSettleDuration;
|
||||||
|
_root.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(settleDuration)
|
||||||
|
};
|
||||||
|
_ghostView.SetOpacityTransitionDuration(settleDuration);
|
||||||
|
_ghostView.SetScaleTransitionDuration(settleDuration);
|
||||||
|
_candidateScale.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration),
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration)
|
||||||
|
};
|
||||||
|
_candidateOutline.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(settleDuration)
|
||||||
|
};
|
||||||
|
var targetScale = _ghostView.HasPreviewImage
|
||||||
|
? 1.00
|
||||||
|
: isCancel ? 0.96 : 1.04;
|
||||||
|
|
||||||
_candidateOutline.Opacity = 0;
|
_candidateOutline.Opacity = 0;
|
||||||
_ghostView.Opacity = 0;
|
_ghostView.Opacity = 0;
|
||||||
_root.Opacity = 0;
|
_root.Opacity = 0;
|
||||||
|
|
||||||
var targetScale = isCancel ? 0.96 : 1.04;
|
|
||||||
_ghostView.AnimateToScale(targetScale);
|
_ghostView.AnimateToScale(targetScale);
|
||||||
_candidateScale.ScaleX = targetScale;
|
_candidateScale.ScaleX = targetScale;
|
||||||
_candidateScale.ScaleY = targetScale;
|
_candidateScale.ScaleY = targetScale;
|
||||||
@@ -257,13 +297,13 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
Canvas.SetLeft(_candidateOutline, rect.X);
|
Canvas.SetLeft(_candidateOutline, rect.X);
|
||||||
Canvas.SetTop(_candidateOutline, rect.Y);
|
Canvas.SetTop(_candidateOutline, rect.Y);
|
||||||
|
|
||||||
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.12, 14, 28);
|
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26);
|
||||||
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
|
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
|
||||||
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||||
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||||
_candidateOutline.Opacity = _isVisible ? 1 : 0;
|
_candidateOutline.Opacity = _isVisible ? 1 : 0;
|
||||||
_candidateScale.ScaleX = _isVisible ? 1 : 0.96;
|
_candidateScale.ScaleX = _isVisible ? 1 : 0.97;
|
||||||
_candidateScale.ScaleY = _isVisible ? 1 : 0.96;
|
_candidateScale.ScaleY = _isVisible ? 1 : 0.97;
|
||||||
UpdateCandidateAppearance();
|
UpdateCandidateAppearance();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,4 +324,20 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
var height = Math.Max(1, rect.Height);
|
var height = Math.Max(1, rect.Height);
|
||||||
return new Rect(rect.X, rect.Y, width, height);
|
return new Rect(rect.X, rect.Y, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Property = property,
|
||||||
|
Duration = duration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DoubleTransition CreateOpacityTransition(TimeSpan duration) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Property = Visual.OpacityProperty,
|
||||||
|
Duration = duration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -959,6 +959,10 @@
|
|||||||
"study.interrupt_density.unavailable": "--",
|
"study.interrupt_density.unavailable": "--",
|
||||||
"desktop.add_page": "Add page",
|
"desktop.add_page": "Add page",
|
||||||
"desktop.delete_page": "Delete page",
|
"desktop.delete_page": "Delete page",
|
||||||
|
"desktop.delete_page_confirm.title": "Confirm Delete Page",
|
||||||
|
"desktop.delete_page_confirm.message": "Are you sure you want to delete the current page?\n\nThis will remove all components on this page and cannot be undone.",
|
||||||
|
"desktop.delete_page_confirm.primary": "Delete",
|
||||||
|
"desktop.delete_page_confirm.close": "Cancel",
|
||||||
"placement.fill": "Fill",
|
"placement.fill": "Fill",
|
||||||
"placement.fit": "Fit",
|
"placement.fit": "Fit",
|
||||||
"placement.stretch": "Stretch",
|
"placement.stretch": "Stretch",
|
||||||
|
|||||||
971
LanMountainDesktop/Localization/ko-KR.json
Normal file
971
LanMountainDesktop/Localization/ko-KR.json
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
{
|
||||||
|
"app.title": "LanMountainDesktop",
|
||||||
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
|
"tray.menu.show_desktop": "바탕화면 열기",
|
||||||
|
"tray.menu.settings": "설정",
|
||||||
|
"tray.menu.component_library": "위젯 라이브러리",
|
||||||
|
"tray.menu.restart": "앱 재시작",
|
||||||
|
"tray.menu.exit": "앱 종료",
|
||||||
|
"button.back_to_windows": "Windows로 돌아가기",
|
||||||
|
"button.back_to_platform": "{0}로 돌아가기",
|
||||||
|
"tooltip.back_to_windows": "Windows로 돌아가기",
|
||||||
|
"tooltip.back_to_platform": "{0}로 돌아가기",
|
||||||
|
"platform.windows": "Windows",
|
||||||
|
"platform.linux": "Linux",
|
||||||
|
"platform.macos": "macOS",
|
||||||
|
"tooltip.open_settings": "설정",
|
||||||
|
"settings.title": "설정",
|
||||||
|
"settings.shell.title": "설정",
|
||||||
|
"settings.shell.subtitle": "LanMountainDesktop 독립 설정 모듈",
|
||||||
|
"settings.shell.sidebar_hint": "카테고리를 선택하여 앱 동작, 바탕화면 레이아웃 및 외관을 조정합니다.",
|
||||||
|
"settings.shell.footer_hint": "트레이에서 열리는 설정은 이 독립 설정 모듈에서 관리됩니다.",
|
||||||
|
"settings.back_to_desktop": "바탕화면으로 돌아가기",
|
||||||
|
"settings.nav_header": "설정",
|
||||||
|
"settings.nav.group_desktop": "바탕화면",
|
||||||
|
"settings.nav.group_system": "시스템",
|
||||||
|
"settings.nav.group_extensions": "확장",
|
||||||
|
"settings.nav.wallpaper": "배경화면",
|
||||||
|
"settings.nav.grid": "컴포넌트",
|
||||||
|
"settings.nav.color": "색상",
|
||||||
|
"settings.nav.status_bar": "상태 표시줄",
|
||||||
|
"settings.nav.weather": "날씨",
|
||||||
|
"settings.nav.region": "지역",
|
||||||
|
"settings.nav.update": "업데이트",
|
||||||
|
"settings.nav.privacy": "개인정보",
|
||||||
|
"settings.nav.launcher": "앱 런처",
|
||||||
|
"settings.nav.plugins": "플러그인",
|
||||||
|
"settings.nav.about": "정보",
|
||||||
|
"settings.wallpaper.title": "배경화면",
|
||||||
|
"settings.wallpaper.description": "이미지 또는 비디오를 선택하여 앱 창의 배경화면으로 즉시 적용합니다.",
|
||||||
|
"settings.wallpaper.current_label": "현재 배경화면",
|
||||||
|
"settings.wallpaper.type_label": "배경화면 유형",
|
||||||
|
"settings.wallpaper.type.image": "이미지",
|
||||||
|
"settings.wallpaper.type.solid_color": "단색",
|
||||||
|
"settings.wallpaper.type.system": "시스템 배경화면",
|
||||||
|
"settings.wallpaper.system.label": "시스템 배경화면",
|
||||||
|
"settings.wallpaper.system.unavailable": "시스템 배경화면을 불러올 수 없습니다",
|
||||||
|
"settings.wallpaper.refresh_interval": "새로고침 간격",
|
||||||
|
"settings.wallpaper.refresh_now": "지금 새로고침",
|
||||||
|
"settings.wallpaper.refresh.30s": "30초",
|
||||||
|
"settings.wallpaper.refresh.1m": "1분",
|
||||||
|
"settings.wallpaper.refresh.5m": "5분",
|
||||||
|
"settings.wallpaper.refresh.10m": "10분",
|
||||||
|
"settings.wallpaper.refresh.15m": "15분",
|
||||||
|
"settings.wallpaper.refresh.30m": "30분",
|
||||||
|
"settings.wallpaper.refresh.1h": "1시간",
|
||||||
|
"settings.wallpaper.refresh.2h": "2시간",
|
||||||
|
"settings.wallpaper.refresh.4h": "4시간",
|
||||||
|
"settings.wallpaper.refresh.8h": "8시간",
|
||||||
|
"settings.wallpaper.refresh.12h": "12시간",
|
||||||
|
"settings.wallpaper.refresh.24h": "24시간",
|
||||||
|
"settings.wallpaper.color_label": "배경화면 색상",
|
||||||
|
"settings.wallpaper.placement_label": "배치",
|
||||||
|
"settings.wallpaper.placement_desc": "이미지가 바탕화면에 표시되는 방식을 조정합니다.",
|
||||||
|
"settings.wallpaper.pick_button": "파일 찾아보기",
|
||||||
|
"settings.wallpaper.clear_button": "단색으로 재설정",
|
||||||
|
"settings.wallpaper.no_selection": "배경화면이 선택되지 않았습니다.",
|
||||||
|
"settings.wallpaper.storage_unavailable": "저장소 제공자를 사용할 수 없습니다.",
|
||||||
|
"settings.wallpaper.import_failed": "배경화면 파일 가져오기에 실패했습니다.",
|
||||||
|
"settings.wallpaper.image_applied": "이미지 배경화면이 적용되었습니다.",
|
||||||
|
"settings.wallpaper.video_applied": "비디오 배경화면이 적용되었습니다.",
|
||||||
|
"settings.wallpaper.unsupported_file": "선택한 파일 형식은 지원되지 않습니다.",
|
||||||
|
"settings.wallpaper.apply_failed_format": "배경화면 적용 실패: {0}",
|
||||||
|
"settings.wallpaper.mode_format": "배경화면 모드: {0}.",
|
||||||
|
"settings.wallpaper.video_mode": "비디오 배경화면은 자동 채우기 모드를 사용합니다.",
|
||||||
|
"settings.wallpaper.cleared": "배경이 단색으로 재설정되었습니다.",
|
||||||
|
"settings.wallpaper.default_status": "현재 배경은 단색을 사용합니다.",
|
||||||
|
"settings.wallpaper.saved_not_found": "저장된 배경화면 파일을 찾을 수 없습니다. 단색 배경을 사용합니다.",
|
||||||
|
"settings.wallpaper.restored": "저장된 설정에서 배경화면이 복원되었습니다.",
|
||||||
|
"settings.wallpaper.video_restored": "저장된 설정에서 비디오 배경화면이 복원되었습니다.",
|
||||||
|
"settings.wallpaper.restore_failed": "저장된 배경화면 복원에 실패했습니다. 단색 배경을 사용합니다.",
|
||||||
|
"settings.wallpaper.video_not_found": "비디오 배경화면 파일을 찾을 수 없습니다.",
|
||||||
|
"settings.wallpaper.video_player_unavailable": "비디오 플레이어를 사용할 수 없습니다.",
|
||||||
|
"settings.wallpaper.video_play_failed_format": "비디오 배경화면 재생 실패: {0}",
|
||||||
|
"settings.grid.title": "그리드 레이아웃",
|
||||||
|
"settings.grid.description": "모든 컴포넌트는 최소 하나의 셀을 차지해야 합니다 (최소 1x1).",
|
||||||
|
"settings.grid.short_side_label": "짧은 쪽 셀 수",
|
||||||
|
"settings.grid.spacing_label": "그리드 간격",
|
||||||
|
"settings.grid.spacing_relaxed": "여유 있음 (iOS)",
|
||||||
|
"settings.grid.spacing_compact": "컴팩트 (Android)",
|
||||||
|
"settings.grid.edge_inset_label": "화면 여백",
|
||||||
|
"settings.grid.edge_inset_px_format": "≈ {0:F1}px",
|
||||||
|
"settings.grid.apply_button": "적용",
|
||||||
|
"settings.grid.info_format": "그리드: {0}열 x {1}행 | 셀 {2:F1}px (1:1)",
|
||||||
|
"settings.color.title": "색상",
|
||||||
|
"settings.color.description": "주야간 모드를 전환하고 앱 강조 색상을 선택합니다.",
|
||||||
|
"settings.color.day_night_label": "주야간 모드",
|
||||||
|
"settings.color.day_night_on": "야간",
|
||||||
|
"settings.color.day_night_off": "주간",
|
||||||
|
"settings.color.recommended_label": "추천 색상",
|
||||||
|
"settings.color.system_monet_label": "시스템 Monet 색상",
|
||||||
|
"settings.color.refresh_button": "새로고침",
|
||||||
|
"settings.color.mode_night": "야간 모드 활성화됨",
|
||||||
|
"settings.color.mode_day": "주간 모드 활성화됨",
|
||||||
|
"settings.color.mode_status_format": "테마 모드: {0}.",
|
||||||
|
"settings.color.monet_refreshed": "Monet 색상이 새로고침되었습니다.",
|
||||||
|
"settings.color.theme_ready_format": "테마 색상 준비됨: {0}.",
|
||||||
|
"settings.color.theme_applied_format": "{0} 테마 색상 적용됨: {1}.",
|
||||||
|
"settings.color.theme_updated_wallpaper": "배경화면이 업데이트되어 Monet 색상이 새로고침되었습니다.",
|
||||||
|
"settings.color.theme_cleared_wallpaper": "배경화면이 제거되어 Monet 색상이 새로고침되었습니다.",
|
||||||
|
"settings.status_bar.title": "상태 표시줄",
|
||||||
|
"settings.status_bar.description": "상단 상태 표시줄에 표시할 컴포넌트를 선택합니다.",
|
||||||
|
"settings.status_bar.clock_header": "시계 컴포넌트",
|
||||||
|
"settings.status_bar.clock_description": "상단 상태 표시줄에 시계를 표시합니다.",
|
||||||
|
"settings.status_bar.clock_transparent_background_label": "투명 배경",
|
||||||
|
"settings.status_bar.clock_transparent_background_desc": "캡슐 배경을 제거하고 시계 텍스트만 유지합니다.",
|
||||||
|
"settings.status_bar.spacing_header": "컴포넌트 간격",
|
||||||
|
"settings.status_bar.spacing_desc": "상태 표시줄 컴포넌트 사이의 간격을 조정합니다.",
|
||||||
|
"settings.status_bar.spacing_mode_compact": "컴팩트",
|
||||||
|
"settings.status_bar.spacing_mode_relaxed": "여유 있음",
|
||||||
|
"settings.status_bar.spacing_mode_custom": "사용자 지정",
|
||||||
|
"settings.status_bar.spacing_custom_label": "사용자 지정 간격 (%)",
|
||||||
|
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||||
|
"settings.privacy.title": "개인정보",
|
||||||
|
"settings.privacy.description": "선택적 익명 업로드 설정을 관리하여 앱 경험을 점진적으로 개선하는 데 도움을 줍니다.",
|
||||||
|
"settings.privacy.crash_upload_title": "익명 충돌 데이터 업로드",
|
||||||
|
"settings.privacy.crash_upload_description": "앱 안정성 향상에 도움을 줍니다.",
|
||||||
|
"settings.privacy.usage_upload_title": "익명 사용 데이터 업로드",
|
||||||
|
"settings.privacy.usage_upload_description": "앱 기능 개선에 도움을 줍니다.",
|
||||||
|
"settings.privacy.device_id_title": "기기 식별자",
|
||||||
|
"settings.privacy.device_id_description": "이 기기의 고유 식별자입니다. 새로고침을 클릭하여 재생성합니다.",
|
||||||
|
"settings.privacy.refresh_device_id": "새로고침",
|
||||||
|
"settings.privacy.policy_hint_prefix": "자세한 내용은",
|
||||||
|
"settings.privacy.view_policy": "개인정보 처리방침 보기",
|
||||||
|
"settings.weather.title": "날씨",
|
||||||
|
"settings.weather.description": "날씨 위치, Xiaomi 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
|
||||||
|
"settings.weather.location_source_header": "위치 소스",
|
||||||
|
"settings.weather.location_source_desc": "날씨 컴포넌트가 현재 위치를 해석하는 방법을 선택합니다.",
|
||||||
|
"settings.weather.mode_city_search": "도시 검색",
|
||||||
|
"settings.weather.mode_coordinates": "좌표 입력",
|
||||||
|
"settings.weather.auto_refresh": "시작 시 위치 자동 새로고침",
|
||||||
|
"settings.weather.city_search_header": "도시 검색",
|
||||||
|
"settings.weather.city_search_desc": "도시를 검색하고 날씨 위치를 적용합니다.",
|
||||||
|
"settings.weather.search_placeholder": "예: 서울",
|
||||||
|
"settings.weather.search_button": "검색",
|
||||||
|
"settings.weather.apply_city_button": "도시 적용",
|
||||||
|
"settings.weather.search_hint": "도시 이름을 입력하여 검색한 후 결과를 적용합니다.",
|
||||||
|
"settings.weather.search_required": "먼저 도시 키워드를 입력하세요.",
|
||||||
|
"settings.weather.search_no_results": "일치하는 위치를 찾을 수 없습니다.",
|
||||||
|
"settings.weather.search_failed_format": "검색 실패: {0}",
|
||||||
|
"settings.weather.search_result_count_format": "총 {0}개 위치를 찾았습니다.",
|
||||||
|
"settings.weather.search_select_required": "먼저 검색 결과에서 위치를 선택하세요.",
|
||||||
|
"settings.weather.search_applied_format": "위치 적용됨: {0}",
|
||||||
|
"settings.weather.coordinates_header": "좌표 입력",
|
||||||
|
"settings.weather.coordinates_desc": "위도와 경도를 설정하고 선택적으로 위치 키와 표시 이름을 입력합니다.",
|
||||||
|
"settings.weather.latitude_label": "위도",
|
||||||
|
"settings.weather.longitude_label": "경도",
|
||||||
|
"settings.weather.location_key_placeholder": "위치 키 (선택)",
|
||||||
|
"settings.weather.location_name_placeholder": "표시 이름 (선택)",
|
||||||
|
"settings.weather.apply_coordinates_button": "좌표 적용",
|
||||||
|
"settings.weather.coordinates_saved_format": "좌표 저장됨: {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.coordinates_default_name_format": "좌표 {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.location_services_header": "위치 서비스",
|
||||||
|
"settings.weather.location_services_desc": "현재 Windows 위치를 사용하고 시작 시 날씨 위치를 자동으로 새로고침할지 결정합니다.",
|
||||||
|
"settings.weather.use_current_location": "현재 위치 사용",
|
||||||
|
"settings.weather.location_unsupported": "현재 플랫폼에서 현재 위치 가져오기를 지원하지 않습니다.",
|
||||||
|
"settings.weather.location_ready": "현재 Windows 위치를 사용할 수 있습니다.",
|
||||||
|
"settings.weather.location_refreshing": "현재 위치 가져오는 중...",
|
||||||
|
"settings.weather.location_refresh_success_format": "현재 위치 적용됨: {0}",
|
||||||
|
"settings.weather.location_refresh_failed_format": "현재 위치 가져오기 실패: {0}",
|
||||||
|
"settings.weather.preview_header": "연결 테스트",
|
||||||
|
"settings.weather.preview_desc": "테스트 요청을 보내 현재 구성이 사용 가능한지 확인합니다.",
|
||||||
|
"settings.weather.preview_button": "테스트 가져오기",
|
||||||
|
"settings.weather.preview_section": "날씨 미리보기",
|
||||||
|
"settings.weather.settings_section": "설정",
|
||||||
|
"settings.weather.preview_panel_header": "날씨 미리보기",
|
||||||
|
"settings.weather.preview_panel_desc": "현재 날씨 서비스 상태를 새로고침하고 확인합니다.",
|
||||||
|
"settings.weather.refresh_button": "새로고침",
|
||||||
|
"settings.weather.preview_updated_format": "{0}에 업데이트됨",
|
||||||
|
"settings.weather.preview_hint": "테스트 가져오기를 통해 날씨 구성을 빠르게 확인할 수 있습니다.",
|
||||||
|
"settings.weather.preview_missing_location": "테스트 전에 먼저 날씨 위치를 적용하세요.",
|
||||||
|
"settings.weather.preview_success_format": "테스트 성공: {0} · {1} · {2}",
|
||||||
|
"settings.weather.preview_failed_format": "테스트 실패: {0}",
|
||||||
|
"settings.weather.preview_unknown": "알 수 없음",
|
||||||
|
"settings.weather.alert_filter_header": "제외된 날씨 경보",
|
||||||
|
"settings.weather.alert_filter_desc": "다음 키워드가 포함된 경보는 표시되지 않습니다. 한 줄에 하나의 규칙.",
|
||||||
|
"settings.weather.alert_filter_placeholder": "한 줄에 하나의 키워드 입력",
|
||||||
|
"settings.weather.icon_style_header": "날씨 아이콘 스타일",
|
||||||
|
"settings.weather.icon_style_desc": "날씨 기호에 사용할 Fluent Icon 스타일을 선택합니다.",
|
||||||
|
"settings.weather.icon_style_fluent_regular": "Fluent 윤곽선",
|
||||||
|
"settings.weather.icon_style_fluent_filled": "Fluent 채우기",
|
||||||
|
"settings.weather.no_tls_header": "TLS 없이 날씨 가져오기",
|
||||||
|
"settings.weather.no_tls_desc": "권장하지 않음, 네트워크 호환성이 낮을 때만 시도하세요.",
|
||||||
|
"settings.weather.status_city_empty": "도시 위치가 아직 구성되지 않았습니다.",
|
||||||
|
"settings.weather.status_city_format": "모드: {0}|{1}|키: {2}",
|
||||||
|
"settings.weather.status_coordinates_format": "모드: {0}|위도 {1:F4}, 경도 {2:F4}|키: {3}",
|
||||||
|
"settings.weather.city_selection_label": "도시 선택",
|
||||||
|
"settings.weather.coordinates_selection_label": "좌표 위치",
|
||||||
|
"settings.weather.location_city_summary_desc": "날씨 조회에 사용할 현재 도시를 선택합니다.",
|
||||||
|
"settings.weather.location_coordinates_summary_desc": "날씨 조회에 사용할 위도와 경도 및 선택적 위치 이름을 설정합니다.",
|
||||||
|
"settings.weather.location_not_selected": "위치가 선택되지 않음",
|
||||||
|
"settings.weather.alert_list_label": "제외 목록",
|
||||||
|
"settings.weather.alert_list_desc": "한 줄에 하나의 제외 항목.",
|
||||||
|
"settings.weather.no_tls_toggle": "호환성이 낮은 네트워크 환경에서 비 TLS 요청으로 대체 허용",
|
||||||
|
"settings.weather.footer_hint": "바탕화면의 날씨 컴포넌트는 여기서 구성한 날씨 위치와 경보 제외 규칙을 공유합니다.",
|
||||||
|
"settings.weather.location_header": "날씨 위치",
|
||||||
|
"settings.weather.location_desc": "날씨 컴포넌트에 사용할 위치를 설정합니다.",
|
||||||
|
"settings.weather.location_placeholder": "예: 서울",
|
||||||
|
"settings.weather.location_apply": "저장",
|
||||||
|
"settings.weather.location_empty": "날씨 위치가 아직 설정되지 않았습니다.",
|
||||||
|
"settings.weather.location_required": "날씨 위치는 비워둘 수 없습니다.",
|
||||||
|
"settings.weather.location_current_format": "현재 날씨 위치: {0}",
|
||||||
|
"settings.weather.location_saved_format": "날씨 위치 저장됨: {0}",
|
||||||
|
"weather.widget.location_not_configured": "날씨 위치가 구성되지 않음",
|
||||||
|
"weather.widget.configure_hint": "설정 > 날씨에서 구성을 완료하세요",
|
||||||
|
"weather.widget.loading": "로딩 중...",
|
||||||
|
"weather.widget.fetch_failed": "날씨 가져오기 실패",
|
||||||
|
"weather.widget.retrying": "나중에 자동으로 재시도",
|
||||||
|
"weather.widget.location_unknown": "알 수 없는 위치",
|
||||||
|
"weather.widget.condition_clear": "맑음",
|
||||||
|
"weather.widget.condition_cloudy": "흐림",
|
||||||
|
"weather.widget.condition_rain": "비",
|
||||||
|
"weather.widget.condition_storm": "뇌우",
|
||||||
|
"weather.widget.condition_snow": "눈",
|
||||||
|
"weather.widget.condition_fog": "안개",
|
||||||
|
"weather.widget.condition_unknown": "알 수 없는 날씨",
|
||||||
|
"weather.widget.range_unknown": "-- / --",
|
||||||
|
"weather.widget.range_format": "{0} / {1}",
|
||||||
|
"schedule.widget.no_source": "ClassIsland 시간표를 읽지 못함",
|
||||||
|
"schedule.widget.no_class_today": "오늘 수업 없음",
|
||||||
|
"schedule.widget.layout_missing": "시간표 레이아웃 누락",
|
||||||
|
"schedule.widget.subject_fallback": "이름 없는 수업",
|
||||||
|
"schedule.widget.detail_fallback": "상세 정보 없음",
|
||||||
|
"schedule.settings.title": "시간표 가져오기",
|
||||||
|
"schedule.settings.desc": "ClassIsland CSES 시간표 파일을 가져오고 활성화 항목을 선택합니다.",
|
||||||
|
"schedule.settings.add": "시간표 추가",
|
||||||
|
"schedule.settings.empty": "가져온 시간표 없음",
|
||||||
|
"schedule.settings.unnamed": "이름 없는 시간표",
|
||||||
|
"schedule.settings.delete": "삭제",
|
||||||
|
"schedule.settings.picker_title": "ClassIsland 시간표 파일 선택",
|
||||||
|
"schedule.settings.picker_file_type.all": "ClassIsland 시간표 파일",
|
||||||
|
"schedule.settings.picker_file_type.json": "ClassIsland 아카이브 (JSON)",
|
||||||
|
"schedule.settings.picker_file_type.cses": "CSES 시간표 (YAML)",
|
||||||
|
"schedule.settings.semester.title": "학기 설정",
|
||||||
|
"schedule.settings.semester.start_date": "학기 시작일",
|
||||||
|
"schedule.settings.semester.week_cycle": "주 순환",
|
||||||
|
"schedule.settings.semester.week_cycle_desc": "다주 시간표 순환 주기를 설정하여 현재 몇 주차인지 계산합니다.",
|
||||||
|
"schedule.settings.semester.week_cycle_format": "{0}주 순환",
|
||||||
|
"worldclock.settings.title": "세계 시계 설정",
|
||||||
|
"worldclock.settings.desc": "네 개의 시계에 대해 각각 시간대를 선택합니다.",
|
||||||
|
"worldclock.settings.clock_1": "시계 1",
|
||||||
|
"worldclock.settings.clock_2": "시계 2",
|
||||||
|
"worldclock.settings.clock_3": "시계 3",
|
||||||
|
"worldclock.settings.clock_4": "시계 4",
|
||||||
|
"worldclock.settings.second_mode_label": "초침 방식",
|
||||||
|
"worldclock.widget.today": "오늘",
|
||||||
|
"worldclock.widget.yesterday": "어제",
|
||||||
|
"worldclock.widget.tomorrow": "내일",
|
||||||
|
"worldclock.widget.offset_same": "0시간",
|
||||||
|
"worldclock.widget.offset_ahead_hours": "{0}시간 빠름",
|
||||||
|
"worldclock.widget.offset_behind_hours": "{0}시간 늦음",
|
||||||
|
"worldclock.widget.offset_ahead_hm": "{0}시간 {1}분 빠름",
|
||||||
|
"worldclock.widget.offset_behind_hm": "{0}시간 {1}분 늦음",
|
||||||
|
"weather.widget.aqi_unknown": "AQI --",
|
||||||
|
"weather.widget.aqi_format": "AQI {0}",
|
||||||
|
"weather.widget.updated_format": "{0:HH:mm}에 업데이트됨",
|
||||||
|
"weather.hourly.now": "현재",
|
||||||
|
"weather.hourly.sunset": "일몰",
|
||||||
|
"weather.multiday.today": "오늘",
|
||||||
|
"weather.multiday.tomorrow": "내일",
|
||||||
|
"weather.multiday.aqi_format": "공기 좋음 {0}",
|
||||||
|
"weather.multiday.aqi_unknown": "공기 --",
|
||||||
|
"settings.region.title": "지역",
|
||||||
|
"settings.region.description": "언어를 선택하고 설정 및 주요 인터페이스에 즉시 적용합니다.",
|
||||||
|
"settings.region.language_header": "언어",
|
||||||
|
"settings.region.language_label": "언어",
|
||||||
|
"settings.region.language_zh": "중국어",
|
||||||
|
"settings.region.language_en": "영어",
|
||||||
|
"settings.region.language_ja": "일본어",
|
||||||
|
"settings.region.language_ko": "한국어",
|
||||||
|
"settings.region.timezone_header": "시간대",
|
||||||
|
"settings.region.timezone_desc": "시간대를 선택합니다. 시계와 달력 컴포넌트가 이 시간대를 사용합니다.",
|
||||||
|
"settings.region.applied_format": "언어가 {0}(으)로 전환되었습니다",
|
||||||
|
"settings.region.follow_system": "시스템 기본값 따르기",
|
||||||
|
"settings.general.title": "일반 설정",
|
||||||
|
"settings.general.description": "언어, 시간대 및 런타임 동작을 조정합니다.",
|
||||||
|
"settings.general.basic_header": "기본 설정",
|
||||||
|
"settings.general.runtime_header": "런타임 설정",
|
||||||
|
"settings.general.preview_header": "날짜 및 시간 미리보기",
|
||||||
|
"settings.general.preview_time_label": "시간",
|
||||||
|
"settings.general.preview_date_label": "날짜",
|
||||||
|
"settings.general.render_mode_restart_message": "렌더링 모드 변경은 앱 재시작이 필요합니다.",
|
||||||
|
"settings.appearance.title": "외관",
|
||||||
|
"settings.appearance.description": "테마 소스, 시스템 소재 및 창 외관을 조정합니다.",
|
||||||
|
"settings.appearance.theme_header": "테마",
|
||||||
|
"settings.color.enable_night_mode_toggle": "야간 모드 활성화",
|
||||||
|
"settings.color.use_system_chrome_toggle": "시스템 창 제목 표시줄 사용",
|
||||||
|
"settings.color.theme_color_label": "테마 강조 색상",
|
||||||
|
"settings.appearance.theme_color_mode_label": "테마 색상 소스",
|
||||||
|
"settings.appearance.theme_color_mode.neutral": "기본 중성",
|
||||||
|
"settings.appearance.theme_color_mode.user": "사용자 테마 색상 Monet",
|
||||||
|
"settings.appearance.theme_color_mode.wallpaper": "배경화면 Monet 색상",
|
||||||
|
"settings.appearance.theme_color_mode_desc.neutral": "표준 주간 흰색 배경 검은 텍스트와 야간 검은 배경 흰색 텍스트 중성색 표면을 사용합니다.",
|
||||||
|
"settings.appearance.theme_color_mode_desc.user": "사용자가 선택한 테마 색상을 전체 바탕화면 셸의 Monet 시드 색상으로 사용합니다.",
|
||||||
|
"settings.appearance.theme_color_mode_desc.wallpaper": "배경화면 색상을 사용합니다. 앱 배경화면을 우선하고 실패 시 시스템 바탕화면 배경화면으로 대체합니다.",
|
||||||
|
"settings.appearance.theme_color_preview.app": "현재 앱 배경화면에서 추출한 색상을 미리보고 있습니다.",
|
||||||
|
"settings.appearance.theme_color_preview.system": "현재 시스템 배경화면에서 추출한 색상을 미리보고 있습니다.",
|
||||||
|
"settings.appearance.theme_color_preview.fallback": "사용 가능한 배경화면이 없어 현재 대체 강조 색상을 사용합니다.",
|
||||||
|
"component.color_scheme.follow_system": "시스템 색상 구성 따르기",
|
||||||
|
"component.color_scheme.native": "컴포넌트 사용자 지정 색상 구성 사용",
|
||||||
|
"settings.appearance.system_material.none": "없음",
|
||||||
|
"settings.appearance.system_material.mica": "Mica",
|
||||||
|
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||||
|
"settings.appearance.system_material_desc.switchable": "선택한 소재를 창, Dock, 상태 표시줄 및 컴포넌트 호스트 배경에 적용합니다.",
|
||||||
|
"settings.appearance.system_material_desc.fixed": "현재 시스템은 여기에 나열된 소재 모드만 제공합니다.",
|
||||||
|
"settings.appearance.restart_message": "테마 색상 소스 및 시스템 소재 변경은 앱 재시작이 필요합니다.",
|
||||||
|
"settings.appearance.preview.primary": "주 색상",
|
||||||
|
"settings.appearance.preview.secondary": "보조 색상",
|
||||||
|
"settings.appearance.preview.tertiary": "제3 색상",
|
||||||
|
"settings.appearance.preview.neutral": "중성 색상",
|
||||||
|
"settings.appearance.preview.seed": "시드 색상",
|
||||||
|
"settings.appearance.preview.neutral_light": "흰색",
|
||||||
|
"settings.appearance.preview.neutral_dark": "검은색",
|
||||||
|
"settings.appearance.preview.apply_seed": "적용",
|
||||||
|
"settings.appearance.preview.wallpaper_candidates": "배경화면 후보 테마 색상",
|
||||||
|
"settings.appearance.preview.wallpaper_current": "현재",
|
||||||
|
"settings.wallpaper.placement.fill": "채우기",
|
||||||
|
"settings.wallpaper.placement.fit": "맞추기",
|
||||||
|
"settings.wallpaper.placement.stretch": "늘리기",
|
||||||
|
"settings.wallpaper.placement.center": "가운데",
|
||||||
|
"settings.wallpaper.placement.tile": "바둑판",
|
||||||
|
"settings.status_bar.clock_format_label": "시계 형식",
|
||||||
|
"settings.status_bar.clock_format.hm": "시:분",
|
||||||
|
"settings.status_bar.clock_format.hms": "시:분:초",
|
||||||
|
"settings.components.title": "컴포넌트",
|
||||||
|
"settings.components.description": "컴포넌트 레이아웃과 모서리 디자인을 조정합니다.",
|
||||||
|
"settings.components.grid_header": "그리드 설정",
|
||||||
|
"settings.components.header": "그리드 설정",
|
||||||
|
"settings.components.short_side_label": "짧은 쪽 셀 수",
|
||||||
|
"settings.components.edge_inset_label": "화면 여백",
|
||||||
|
"settings.components.spacing_label": "컴포넌트 간격",
|
||||||
|
"settings.components.spacing_compact": "컴팩트",
|
||||||
|
"settings.components.spacing_relaxed": "여유 있음",
|
||||||
|
"settings.components.corner_radius.header": "모서리 디자인",
|
||||||
|
"settings.components.corner_radius.label": "컴포넌트 모서리",
|
||||||
|
"settings.components.corner_radius.description": "컴포넌트 컨테이너 모서리를 직각에서 캡슐 모양에 가깝게 연속 조정하고 모서리가 커짐에 따라 내부 안전 영역도 확장합니다.",
|
||||||
|
"settings.update.title": "업데이트",
|
||||||
|
"settings.update.current_version_label": "현재 버전",
|
||||||
|
"settings.update.latest_version_label": "최신 릴리스",
|
||||||
|
"settings.update.published_at_label": "게시일",
|
||||||
|
"settings.update.options_header": "업데이트 옵션",
|
||||||
|
"settings.update.options_desc": "업데이트 확인과 릴리스 채널을 구성합니다.",
|
||||||
|
"settings.update.auto_check_toggle": "시작 시 자동 업데이트 확인",
|
||||||
|
"settings.update.include_prerelease_toggle": "사전 릴리스 버전 포함",
|
||||||
|
"settings.update.channel_label": "업데이트 채널",
|
||||||
|
"settings.update.channel_stable": "정식 버전",
|
||||||
|
"settings.update.channel_preview": "미리보기 버전",
|
||||||
|
"settings.update.actions_header": "업데이트 작업",
|
||||||
|
"settings.update.actions_desc": "릴리스 확인, 설치 패키지 다운로드 및 업데이트 시작.",
|
||||||
|
"settings.update.check_button": "업데이트 확인",
|
||||||
|
"settings.update.download_install_button": "다운로드 및 설치",
|
||||||
|
"settings.update.download_progress_idle": "다운로드 진행률: -",
|
||||||
|
"settings.update.download_progress_format": "다운로드 진행률: {0:F0}%",
|
||||||
|
"settings.update.status_ready": "업데이트 확인을 시작할 수 있습니다.",
|
||||||
|
"settings.update.status_channel_changed": "업데이트 채널이 변경되었습니다. 다시 업데이트를 확인하세요.",
|
||||||
|
"settings.update.status_channel_changed_format": "업데이트 채널이 {0}(으)로 전환되었습니다. 다시 업데이트를 확인하세요.",
|
||||||
|
"settings.update.status_windows_only": "자동 설치 패키지 업데이트는 현재 Windows만 지원합니다.",
|
||||||
|
"settings.update.status_checking": "GitHub Release 확인 중...",
|
||||||
|
"settings.update.status_check_failed_format": "업데이트 확인 실패: {0}",
|
||||||
|
"settings.update.status_up_to_date": "현재 최신 버전입니다.",
|
||||||
|
"settings.update.status_asset_missing": "새 버전이 발견되었지만 호환되는 설치 패키지를 찾을 수 없습니다.",
|
||||||
|
"settings.update.status_available_format": "새 버전 {0}이(가) 발견되었습니다. \"다운로드 및 설치\"를 클릭하여 계속하세요.",
|
||||||
|
"settings.update.status_downloading": "설치 패키지 다운로드 중...",
|
||||||
|
"settings.update.status_download_failed_format": "다운로드 실패: {0}",
|
||||||
|
"settings.update.status_launching_installer": "다운로드 완료, 설치 프로그램 시작 중...",
|
||||||
|
"settings.update.status_installer_missing": "다운로드 후 설치 패키지 파일을 찾을 수 없습니다.",
|
||||||
|
"settings.update.status_installer_started": "설치 프로그램이 시작되었습니다. 앱이 업데이트를 위해 종료됩니다.",
|
||||||
|
"settings.update.status_elevation_cancelled": "관리자 권한이 부여되지 않아 업데이트가 취소되었습니다.",
|
||||||
|
"settings.update.status_launch_failed_format": "설치 프로그램 시작 실패: {0}",
|
||||||
|
"settings.about.title": "정보",
|
||||||
|
"settings.about.version_format": "버전: {0}",
|
||||||
|
"settings.about.codename_format": "버전 코드명: {0}",
|
||||||
|
"settings.about.font_format": "글꼴: {0}",
|
||||||
|
"settings.about.startup_header": "Windows 자동 시작",
|
||||||
|
"settings.about.startup_desc": "Windows 로그인 시 앱을 자동으로 시작합니다.",
|
||||||
|
"settings.about.startup_toggle": "Windows 로그인 시 시작",
|
||||||
|
"settings.about.render_mode_header": "앱 렌더링 모드",
|
||||||
|
"settings.about.render_mode_desc": "앱 렌더링 백엔드를 선택합니다. 변경 후 앱 재시작이 필요합니다. 지원하지 않는 모드는 소프트웨어 렌더링으로 대체됩니다.",
|
||||||
|
"settings.about.render_mode.default": "기본",
|
||||||
|
"settings.about.render_mode.software": "소프트웨어",
|
||||||
|
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||||
|
"settings.about.render_mode.wgl": "WGL",
|
||||||
|
"settings.about.render_mode.vulkan": "Vulkan",
|
||||||
|
"settings.about.render_mode.unknown": "알 수 없음",
|
||||||
|
"settings.about.render_mode.current_label": "현재 실제 렌더링 백엔드",
|
||||||
|
"settings.about.render_mode.current_format": "현재 백엔드: {0}",
|
||||||
|
"settings.about.render_mode.impl_format": "런타임 구현: {0}",
|
||||||
|
"settings.about.render_mode.impl_unavailable": "현재 런타임 구현 정보를 가져올 수 없습니다.",
|
||||||
|
"settings.about.description": "앱 정보.",
|
||||||
|
"settings.update.description": "업데이트 확인, 릴리스 채널 및 다운로드 소스 선택, 업데이트 설치 방법 제어.",
|
||||||
|
"settings.update.status_card_title": "업데이트 상태",
|
||||||
|
"settings.update.status_card_description": "새 버전 확인, 릴리스 정보 보기, 업데이트 시 다운로드 또는 설치 계속.",
|
||||||
|
"settings.update.preferences_header": "업데이트 설정",
|
||||||
|
"settings.update.preferences_description": "릴리스 채널, 설치 패키지 다운로드 소스, 설치 방법 및 다운로드 병렬 스레드 수를 선택합니다.",
|
||||||
|
"settings.update.last_checked_label": "마지막 확인",
|
||||||
|
"settings.update.source_label": "다운로드 소스",
|
||||||
|
"settings.update.source_github": "GitHub",
|
||||||
|
"settings.update.source_ghproxy": "gh-proxy",
|
||||||
|
"settings.update.source_github_desc": "GitHub에서 직접 릴리스 설치 패키지를 다운로드합니다.",
|
||||||
|
"settings.update.source_ghproxy_desc": "GitHub 릴리스 설치 패키지를 다운로드할 때 gh-proxy 미러를 사용합니다.",
|
||||||
|
"settings.update.mode_label": "업데이트 모드",
|
||||||
|
"settings.update.mode_manual": "수동 업데이트",
|
||||||
|
"settings.update.mode_download_then_confirm": "자동 다운로드",
|
||||||
|
"settings.update.mode_silent_on_exit": "자동 설치",
|
||||||
|
"settings.update.mode_manual_desc": "업데이트만 확인합니다. 다운로드와 설치 시기는 사용자가 결정합니다.",
|
||||||
|
"settings.update.mode_download_then_confirm_desc": "백그라운드에서 업데이트를 다운로드하고 완료 후 설치 여부를 확인합니다.",
|
||||||
|
"settings.update.mode_silent_on_exit_desc": "백그라운드에서 업데이트를 다운로드하고 다음 앱 종료 시 자동으로 설치합니다.",
|
||||||
|
"settings.update.channel_stable_desc": "정식 버전은 안정성을 우선하며 대부분의 사용자에게 적합합니다.",
|
||||||
|
"settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.",
|
||||||
|
"settings.update.download_threads_label": "다운로드 스레드 수",
|
||||||
|
"settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.",
|
||||||
|
"settings.update.install_now_button": "지금 설치",
|
||||||
|
"settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.",
|
||||||
|
"settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.",
|
||||||
|
"settings.about.app_info_header": "앱 정보",
|
||||||
|
"settings.about.update_header": "업데이트",
|
||||||
|
"settings.about.version_label": "버전",
|
||||||
|
"settings.about.codename_label": "버전 코드명",
|
||||||
|
"settings.about.render_backend_label": "렌더링 백엔드",
|
||||||
|
"settings.about.render_backend_format": "렌더링 백엔드: {0}",
|
||||||
|
"settings.restart_dialog.title": "앱 재시작 필요",
|
||||||
|
"settings.restart_dialog.render_mode_message": "렌더링 모드를 \"{0}\"에서 \"{1}\"(으)로 변경하려면 앱을 재시작해야 합니다. 지금 재시작하시겠습니까?",
|
||||||
|
"settings.restart_dialog.restart": "지금 재시작",
|
||||||
|
"settings.restart_dialog.later": "나중에",
|
||||||
|
"settings.restart_dialog.cancel": "취소",
|
||||||
|
"settings.restart_dock.title": "앱 재시작 필요",
|
||||||
|
"settings.restart_dock.description": "일부 변경 사항은 앱 재시작 후에 적용됩니다.",
|
||||||
|
"settings.restart_dock.button": "앱 재시작",
|
||||||
|
"settings.footer": "LanMountainDesktop 설정",
|
||||||
|
"filepicker.title": "배경화면 선택",
|
||||||
|
"filepicker.image_files": "이미지 파일",
|
||||||
|
"common.day": "주간",
|
||||||
|
"common.night": "야간",
|
||||||
|
"common.back": "뒤로",
|
||||||
|
"common.close": "닫기",
|
||||||
|
"common.unknown": "알 수 없는 오류",
|
||||||
|
"common.recommended": "추천",
|
||||||
|
"common.monet": "Monet",
|
||||||
|
"desktop.page_index_format": "바탕화면 {0}",
|
||||||
|
"launcher.title": "앱 런처",
|
||||||
|
"launcher.folder": "폴더",
|
||||||
|
"launcher.subtitle": "Windows 시작 메뉴 구조에 따라 모든 앱과 폴더 표시",
|
||||||
|
"launcher.subtitle_linux": "Linux .desktop 항목에서 스캔한 설치된 앱 표시",
|
||||||
|
"launcher.empty": "시작 메뉴 항목을 찾을 수 없습니다.",
|
||||||
|
"launcher.empty_linux": "Linux .desktop 앱 항목을 찾을 수 없습니다.",
|
||||||
|
"launcher.empty_folder": "이 폴더는 비어 있습니다.",
|
||||||
|
"launcher.folder_items_format": "{0}개 앱",
|
||||||
|
"launcher.context.hide_icon": "아이콘 숨기기",
|
||||||
|
"launcher.action.hide": "숨기기",
|
||||||
|
"settings.launcher.title": "앱 런처",
|
||||||
|
"settings.launcher.description": "앱 런처에서 숨겨진 앱과 폴더를 관리합니다.",
|
||||||
|
"settings.launcher.hidden_header": "숨겨진 항목",
|
||||||
|
"settings.launcher.hidden_desc": "숨겨진 런처 항목을 보고 다시 표시합니다.",
|
||||||
|
"settings.launcher.hidden_hint": "바탕화면 편집 모드에서 런처 아이콘을 선택하고 \"숨기기\"를 클릭하면 숨겨진 항목이 여기에 표시됩니다.",
|
||||||
|
"settings.launcher.hidden_empty": "숨겨진 항목이 없습니다.",
|
||||||
|
"settings.launcher.hidden_summary_format": "총 {0}개 숨겨진 항목",
|
||||||
|
"settings.launcher.hidden_type_folder": "폴더",
|
||||||
|
"settings.launcher.hidden_type_shortcut": "앱",
|
||||||
|
"settings.launcher.restore_button": "숨기기 해제",
|
||||||
|
"settings.plugins.title": "플러그인",
|
||||||
|
"settings.plugins.runtime_header": "플러그인 런타임",
|
||||||
|
"settings.plugins.runtime_desc": "플러그인 런타임 상태, 로드 결과 및 진단 정보를 확인합니다.",
|
||||||
|
"settings.plugins.runtime_hint": "설치된 플러그인의 발견 결과, 로드 상태 및 런타임 진단 정보가 여기에 표시됩니다.",
|
||||||
|
"settings.plugins.runtime_status": "플러그인 스캔이 완료되면 런타임 상태가 여기에 표시됩니다.",
|
||||||
|
"settings.plugins.description": "설치된 플러그인을 관리하고 런타임 상태를 확인합니다.",
|
||||||
|
"settings.plugins.initial_status": "플러그인 상태를 새로고침하여 최신 설치된 플러그인을 확인하세요.",
|
||||||
|
"settings.plugins.refresh_button": "플러그인 새로고침",
|
||||||
|
"settings.plugins.refresh_success_installed_format": "{0}개 설치된 플러그인을 로드했습니다.",
|
||||||
|
"settings.plugins.refresh_success_format": "{0}개 설치된 플러그인과 {1}개 마켓 항목을 로드했습니다.",
|
||||||
|
"settings.plugins.refresh_failed": "플러그인 마켓 인덱스 로드 실패.",
|
||||||
|
"settings.plugins.marketplace_header": "플러그인 마켓",
|
||||||
|
"settings.plugins.marketplace_empty": "현재 사용 가능한 마켓 플러그인이 없습니다.",
|
||||||
|
"settings.plugins.delete_button_short": "삭제",
|
||||||
|
"settings.plugins.install_button_short": "설치",
|
||||||
|
"settings.plugins.restart_required": "플러그인 변경 사항은 재시작 후 적용됩니다.",
|
||||||
|
"settings.plugins.toggle_unchanged_format": "플러그인 \"{0}\"에 변경 사항이 없습니다.",
|
||||||
|
"settings.plugins.delete_failed_name_format": "플러그인 \"{0}\" 제거 실패.",
|
||||||
|
"settings.plugins.install_failed_name_format": "플러그인 \"{0}\" 설치 실패.",
|
||||||
|
"settings.plugins.installed_header": "설치된 플러그인",
|
||||||
|
"settings.plugins.installed_desc": "여기서 설치된 플러그인을 보고 삭제합니다.",
|
||||||
|
"settings.plugins.import_header": "설치 패키지에서 가져오기",
|
||||||
|
"settings.plugins.import_desc": ".laapp 플러그인 패키지를 열고 로컬 플러그인 디렉토리에 스테이징합니다.",
|
||||||
|
"settings.plugins.restart_hint": "플러그인 설치 및 삭제 변경 사항은 앱 재시작 후 적용됩니다.",
|
||||||
|
"settings.plugins.empty": "플러그인을 찾을 수 없습니다.",
|
||||||
|
"settings.plugins.runtime_unavailable": "플러그인 런타임을 사용할 수 없습니다.",
|
||||||
|
"settings.plugins.summary_format": "총 {0}개 플러그인 감지됨; {1}개 활성화됨; {2}개 로드됨; {3}개 설정 페이지; {4}개 컴포넌트; {5}개 실패.",
|
||||||
|
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||||
|
"settings.plugins.state.enabled": "활성화됨",
|
||||||
|
"settings.plugins.state.enabled_failed": "활성화됨 / 로드 실패",
|
||||||
|
"settings.plugins.state.disabled": "비활성화됨",
|
||||||
|
"settings.plugins.state.loaded": "로드됨",
|
||||||
|
"settings.plugins.state.load_failed": "로드 실패",
|
||||||
|
"settings.plugins.toggle_on": "활성화",
|
||||||
|
"settings.plugins.toggle_off": "비활성화",
|
||||||
|
"settings.plugins.toggle_result_format": "플러그인 \"{0}\"이(가) 다음 시작 시 {1}(으)로 설정되었습니다. 앱 재시작 후 설정 페이지와 컴포넌트 변경 사항이 적용됩니다.",
|
||||||
|
"settings.plugins.toggle_state_enabled": "활성화",
|
||||||
|
"settings.plugins.toggle_state_disabled": "비활성화",
|
||||||
|
"settings.plugins.toggle_failed_detail_format": "플러그인 \"{0}\" 상태 업데이트 실패: {1}",
|
||||||
|
"settings.plugins.install_button": ".laapp 플러그인 패키지 열기",
|
||||||
|
"settings.plugins.install_unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 .laapp 플러그인 패키지를 설치할 수 없습니다.",
|
||||||
|
"settings.plugins.install_hint_format": ".laapp 플러그인 패키지를 열어 설치: {0}",
|
||||||
|
"settings.plugins.install_picker_title": "플러그인 설치 패키지 선택",
|
||||||
|
"settings.plugins.install_file_type": ".laapp 플러그인 패키지",
|
||||||
|
"settings.plugins.install_picker_unavailable": "파일 저장소 제공자를 사용할 수 없습니다.",
|
||||||
|
"settings.plugins.install_copy_failed": "선택한 .laapp 플러그인 패키지 복사 실패.",
|
||||||
|
"settings.plugins.install_success_format": "플러그인 \"{0}\" 설치 완료. 앱 재시작 후 새 설정 페이지와 컴포넌트가 적용됩니다.",
|
||||||
|
"settings.plugins.install_failed_format": "플러그인 패키지 설치 실패: {0}",
|
||||||
|
"settings.plugins.delete_button": "플러그인 삭제",
|
||||||
|
"settings.plugins.delete_success_format": "플러그인 \"{0}\"이(가) 삭제 예정입니다. 앱 재시작 후 제거가 완료됩니다.",
|
||||||
|
"settings.plugins.delete_failed_format": "플러그인 삭제 실패: {0}",
|
||||||
|
"settings.plugins.delete_failed_detail_format": "플러그인 \"{0}\" 삭제 실패: {1}",
|
||||||
|
"settings.plugins.publisher_format": "게시자: {0}",
|
||||||
|
"settings.plugins.publisher_unknown": "알 수 없는 게시자",
|
||||||
|
"settings.plugins.source_package": ".laapp 패키지",
|
||||||
|
"settings.plugins.source_manifest": "매니페스트 파일",
|
||||||
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
|
"settings.plugins.detail_format": "설정 페이지: {0} | 컴포넌트: {1}",
|
||||||
|
"settings.nav.plugin_market": "플러그인 마켓",
|
||||||
|
"settings.plugin_market.title": "플러그인 마켓",
|
||||||
|
"settings.plugin_market.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
|
||||||
|
"settings.plugin_market.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 마켓을 열 수 없습니다.",
|
||||||
|
"settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.",
|
||||||
|
"settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.",
|
||||||
|
"settings.update.status_check_failed": "업데이트 확인 실패.",
|
||||||
|
"settings.update.status_available_summary_format": "업데이트 발견: {0} (현재: {1}).",
|
||||||
|
"settings.update.status_up_to_date_format": "현재 최신 버전입니다 ({0}).",
|
||||||
|
"settings.window.drawer_default": "상세 정보",
|
||||||
|
"market.toolbar.search_placeholder": "플러그인 검색",
|
||||||
|
"market.toolbar.refresh": "새로고침",
|
||||||
|
"market.status.loading": "공식 플러그인 마켓 로딩 중...",
|
||||||
|
"market.status.loaded_network_format": "공식 소스에서 {0}개 플러그인을 로드했습니다.",
|
||||||
|
"market.status.loaded_cache_format": "공식 소스를 일시적으로 사용할 수 없어 캐시에서 {0}개 플러그인을 로드했습니다. 원인: {1}",
|
||||||
|
"market.status.load_failed_format": "플러그인 마켓 로드 실패: {0}",
|
||||||
|
"market.status.installing_format": "플러그인 \"{0}\" 다운로드 및 스테이징 중...",
|
||||||
|
"market.status.install_success_format": "플러그인 \"{0}\" 스테이징 완료. 앱 재시작 후 적용됩니다.",
|
||||||
|
"market.status.install_failed_format": "플러그인 설치 실패: {0}",
|
||||||
|
"market.status.host_incompatible_format": "현재 호스트 버전이 너무 낮습니다. 최소 {0}이(가) 필요합니다.",
|
||||||
|
"market.list.empty": "플러그인 마켓이 아직 로드되지 않았습니다.",
|
||||||
|
"market.list.no_results": "현재 검색과 일치하는 플러그인이 없습니다.",
|
||||||
|
"market.card.subtitle_format": "{0} | v{1}",
|
||||||
|
"market.card.loaded": "로드됨",
|
||||||
|
"market.card.pending_restart": "재시작 필요",
|
||||||
|
"market.detail.placeholder": "왼쪽에서 플러그인을 선택하여 상세 정보를 확인하세요.",
|
||||||
|
"market.detail.author": "게시자",
|
||||||
|
"market.detail.version": "버전",
|
||||||
|
"market.detail.api_version": "API 버전",
|
||||||
|
"market.detail.min_host_version": "최소 호스트 버전",
|
||||||
|
"market.detail.installed_version": "설치된 버전",
|
||||||
|
"market.detail.not_installed": "미설치",
|
||||||
|
"market.detail.readme": "README",
|
||||||
|
"market.detail.plugin_information": "플러그인 정보",
|
||||||
|
"market.detail.author_subtitle_format": "작성자: {0}",
|
||||||
|
"market.detail.package_size": "패키지 크기",
|
||||||
|
"market.detail.published_at": "최초 게시",
|
||||||
|
"market.detail.updated_at": "최근 업데이트",
|
||||||
|
"market.detail.tags": "태그",
|
||||||
|
"market.detail.project": "프로젝트",
|
||||||
|
"market.detail.state": "설치 상태",
|
||||||
|
"market.detail.market_source": "마켓 소스",
|
||||||
|
"market.detail.homepage": "홈페이지",
|
||||||
|
"market.detail.repository": "저장소",
|
||||||
|
"market.detail.release_notes": "릴리스 노트",
|
||||||
|
"market.detail.dependencies": "의존성",
|
||||||
|
"market.detail.dependencies_empty": "이 플러그인은 SharedContracts 의존성을 선언하지 않았습니다.",
|
||||||
|
"market.detail.readme_loading": "README 로딩 중...",
|
||||||
|
"market.detail.readme_empty": "README가 비어 있습니다.",
|
||||||
|
"market.detail.readme_error_format": "README 로드 실패: {0}",
|
||||||
|
"market.detail.state.not_installed": "미설치",
|
||||||
|
"market.detail.state.update_available": "업데이트 가능",
|
||||||
|
"market.detail.state.installed": "설치됨",
|
||||||
|
"market.detail.unknown": "알 수 없음",
|
||||||
|
"market.button.install": "설치",
|
||||||
|
"market.button.update": "업데이트",
|
||||||
|
"market.button.installed": "설치됨",
|
||||||
|
"market.button.installing": "설치 중...",
|
||||||
|
"market.button.restart": "재시작 후 적용",
|
||||||
|
"button.component_library": "바탕화면 편집",
|
||||||
|
"tooltip.component_library": "바탕화면 편집",
|
||||||
|
"component_library.title": "바탕화면 편집",
|
||||||
|
"component_library.empty": "좌우로 스와이프하여 카테고리를 선택하고 클릭하여 진입한 후 컴포넌트를 바탕화면에 드래그하여 배치하세요.",
|
||||||
|
"component_library.drag_hint": "드래그하여 배치",
|
||||||
|
"component.delete": "삭제",
|
||||||
|
"component.edit": "편집",
|
||||||
|
"component.editor.instance_scope": "설정은 현재 컴포넌트 인스턴스에만 적용됩니다.",
|
||||||
|
"component.editor.info_header": "컴포넌트 정보",
|
||||||
|
"component.editor.id_label": "컴포넌트 ID",
|
||||||
|
"component.editor.placement_label": "인스턴스 ID",
|
||||||
|
"component.editor.scope_label": "범위",
|
||||||
|
"component.editor.scope_instance": "인스턴스 수준 편집기",
|
||||||
|
"component_category.clock": "시계",
|
||||||
|
"component_category.date": "달력",
|
||||||
|
"component_category.weather": "날씨",
|
||||||
|
"component_category.board": "화이트보드",
|
||||||
|
"component_category.media": "미디어",
|
||||||
|
"component_category.info": "정보 추천",
|
||||||
|
"component_category.calculator": "계산기",
|
||||||
|
"component_category.study": "공부",
|
||||||
|
"component_category.file": "파일",
|
||||||
|
"component.date": "달력",
|
||||||
|
"component.month_calendar": "월간 달력",
|
||||||
|
"component.lunar_calendar": "음력",
|
||||||
|
"component.desktop_clock": "시계",
|
||||||
|
"component.weather_clock": "날씨 시계",
|
||||||
|
"component.world_clock": "세계 시계",
|
||||||
|
"component.desktop_timer": "타이머",
|
||||||
|
"component.desktop_weather": "날씨",
|
||||||
|
"component.hourly_weather": "시간별 날씨",
|
||||||
|
"component.multiday_weather": "다일 날씨",
|
||||||
|
"component.extended_weather": "확장 날씨",
|
||||||
|
"component.class_schedule": "시간표",
|
||||||
|
"component.music_control": "음악 제어",
|
||||||
|
"component.audio_recorder": "녹음",
|
||||||
|
"component.daily_poetry": "매일 시",
|
||||||
|
"component.daily_artwork": "매일 명화",
|
||||||
|
"component.daily_word": "매일 단어",
|
||||||
|
"component.daily_word_2x2": "매일 단어 2x2",
|
||||||
|
"component.cnr_daily_news": "CNR 뉴스",
|
||||||
|
"component.ifeng_news": "Ifeng 뉴스",
|
||||||
|
"component.bilibili_hot_search": "Bilibili 인기 검색",
|
||||||
|
"component.baidu_hot_search": "Baidu 인기 검색",
|
||||||
|
"component.stcn24_forum": "STCN 24",
|
||||||
|
"component.exchange_rate_converter": "환율 변환기",
|
||||||
|
"component.whiteboard": "세로 작은 칠판",
|
||||||
|
"component.blackboard_landscape": "가로 작은 칠판",
|
||||||
|
"component.browser": "브라우저",
|
||||||
|
"component.office_recent_documents": "최근 문서",
|
||||||
|
"whiteboard.settings.desc": "각 작은 칠판은 독립적으로 자신의 노트 기록을 저장합니다.",
|
||||||
|
"whiteboard.settings.retention.title": "노트 보존 기간",
|
||||||
|
"whiteboard.settings.retention.desc": "이 작은 칠판에서 만료된 노트가 자동 삭제되기 전에 저장된 노트를 얼마나 오래 보존할지 선택합니다.",
|
||||||
|
"whiteboard.settings.retention.option": "{0}일",
|
||||||
|
"whiteboard.settings.instance_scope": "이 보존 기간 설정은 각 작은 칠판 컴포넌트 인스턴스별로 개별 저장됩니다.",
|
||||||
|
"office_recent_documents.settings.desc": "이 위젯이 스캔할 Windows 및 Office 최근 문서 소스를 선택합니다.",
|
||||||
|
"office_recent_documents.settings.sources_title": "최근 문서 소스",
|
||||||
|
"office_recent_documents.settings.sources_desc": "여러 소스를 동시에 선택할 수 있습니다. 레지스트리 소스를 선택하면 Office interop MRU 대체도 유지됩니다.",
|
||||||
|
"office_recent_documents.settings.source.registry": "Office 레지스트리 MRU",
|
||||||
|
"office_recent_documents.settings.source.recent_folders": "Windows 최근 폴더",
|
||||||
|
"office_recent_documents.settings.source.jump_lists": "Windows 점프 목록",
|
||||||
|
"office_recent_documents.settings.hint": "모든 소스를 끄면 최소 하나의 소스를 다시 활성화할 때까지 이 위젯은 비어 있게 됩니다.",
|
||||||
|
"component.holiday_calendar": "공휴일 달력",
|
||||||
|
"component.study_environment": "환경",
|
||||||
|
"component.study_session_control": "공부 시간 제어",
|
||||||
|
"component.study_session_history": "기록 시간 데이터",
|
||||||
|
"component.study_noise_curve": "소음 곡선",
|
||||||
|
"component.study_noise_distribution": "소음 레벨 분포",
|
||||||
|
"component.study_score_overview": "공부 점수 개요",
|
||||||
|
"component.study_deduction_reasons": "감점 원인",
|
||||||
|
"component.study_interrupt_density": "방해 밀도",
|
||||||
|
"desktop_clock.settings.title": "시계 설정",
|
||||||
|
"desktop_clock.settings.desc": "단일 시계의 시간대를 선택합니다.",
|
||||||
|
"desktop_clock.settings.timezone_label": "시간대",
|
||||||
|
"desktop_clock.settings.second_mode_label": "초침 방식",
|
||||||
|
"clock.second_mode.tick": "똑딱이",
|
||||||
|
"clock.second_mode.sweep": "스윕",
|
||||||
|
"poetry.widget.loading_content": "시 불러오는 중",
|
||||||
|
"poetry.widget.loading_author": "로딩 중",
|
||||||
|
"poetry.widget.fetch_failed": "시 가져오기 실패",
|
||||||
|
"poetry.widget.fallback_content": "오늘의 시를 사용할 수 없습니다",
|
||||||
|
"poetry.widget.fallback_author": "나중에 다시 시도하세요",
|
||||||
|
"poetry.widget.unknown_author": "익명",
|
||||||
|
"artwork.widget.loading": "로딩 중",
|
||||||
|
"artwork.widget.loading_title": "매일 명화",
|
||||||
|
"artwork.widget.loading_subtitle": "오늘의 명화 가져오는 중",
|
||||||
|
"artwork.widget.fetch_failed": "명화 가져오기 실패",
|
||||||
|
"artwork.widget.fallback_title": "매일 명화",
|
||||||
|
"artwork.widget.fallback_artist": "추천 서비스를 사용할 수 없습니다",
|
||||||
|
"artwork.widget.fallback_year": "나중에 다시 시도하세요",
|
||||||
|
"artwork.widget.unknown_artist": "알 수 없는 작가",
|
||||||
|
"dailyword.widget.loading": "로딩 중...",
|
||||||
|
"dailyword.widget.loading_word": "매일 단어",
|
||||||
|
"dailyword.widget.loading_pronunciation": "발음 가져오는 중",
|
||||||
|
"dailyword.widget.loading_meaning": "뜻 가져오는 중",
|
||||||
|
"dailyword.widget.loading_example": "예문 가져오는 중",
|
||||||
|
"dailyword.widget.loading_example_translation": "로딩 중",
|
||||||
|
"dailyword.widget.fetch_failed": "매일 단어 가져오기 실패",
|
||||||
|
"dailyword.widget.fallback_word": "매일 단어",
|
||||||
|
"dailyword.widget.fallback_pronunciation": "발음을 사용할 수 없습니다",
|
||||||
|
"dailyword.widget.fallback_meaning": "Youdao 사전을 사용할 수 없습니다",
|
||||||
|
"dailyword.widget.fallback_example": "오른쪽 상단 새로고침을 클릭하여 다시 시도하세요",
|
||||||
|
"dailyword.widget.fallback_example_translation": "네트워크 복구 후 자동 업데이트됩니다",
|
||||||
|
"dailyword2x2.widget.tap_to_show": "탭하여 뜻 보기",
|
||||||
|
"cnrnews.widget.loading": "로딩 중...",
|
||||||
|
"cnrnews.widget.loading_title": "뉴스 헤드라인 가져오는 중",
|
||||||
|
"cnrnews.widget.loading_subtitle": "잠시 기다려주세요",
|
||||||
|
"cnrnews.widget.fetch_failed": "뉴스 가져오기 실패",
|
||||||
|
"cnrnews.widget.fallback_title": "CNR 뉴스를 사용할 수 없습니다",
|
||||||
|
"cnrnews.widget.fallback_subtitle": "오른쪽 상단을 클릭하여 나중에 다시 시도하세요",
|
||||||
|
"cnrnews.widget.hot_label": "핫",
|
||||||
|
"bilihot.widget.brand": "bilibili 인기 검색",
|
||||||
|
"bilihot.widget.top_right_label": "bilibili 인기 검색",
|
||||||
|
"bilihot.widget.search_entry": "검색",
|
||||||
|
"bilihot.widget.search_placeholder": "인기 검색어 검색",
|
||||||
|
"bilihot.widget.loading": "로딩 중...",
|
||||||
|
"bilihot.widget.loading_item": "로딩 중...",
|
||||||
|
"bilihot.widget.fetch_failed": "인기 검색 가져오기 실패",
|
||||||
|
"bilihot.widget.fallback_item": "인기 검색 없음",
|
||||||
|
"bilihot.widget.more_hot": "더 많은 인기 검색",
|
||||||
|
"baiduhot.widget.brand": "Baidu 인기 검색",
|
||||||
|
"baiduhot.widget.loading": "로딩 중...",
|
||||||
|
"baiduhot.widget.loading_item": "로딩 중...",
|
||||||
|
"baiduhot.widget.fetch_failed": "인기 검색 가져오기 실패",
|
||||||
|
"baiduhot.widget.fallback_item": "인기 검색 없음",
|
||||||
|
"baiduhot.widget.refresh_tooltip": "새로고침",
|
||||||
|
"ifeng.widget.brand": "Ifeng 뉴스",
|
||||||
|
"ifeng.widget.loading": "로딩 중...",
|
||||||
|
"ifeng.widget.loading_item": "로딩 중...",
|
||||||
|
"ifeng.widget.fetch_failed": "뉴스 가져오기 실패",
|
||||||
|
"ifeng.widget.fallback_item": "뉴스 없음",
|
||||||
|
"ifeng.widget.refresh_tooltip": "새로고침",
|
||||||
|
"dailyword.settings.title": "매일 단어 설정",
|
||||||
|
"dailyword.settings.desc": "자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"dailyword.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"dailyword.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"dailyword.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"bilihot.settings.title": "Bilibili 인기 검색 설정",
|
||||||
|
"bilihot.settings.desc": "자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"bilihot.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"bilihot.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"bilihot.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"baiduhot.settings.title": "Baidu 인기 검색 설정",
|
||||||
|
"baiduhot.settings.desc": "데이터 소스, 자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"baiduhot.settings.source_label": "데이터 소스",
|
||||||
|
"baiduhot.settings.source_official": "Baidu 공식 소스",
|
||||||
|
"baiduhot.settings.source_rss": "타사 RSS 소스",
|
||||||
|
"baiduhot.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"baiduhot.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"baiduhot.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"ifeng.settings.title": "Ifeng 뉴스 설정",
|
||||||
|
"ifeng.settings.desc": "채널, 자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"ifeng.settings.channel_label": "뉴스 채널",
|
||||||
|
"ifeng.settings.channel_comprehensive": "종합",
|
||||||
|
"ifeng.settings.channel_mainland": "중국 본토",
|
||||||
|
"ifeng.settings.channel_taiwan": "대만",
|
||||||
|
"ifeng.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"ifeng.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"ifeng.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"refresh.frequency.5m": "5분",
|
||||||
|
"refresh.frequency.10m": "10분",
|
||||||
|
"refresh.frequency.12m": "12분",
|
||||||
|
"refresh.frequency.15m": "15분",
|
||||||
|
"refresh.frequency.20m": "20분",
|
||||||
|
"refresh.frequency.30m": "30분",
|
||||||
|
"refresh.frequency.40m": "40분",
|
||||||
|
"refresh.frequency.1h": "1시간",
|
||||||
|
"refresh.frequency.3h": "3시간",
|
||||||
|
"refresh.frequency.6h": "6시간",
|
||||||
|
"refresh.frequency.12h": "12시간",
|
||||||
|
"refresh.frequency.24h": "24시간",
|
||||||
|
"weather.widget.settings.title": "날씨 컴포넌트 설정",
|
||||||
|
"weather.widget.settings.desc": "모든 날씨 컴포넌트의 자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"weather.widget.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"weather.widget.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"weather.widget.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"weather.widget.settings.frequency_10m": "10분",
|
||||||
|
"weather.widget.settings.frequency_12m": "12분",
|
||||||
|
"weather.widget.settings.frequency_15m": "15분",
|
||||||
|
"weather.widget.settings.frequency_30m": "30분",
|
||||||
|
"weather.widget.settings.frequency_1h": "1시간",
|
||||||
|
"weather.widget.settings.frequency_3h": "3시간",
|
||||||
|
"stcn24.widget.loading": "로딩 중...",
|
||||||
|
"stcn24.widget.loading_item": "로딩 중...",
|
||||||
|
"stcn24.widget.fetch_failed": "게시물 가져오기 실패",
|
||||||
|
"stcn24.widget.fallback_item": "게시물 없음",
|
||||||
|
"stcn24.settings.title": "STCN 24 설정",
|
||||||
|
"stcn24.settings.desc": "정보 소스, 자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"stcn24.settings.source_label": "정보 소스",
|
||||||
|
"stcn24.settings.source_latest_created": "최신 게시",
|
||||||
|
"stcn24.settings.source_latest_activity": "최신 답변",
|
||||||
|
"stcn24.settings.source_most_replies": "답변 많음",
|
||||||
|
"stcn24.settings.source_earliest_created": "가장 오래된 게시",
|
||||||
|
"stcn24.settings.source_earliest_activity": "가장 오래된 답변",
|
||||||
|
"stcn24.settings.source_least_replies": "답변 적음",
|
||||||
|
"stcn24.settings.source_frontpage_latest": "프론트 추천 (신규)",
|
||||||
|
"stcn24.settings.source_frontpage_earliest": "프론트 추천 (구형)",
|
||||||
|
"stcn24.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"stcn24.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"stcn24.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"stcn24.settings.frequency_5m": "5분",
|
||||||
|
"stcn24.settings.frequency_10m": "10분",
|
||||||
|
"stcn24.settings.frequency_20m": "20분",
|
||||||
|
"stcn24.settings.frequency_30m": "30분",
|
||||||
|
"stcn24.settings.frequency_1h": "1시간",
|
||||||
|
"stcn24.settings.frequency_3h": "3시간",
|
||||||
|
"exchange.widget.loading": "환율 로딩 중...",
|
||||||
|
"exchange.widget.fetch_failed": "환율 가져오기 실패",
|
||||||
|
"cnrnews.settings.title": "CNR 뉴스 설정",
|
||||||
|
"cnrnews.settings.desc": "뉴스 자동 순환과 새로고침 빈도를 구성합니다.",
|
||||||
|
"cnrnews.settings.auto_rotate_label": "자동 순환",
|
||||||
|
"cnrnews.settings.auto_rotate_enabled": "자동 순환 활성화",
|
||||||
|
"cnrnews.settings.frequency_label": "순환 빈도",
|
||||||
|
"cnrnews.settings.frequency_5m": "5분",
|
||||||
|
"cnrnews.settings.frequency_10m": "10분",
|
||||||
|
"cnrnews.settings.frequency_40m": "40분",
|
||||||
|
"cnrnews.settings.frequency_1h": "1시간",
|
||||||
|
"cnrnews.settings.frequency_12h": "12시간",
|
||||||
|
"cnrnews.settings.frequency_24h": "24시간",
|
||||||
|
"artwork.settings.title": "매일 이미지 설정",
|
||||||
|
"artwork.settings.desc": "매일 이미지의 데이터 소스를 전환합니다.",
|
||||||
|
"artwork.settings.source_label": "미러 소스",
|
||||||
|
"artwork.settings.source_domestic": "국내 미러",
|
||||||
|
"artwork.settings.source_overseas": "해외 미러",
|
||||||
|
"artwork.settings.source_status_domestic": "현재 소스: 국내 미러 (중국 네트워크 우선)",
|
||||||
|
"artwork.settings.source_status_overseas": "현재 소스: 해외 미러 (미술관 추천)",
|
||||||
|
"music.widget.unsupported": "현재 플랫폼에서 음악 제어를 지원하지 않습니다",
|
||||||
|
"music.widget.unsupported_hint": "이 컴포넌트는 Windows SMTC만 지원합니다",
|
||||||
|
"music.widget.no_session": "음원 없음",
|
||||||
|
"music.widget.no_session_hint": "앱 스토어에서 \"QQ Music/Kugou Music/NetEase Cloud Music\"을 다운로드한 후 사용하세요",
|
||||||
|
"music.widget.open_player": "플레이어 열기",
|
||||||
|
"music.widget.unknown_title": "알 수 없는 곡",
|
||||||
|
"music.widget.unknown_artist": "알 수 없는 아티스트",
|
||||||
|
"music.widget.status.opened": "열림",
|
||||||
|
"music.widget.status.changing": "전환 중",
|
||||||
|
"music.widget.status.stopped": "정지됨",
|
||||||
|
"music.widget.status.playing": "재생 중",
|
||||||
|
"music.widget.status.paused": "일시정지됨",
|
||||||
|
"recording.widget.title": "녹음",
|
||||||
|
"recording.widget.hint.ready": "빨간 버튼을 클릭하여 시작",
|
||||||
|
"recording.widget.hint.recording": "녹음 중",
|
||||||
|
"recording.widget.hint.paused": "일시정지됨",
|
||||||
|
"recording.widget.hint.unsupported": "마이크를 사용할 수 없음",
|
||||||
|
"recording.widget.hint.error": "녹음 실패",
|
||||||
|
"recording.widget.hint.saved_format": "{0} 저장됨",
|
||||||
|
"recording.widget.save_picker_title": "녹음 파일 저장",
|
||||||
|
"recording.widget.save_picker_type": "WAV 오디오",
|
||||||
|
"study.environment.status_label": "환경 상태",
|
||||||
|
"study.environment.status.initializing": "초기화 중",
|
||||||
|
"study.environment.status.ready": "대기",
|
||||||
|
"study.environment.status.quiet": "조용함",
|
||||||
|
"study.environment.status.noisy": "시끄러움",
|
||||||
|
"study.environment.status.paused": "일시정지됨",
|
||||||
|
"study.environment.status.error": "오류",
|
||||||
|
"study.environment.status.unsupported": "지원하지 않음",
|
||||||
|
"study.environment.value.unavailable": "--",
|
||||||
|
"study.environment.value.display_format": "{0:F1} dB",
|
||||||
|
"study.environment.value.dbfs_format": "{0:F1} dBFS",
|
||||||
|
"component.removable_storage": "이동식 저장소",
|
||||||
|
"removable_storage.settings.desc": "연결된 USB 드라이브를 바탕화면에 표시하고 열기 및 꺼내기 작업을 제공합니다.",
|
||||||
|
"removable_storage.settings.behavior_title": "동작",
|
||||||
|
"removable_storage.settings.behavior_desc": "컴포넌트는 이동식 저장 장치를 자동으로 모니터링하고 가장 최근에 연결된 USB 드라이브를 우선 표시합니다.",
|
||||||
|
"removable_storage.action.open": "열기",
|
||||||
|
"removable_storage.action.eject": "꺼내기",
|
||||||
|
"removable_storage.widget.default_name": "이동식 디스크",
|
||||||
|
"removable_storage.widget.empty_title": "연결된 기기 없음",
|
||||||
|
"removable_storage.widget.empty_subtitle": "USB 드라이브를 연결하면 여기에 자동으로 표시됩니다.",
|
||||||
|
"removable_storage.widget.empty_hint": "이동식 기기를 연결하기 전까지 하단 버튼은 비활성화됩니다.",
|
||||||
|
"removable_storage.widget.ready": "준비 완료, 바로 열거나 꺼낼 수 있습니다.",
|
||||||
|
"removable_storage.widget.ejecting": "기기 꺼내는 중...",
|
||||||
|
"removable_storage.widget.eject_failed": "이 기기를 꺼낼 수 없습니다. 사용 중인 파일을 닫은 후 다시 시도하세요.",
|
||||||
|
"removable_storage.widget.open_failed": "이 기기를 열지 못했습니다.",
|
||||||
|
"removable_storage.widget.refresh_failed": "이동식 저장소 목록 새로고침 실패.",
|
||||||
|
"study.environment.settings.title": "환경 컴포넌트 설정",
|
||||||
|
"study.environment.settings.desc": "오른쪽 실시간 소음 값 표시 내용을 구성합니다.",
|
||||||
|
"study.environment.settings.show_display_db": "display dB 표시",
|
||||||
|
"study.environment.settings.show_dbfs": "dBFS 표시",
|
||||||
|
"study.environment.settings.hint": "최소 하나의 표시 방식을 활성화하세요.",
|
||||||
|
"study.session_control.action.start": "공부 시간 시작",
|
||||||
|
"study.session_control.action.stop": "공부 시간 종료",
|
||||||
|
"study.session_control.idle_hint": "오른쪽 버튼을 클릭하여 시작",
|
||||||
|
"study.session_control.report_preview": "보고서 미리보기",
|
||||||
|
"study.session_control.report_confirm_hint": "오른쪽을 클릭하여 보기 종료 확인",
|
||||||
|
"study.session_control.running_elapsed_format": "{0} 진행됨",
|
||||||
|
"study.session_control.last_session_format": "마지막 시간 {0}",
|
||||||
|
"study.session_control.start_failed": "시작 실패",
|
||||||
|
"study.session_control.stop_failed": "종료 실패",
|
||||||
|
"study.session_history.title": "기록 시간",
|
||||||
|
"study.session_history.empty": "기록 시간 없음",
|
||||||
|
"study.session_history.select_failed": "전환 실패",
|
||||||
|
"study.session_history.rename_failed": "이름 변경 실패",
|
||||||
|
"study.session_history.delete_failed": "삭제 실패",
|
||||||
|
"study.session_history.rename_placeholder": "시간 이름 입력",
|
||||||
|
"study.session_history.rename_confirm": "이름 변경 확인",
|
||||||
|
"study.session_history.rename_cancel": "이름 변경 취소",
|
||||||
|
"study.session_history.loading": "데이터 로딩 중...",
|
||||||
|
"study.session_history.loaded": "데이터 로드됨",
|
||||||
|
"study.session_history.duration_format": "{0:hh\\:mm\\:ss}",
|
||||||
|
"study.session_history.meta_format": "{0} · 평균 {1:F1}",
|
||||||
|
"study.session_history.action.view": "보기",
|
||||||
|
"study.session_history.action.rename": "이름 변경",
|
||||||
|
"study.session_history.action.delete": "삭제",
|
||||||
|
"study.session_history.dialog.rename_title": "시간 이름 변경",
|
||||||
|
"study.session_history.dialog.rename_message": "\"{0}\"의 새 이름을 입력하세요.",
|
||||||
|
"study.session_history.dialog.delete_title": "시간 삭제",
|
||||||
|
"study.session_history.dialog.delete_message": "\"{0}\"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
|
"study.session_history.dialog.delete_confirm": "삭제 확인",
|
||||||
|
"study.noise_curve.value_format": "{0:F1} dB",
|
||||||
|
"study.noise_curve.axis.now": "현재",
|
||||||
|
"study.noise_distribution.title": "소음 레벨 분포",
|
||||||
|
"study.noise_distribution.mode.realtime": "실시간",
|
||||||
|
"study.noise_distribution.mode.session": "시간",
|
||||||
|
"study.noise_distribution.summary.mainly_format": "주로: {0}",
|
||||||
|
"study.noise_distribution.summary.latest_format": "최신: {0}",
|
||||||
|
"study.noise_distribution.summary.compact_format": "주 {0} · 신 {1}",
|
||||||
|
"study.noise_distribution.level.quiet": "조용함",
|
||||||
|
"study.noise_distribution.level.normal": "보통",
|
||||||
|
"study.noise_distribution.level.noisy": "시끄러움",
|
||||||
|
"study.noise_distribution.level.extreme": "매우 시끄러움",
|
||||||
|
"study.noise_distribution.axis.extreme": "매우 시끄러움",
|
||||||
|
"study.noise_distribution.axis.noisy": "시끄러움",
|
||||||
|
"study.noise_distribution.axis.normal": "보통",
|
||||||
|
"study.noise_distribution.axis.quiet": "조용함",
|
||||||
|
"study.noise_distribution.axis.now": "현재",
|
||||||
|
"study.score_overview.title": "공부 점수",
|
||||||
|
"study.score_overview.mode.realtime": "실시간",
|
||||||
|
"study.score_overview.mode.session": "시간",
|
||||||
|
"study.score_overview.current": "현재",
|
||||||
|
"study.score_overview.average": "평균",
|
||||||
|
"study.score_overview.minimum": "최저",
|
||||||
|
"study.score_overview.maximum": "최고",
|
||||||
|
"study.score_overview.average_short": "평",
|
||||||
|
"study.score_overview.minimum_short": "저",
|
||||||
|
"study.score_overview.maximum_short": "고",
|
||||||
|
"study.score_overview.unavailable": "--",
|
||||||
|
"study.deduction.title": "감점 원인",
|
||||||
|
"study.deduction.mode.realtime": "실시간",
|
||||||
|
"study.deduction.mode.session": "시간",
|
||||||
|
"study.deduction.reason.sustained": "지속적 소음",
|
||||||
|
"study.deduction.reason.time": "임계 초과 시간",
|
||||||
|
"study.deduction.reason.segment": "방해 빈도",
|
||||||
|
"study.deduction.reason.sustained_short": "지속",
|
||||||
|
"study.deduction.reason.time_short": "시간",
|
||||||
|
"study.deduction.reason.segment_short": "방해",
|
||||||
|
"study.deduction.metric.sustained_format": "p50 {0:F1} dBFS",
|
||||||
|
"study.deduction.metric.sustained_short_format": "p50 {0:F1}",
|
||||||
|
"study.deduction.metric.time_format": "임계 초과 {0:F1}%",
|
||||||
|
"study.deduction.metric.time_short_format": "{0:F1}%",
|
||||||
|
"study.deduction.metric.segment_format": "{0:F1}회/분",
|
||||||
|
"study.deduction.metric.segment_short_format": "{0:F1}/분",
|
||||||
|
"study.deduction.loss_format": "-{0:F1}",
|
||||||
|
"study.deduction.total_loss_format": "총 감점 -{0:F1}",
|
||||||
|
"study.deduction.total_score_format": "점수 {0:F1}",
|
||||||
|
"study.deduction.total_loss_unavailable": "총 감점 {0}",
|
||||||
|
"study.deduction.total_score_unavailable": "점수 {0}",
|
||||||
|
"study.deduction.unavailable": "--",
|
||||||
|
"study.interrupt_density.title": "방해 밀도",
|
||||||
|
"study.interrupt_density.mode.realtime": "실시간",
|
||||||
|
"study.interrupt_density.mode.session": "시간",
|
||||||
|
"study.interrupt_density.unit": "회/분",
|
||||||
|
"study.interrupt_density.segment_count": "방해 횟수",
|
||||||
|
"study.interrupt_density.segment_count_short": "횟수",
|
||||||
|
"study.interrupt_density.duration": "통계 시간",
|
||||||
|
"study.interrupt_density.duration_short": "시간",
|
||||||
|
"study.interrupt_density.density_value_format": "{0:F1}",
|
||||||
|
"study.interrupt_density.segment_count_value_format": "{0}",
|
||||||
|
"study.interrupt_density.level_format": "방해 레벨: {0}",
|
||||||
|
"study.interrupt_density.level.calm": "낮음",
|
||||||
|
"study.interrupt_density.level.normal": "보통",
|
||||||
|
"study.interrupt_density.level.frequent": "높음",
|
||||||
|
"study.interrupt_density.level.severe": "매우 높음",
|
||||||
|
"study.interrupt_density.threshold_format": "최대 감점 임계값 {0:F1}회/분",
|
||||||
|
"study.interrupt_density.unavailable": "--",
|
||||||
|
"desktop.add_page": "새 페이지 추가",
|
||||||
|
"desktop.delete_page": "페이지 삭제",
|
||||||
|
"placement.fill": "채우기",
|
||||||
|
"placement.fit": "맞추기",
|
||||||
|
"placement.stretch": "늘리기",
|
||||||
|
"placement.center": "가운데",
|
||||||
|
"placement.tile": "바둑판",
|
||||||
|
"single_instance.notice.title": "앱이 이미 실행 중입니다",
|
||||||
|
"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지금 재시작하시겠습니까?"
|
||||||
|
}
|
||||||
@@ -953,6 +953,10 @@
|
|||||||
"study.interrupt_density.unavailable": "--",
|
"study.interrupt_density.unavailable": "--",
|
||||||
"desktop.add_page": "新增页面",
|
"desktop.add_page": "新增页面",
|
||||||
"desktop.delete_page": "删除页面",
|
"desktop.delete_page": "删除页面",
|
||||||
|
"desktop.delete_page_confirm.title": "确认删除页面",
|
||||||
|
"desktop.delete_page_confirm.message": "确定要删除当前页面吗?\n\n此操作将删除当前页面上的所有组件,且无法撤销。",
|
||||||
|
"desktop.delete_page_confirm.primary": "删除",
|
||||||
|
"desktop.delete_page_confirm.close": "取消",
|
||||||
"placement.fill": "填充",
|
"placement.fill": "填充",
|
||||||
"placement.fit": "适应",
|
"placement.fit": "适应",
|
||||||
"placement.stretch": "拉伸",
|
"placement.stretch": "拉伸",
|
||||||
|
|||||||
261
LanMountainDesktop/Services/ComponentPreviewImageService.cs
Normal file
261
LanMountainDesktop/Services/ComponentPreviewImageService.cs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class ComponentPreviewImageService : IComponentPreviewImageService
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<ComponentPreviewKey, ComponentPreviewImageEntry> _entries = new(ComponentPreviewKeyComparer.Instance);
|
||||||
|
private readonly Dictionary<ComponentPreviewKey, Task<ComponentPreviewImageEntry>> _inFlightRequests = new(ComponentPreviewKeyComparer.Instance);
|
||||||
|
private Task _queueTail = Task.CompletedTask;
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = new ComponentPreviewImageEntry(key, visualSignature);
|
||||||
|
_entries[key] = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
entry = existing;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _entries.Values.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||||
|
ComponentPreviewKey key,
|
||||||
|
string visualSignature,
|
||||||
|
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(generationWork);
|
||||||
|
|
||||||
|
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entry = GetOrCreateEntryCore(key);
|
||||||
|
|
||||||
|
if (entry.State == ComponentPreviewImageState.Ready &&
|
||||||
|
entry.Bitmap is not null &&
|
||||||
|
StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||||
|
{
|
||||||
|
return Task.FromResult(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_inFlightRequests.TryGetValue(key, out var inFlight))
|
||||||
|
{
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedRevision = entry.BeginGeneration(normalizedSignature);
|
||||||
|
var previousTask = _queueTail;
|
||||||
|
var queuedTask = RunGenerationAsync(
|
||||||
|
previousTask,
|
||||||
|
key,
|
||||||
|
entry,
|
||||||
|
expectedRevision,
|
||||||
|
normalizedSignature,
|
||||||
|
generationWork,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
_inFlightRequests[key] = queuedTask;
|
||||||
|
_queueTail = queuedTask.ContinueWith(
|
||||||
|
static _ => { },
|
||||||
|
CancellationToken.None,
|
||||||
|
TaskContinuationOptions.ExecuteSynchronously,
|
||||||
|
TaskScheduler.Default);
|
||||||
|
return queuedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bitmap);
|
||||||
|
|
||||||
|
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entry = GetOrCreateEntryCore(key);
|
||||||
|
entry.StoreBitmap(bitmap, normalizedSignature);
|
||||||
|
_inFlightRequests.Remove(key);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null)
|
||||||
|
{
|
||||||
|
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entry = GetOrCreateEntryCore(key);
|
||||||
|
entry.StoreFailure(normalizedSignature, errorMessage);
|
||||||
|
_inFlightRequests.Remove(key);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Invalidate(ComponentPreviewKey key, string? visualSignature = null)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_entries.TryGetValue(key, out var entry))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Invalidate(visualSignature);
|
||||||
|
_inFlightRequests.Remove(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int RemovePlacementPreviews(string placementId)
|
||||||
|
{
|
||||||
|
var normalizedPlacementId = NormalizeRequired(placementId, nameof(placementId));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entriesToRemove = _entries
|
||||||
|
.Where(static pair => pair.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||||
|
.Where(pair => StringComparer.OrdinalIgnoreCase.Equals(pair.Key.PlacementId, normalizedPlacementId))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var pair in entriesToRemove)
|
||||||
|
{
|
||||||
|
pair.Value.DisposeBitmap();
|
||||||
|
_entries.Remove(pair.Key);
|
||||||
|
_inFlightRequests.Remove(pair.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entriesToRemove.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int InvalidateVisualSignature(string visualSignature)
|
||||||
|
{
|
||||||
|
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entriesToInvalidate = _entries.Values
|
||||||
|
.Where(entry => StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var entry in entriesToInvalidate)
|
||||||
|
{
|
||||||
|
entry.Invalidate(normalizedSignature);
|
||||||
|
_inFlightRequests.Remove(entry.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entriesToInvalidate.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ComponentPreviewImageEntry> RunGenerationAsync(
|
||||||
|
Task previousTask,
|
||||||
|
ComponentPreviewKey key,
|
||||||
|
ComponentPreviewImageEntry entry,
|
||||||
|
long expectedRevision,
|
||||||
|
string visualSignature,
|
||||||
|
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await previousTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep serial queue processing even if previous work faulted.
|
||||||
|
}
|
||||||
|
|
||||||
|
IImage? bitmap;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bitmap = await generationWork(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
entry.TryApplyFailure(expectedRevision, visualSignature, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (bitmap is null)
|
||||||
|
{
|
||||||
|
entry.TryApplyFailure(expectedRevision, visualSignature, "Preview generation returned no bitmap.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entry.TryApplyGeneratedBitmap(expectedRevision, bitmap, visualSignature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_inFlightRequests.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewImageEntry GetOrCreateEntryCore(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = new ComponentPreviewImageEntry(key);
|
||||||
|
_entries[key] = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRequired(string? value, string paramName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
281
LanMountainDesktop/Services/ComponentPreviewTypes.cs
Normal file
281
LanMountainDesktop/Services/ComponentPreviewTypes.cs
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public enum ComponentPreviewKeyKind
|
||||||
|
{
|
||||||
|
ComponentType = 0,
|
||||||
|
PlacementInstance = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct ComponentPreviewKey
|
||||||
|
{
|
||||||
|
private ComponentPreviewKey(
|
||||||
|
ComponentPreviewKeyKind kind,
|
||||||
|
string componentTypeId,
|
||||||
|
string? placementId,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
ComponentTypeId = NormalizeRequired(componentTypeId, nameof(componentTypeId));
|
||||||
|
PlacementId = kind == ComponentPreviewKeyKind.PlacementInstance
|
||||||
|
? NormalizeRequired(placementId, nameof(placementId))
|
||||||
|
: null;
|
||||||
|
WidthCells = NormalizeSpan(widthCells, nameof(widthCells));
|
||||||
|
HeightCells = NormalizeSpan(heightCells, nameof(heightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewKeyKind Kind { get; }
|
||||||
|
|
||||||
|
public string ComponentTypeId { get; }
|
||||||
|
|
||||||
|
public string? PlacementId { get; }
|
||||||
|
|
||||||
|
public int WidthCells { get; }
|
||||||
|
|
||||||
|
public int HeightCells { get; }
|
||||||
|
|
||||||
|
public static ComponentPreviewKey ForComponentType(string componentTypeId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
return new ComponentPreviewKey(ComponentPreviewKeyKind.ComponentType, componentTypeId, null, widthCells, heightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComponentPreviewKey ForPlacementInstance(string componentTypeId, string placementId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
return new ComponentPreviewKey(
|
||||||
|
ComponentPreviewKeyKind.PlacementInstance,
|
||||||
|
componentTypeId,
|
||||||
|
placementId,
|
||||||
|
widthCells,
|
||||||
|
heightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return Kind == ComponentPreviewKeyKind.ComponentType
|
||||||
|
? $"Type:{ComponentTypeId}[{WidthCells}x{HeightCells}]"
|
||||||
|
: $"Placement:{ComponentTypeId}@{PlacementId}[{WidthCells}x{HeightCells}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRequired(string? value, string paramName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizeSpan(int value, string paramName)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(paramName, value, "Span must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ComponentPreviewImageState
|
||||||
|
{
|
||||||
|
Pending = 0,
|
||||||
|
Ready = 1,
|
||||||
|
Failed = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ComponentPreviewImageEntry : ObservableObject
|
||||||
|
{
|
||||||
|
private IImage? _bitmap;
|
||||||
|
private ComponentPreviewImageState _state = ComponentPreviewImageState.Pending;
|
||||||
|
private string _visualSignature = string.Empty;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private long _revision;
|
||||||
|
private DateTimeOffset _lastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewKey Key { get; }
|
||||||
|
|
||||||
|
public IImage? Bitmap
|
||||||
|
{
|
||||||
|
get => _bitmap;
|
||||||
|
private set => SetProperty(ref _bitmap, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewImageState State
|
||||||
|
{
|
||||||
|
get => _state;
|
||||||
|
private set => SetProperty(ref _state, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string VisualSignature
|
||||||
|
{
|
||||||
|
get => _visualSignature;
|
||||||
|
private set => SetProperty(ref _visualSignature, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ErrorMessage
|
||||||
|
{
|
||||||
|
get => _errorMessage;
|
||||||
|
private set => SetProperty(ref _errorMessage, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long Revision
|
||||||
|
{
|
||||||
|
get => _revision;
|
||||||
|
private set => SetProperty(ref _revision, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset LastUpdatedUtc
|
||||||
|
{
|
||||||
|
get => _lastUpdatedUtc;
|
||||||
|
private set => SetProperty(ref _lastUpdatedUtc, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal long BeginGeneration(string visualSignature)
|
||||||
|
{
|
||||||
|
var normalizedVisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
var nextRevision = Revision + 1;
|
||||||
|
Revision = nextRevision;
|
||||||
|
VisualSignature = normalizedVisualSignature;
|
||||||
|
State = ComponentPreviewImageState.Pending;
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
return nextRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryApplyGeneratedBitmap(long expectedRevision, IImage bitmap, string visualSignature)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bitmap);
|
||||||
|
|
||||||
|
if (Revision != expectedRevision)
|
||||||
|
{
|
||||||
|
DisposeIfNeeded(bitmap);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
State = ComponentPreviewImageState.Ready;
|
||||||
|
ReplaceBitmap(bitmap);
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryApplyFailure(long expectedRevision, string visualSignature, string? errorMessage)
|
||||||
|
{
|
||||||
|
if (Revision != expectedRevision)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
State = ComponentPreviewImageState.Failed;
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void StoreBitmap(IImage bitmap, string visualSignature)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bitmap);
|
||||||
|
|
||||||
|
Revision += 1;
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
State = ComponentPreviewImageState.Ready;
|
||||||
|
ReplaceBitmap(bitmap);
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void StoreFailure(string visualSignature, string? errorMessage)
|
||||||
|
{
|
||||||
|
Revision += 1;
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
State = ComponentPreviewImageState.Failed;
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Invalidate(string? visualSignature = null)
|
||||||
|
{
|
||||||
|
Revision += 1;
|
||||||
|
if (visualSignature is not null)
|
||||||
|
{
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
State = ComponentPreviewImageState.Pending;
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void DisposeBitmap()
|
||||||
|
{
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReplaceBitmap(IImage? bitmap)
|
||||||
|
{
|
||||||
|
var previous = _bitmap;
|
||||||
|
if (ReferenceEquals(previous, bitmap))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bitmap = bitmap;
|
||||||
|
DisposeIfNeeded(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeIfNeeded(IImage? bitmap)
|
||||||
|
{
|
||||||
|
if (bitmap is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeSignature(string? visualSignature)
|
||||||
|
{
|
||||||
|
return visualSignature?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ComponentPreviewKeyComparer : IEqualityComparer<ComponentPreviewKey>
|
||||||
|
{
|
||||||
|
public static ComponentPreviewKeyComparer Instance { get; } = new();
|
||||||
|
|
||||||
|
public bool Equals(ComponentPreviewKey x, ComponentPreviewKey y)
|
||||||
|
{
|
||||||
|
return x.Kind == y.Kind &&
|
||||||
|
StringComparer.OrdinalIgnoreCase.Equals(x.ComponentTypeId, y.ComponentTypeId) &&
|
||||||
|
StringComparer.OrdinalIgnoreCase.Equals(x.PlacementId, y.PlacementId) &&
|
||||||
|
x.WidthCells == y.WidthCells &&
|
||||||
|
x.HeightCells == y.HeightCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetHashCode(ComponentPreviewKey obj)
|
||||||
|
{
|
||||||
|
var hash = new HashCode();
|
||||||
|
hash.Add(obj.Kind);
|
||||||
|
hash.Add(obj.ComponentTypeId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
hash.Add(obj.PlacementId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
hash.Add(obj.WidthCells);
|
||||||
|
hash.Add(obj.HeightCells);
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
LanMountainDesktop/Services/FontFamilyService.cs
Normal file
48
LanMountainDesktop/Services/FontFamilyService.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class FontFamilyService
|
||||||
|
{
|
||||||
|
private const string FontsBasePath = "avares://LanMountainDesktop/Assets/Fonts";
|
||||||
|
|
||||||
|
public static readonly FontFamily DefaultFontFamily =
|
||||||
|
new($"{FontsBasePath}#MiSans");
|
||||||
|
|
||||||
|
public static readonly FontFamily JapaneseFontFamily =
|
||||||
|
new($"{FontsBasePath}#MiSans");
|
||||||
|
|
||||||
|
public static readonly FontFamily KoreanFontFamily =
|
||||||
|
new($"Malgun Gothic, {FontsBasePath}#MiSans");
|
||||||
|
|
||||||
|
public FontFamily GetFontFamilyForLanguage(string? languageCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(languageCode))
|
||||||
|
{
|
||||||
|
return DefaultFontFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
return languageCode.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"ja-jp" or "ja" => JapaneseFontFamily,
|
||||||
|
"ko-kr" or "ko" => KoreanFontFamily,
|
||||||
|
_ => DefaultFontFamily
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetFontFamilyResourceKey(string? languageCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(languageCode))
|
||||||
|
{
|
||||||
|
return "AppFontFamily";
|
||||||
|
}
|
||||||
|
|
||||||
|
return languageCode.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"ja-jp" or "ja" => "AppFontFamilyJP",
|
||||||
|
"ko-kr" or "ko" => "AppFontFamilyKR",
|
||||||
|
_ => "AppFontFamily"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
32
LanMountainDesktop/Services/IComponentPreviewImageService.cs
Normal file
32
LanMountainDesktop/Services/IComponentPreviewImageService.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public interface IComponentPreviewImageService
|
||||||
|
{
|
||||||
|
ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null);
|
||||||
|
|
||||||
|
bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry);
|
||||||
|
|
||||||
|
IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot();
|
||||||
|
|
||||||
|
Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||||
|
ComponentPreviewKey key,
|
||||||
|
string visualSignature,
|
||||||
|
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature);
|
||||||
|
|
||||||
|
ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null);
|
||||||
|
|
||||||
|
bool Invalidate(ComponentPreviewKey key, string? visualSignature = null);
|
||||||
|
|
||||||
|
int RemovePlacementPreviews(string placementId);
|
||||||
|
|
||||||
|
int InvalidateVisualSignature(string visualSignature);
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ public sealed class LocalizationService
|
|||||||
{
|
{
|
||||||
"en-us" or "en" => "en-US",
|
"en-us" or "en" => "en-US",
|
||||||
"ja-jp" or "ja" => "ja-JP",
|
"ja-jp" or "ja" => "ja-JP",
|
||||||
|
"ko-kr" or "ko" => "ko-KR",
|
||||||
_ => "zh-CN"
|
_ => "zh-CN"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
using LanMountainDesktop.Settings.Core;
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.Settings;
|
namespace LanMountainDesktop.Services.Settings
|
||||||
|
{
|
||||||
|
|
||||||
public enum WallpaperMediaType
|
public enum WallpaperMediaType
|
||||||
{
|
{
|
||||||
@@ -66,10 +69,272 @@ public sealed record UpdateSettingsState(
|
|||||||
long? PendingUpdatePublishedAtUtcMs,
|
long? PendingUpdatePublishedAtUtcMs,
|
||||||
long? LastUpdateCheckUtcMs);
|
long? LastUpdateCheckUtcMs);
|
||||||
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
||||||
|
public enum PluginPackageSourceKind
|
||||||
|
{
|
||||||
|
ReleaseAsset = 0,
|
||||||
|
RawFallback = 1,
|
||||||
|
WorkspaceLocal = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogSourceInfo(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string? SourceUrl,
|
||||||
|
string? CachePath,
|
||||||
|
bool IsOfficial,
|
||||||
|
int Priority);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogSharedContractInfo(
|
||||||
|
string Id,
|
||||||
|
string Version,
|
||||||
|
string AssemblyName);
|
||||||
|
|
||||||
|
public sealed record PluginCapabilityInfo(
|
||||||
|
string Id,
|
||||||
|
string? Version,
|
||||||
|
string? AssemblyName);
|
||||||
|
|
||||||
|
public sealed record PluginPackageSourceInfo(
|
||||||
|
PluginPackageSourceKind Kind,
|
||||||
|
string Url,
|
||||||
|
string Sha256,
|
||||||
|
long PackageSizeBytes);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogManifestInfo(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Description,
|
||||||
|
string Author,
|
||||||
|
string Version,
|
||||||
|
string ApiVersion,
|
||||||
|
string EntranceAssembly,
|
||||||
|
IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogCompatibilityInfo(
|
||||||
|
string MinHostVersion,
|
||||||
|
string ApiVersion);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogRepositoryInfo(
|
||||||
|
string IconUrl,
|
||||||
|
string ProjectUrl,
|
||||||
|
string ReadmeUrl,
|
||||||
|
string HomepageUrl,
|
||||||
|
string RepositoryUrl,
|
||||||
|
IReadOnlyList<string> Tags,
|
||||||
|
string ReleaseNotes);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogPublicationInfo(
|
||||||
|
string ReleaseTag,
|
||||||
|
string ReleaseAssetName,
|
||||||
|
DateTimeOffset PublishedAt,
|
||||||
|
DateTimeOffset UpdatedAt,
|
||||||
|
long PackageSizeBytes,
|
||||||
|
string Sha256,
|
||||||
|
string? Md5);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogItemInfo(
|
||||||
|
PluginCatalogManifestInfo Manifest,
|
||||||
|
PluginCatalogCompatibilityInfo Compatibility,
|
||||||
|
PluginCatalogRepositoryInfo Repository,
|
||||||
|
PluginCatalogPublicationInfo Publication,
|
||||||
|
IReadOnlyList<PluginPackageSourceInfo> PackageSources,
|
||||||
|
IReadOnlyList<PluginCapabilityInfo> Capabilities)
|
||||||
|
{
|
||||||
|
public string Id => Manifest.Id;
|
||||||
|
|
||||||
|
public string Name => Manifest.Name;
|
||||||
|
|
||||||
|
public string Description => Manifest.Description;
|
||||||
|
|
||||||
|
public string Author => Manifest.Author;
|
||||||
|
|
||||||
|
public string Version => Manifest.Version;
|
||||||
|
|
||||||
|
public string ApiVersion => Manifest.ApiVersion;
|
||||||
|
|
||||||
|
public string MinHostVersion => Compatibility.MinHostVersion;
|
||||||
|
|
||||||
|
public string DownloadUrl => PackageSources.FirstOrDefault()?.Url ?? string.Empty;
|
||||||
|
|
||||||
|
public string Sha256 => Publication.Sha256;
|
||||||
|
|
||||||
|
public long PackageSizeBytes => Publication.PackageSizeBytes;
|
||||||
|
|
||||||
|
public string IconUrl => Repository.IconUrl;
|
||||||
|
|
||||||
|
public string ProjectUrl => Repository.ProjectUrl;
|
||||||
|
|
||||||
|
public string ReadmeUrl => Repository.ReadmeUrl;
|
||||||
|
|
||||||
|
public string HomepageUrl => Repository.HomepageUrl;
|
||||||
|
|
||||||
|
public string RepositoryUrl => Repository.RepositoryUrl;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Tags => Repository.Tags;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCatalogDependencyInfo> Dependencies =>
|
||||||
|
Manifest.SharedContracts
|
||||||
|
.Select(contract => new PluginCatalogDependencyInfo(
|
||||||
|
contract.Id,
|
||||||
|
contract.Version,
|
||||||
|
contract.AssemblyName))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public DateTimeOffset PublishedAt => Publication.PublishedAt;
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
|
||||||
|
|
||||||
|
public string ReleaseTag => Publication.ReleaseTag;
|
||||||
|
|
||||||
|
public string ReleaseAssetName => Publication.ReleaseAssetName;
|
||||||
|
|
||||||
|
public string ReleaseNotes => Repository.ReleaseNotes;
|
||||||
|
|
||||||
|
public static implicit operator PluginMarketPluginInfo(PluginCatalogItemInfo item)
|
||||||
|
{
|
||||||
|
return new PluginMarketPluginInfo(
|
||||||
|
item.Id,
|
||||||
|
item.Name,
|
||||||
|
item.Description,
|
||||||
|
item.Author,
|
||||||
|
item.Version,
|
||||||
|
item.ApiVersion,
|
||||||
|
item.MinHostVersion,
|
||||||
|
item.DownloadUrl,
|
||||||
|
item.ReleaseTag,
|
||||||
|
item.ReleaseAssetName,
|
||||||
|
item.IconUrl,
|
||||||
|
item.ReadmeUrl,
|
||||||
|
item.HomepageUrl,
|
||||||
|
item.RepositoryUrl,
|
||||||
|
item.Tags.ToArray(),
|
||||||
|
item.Dependencies.Select(dependency => new PluginMarketDependencyInfo(
|
||||||
|
dependency.Id,
|
||||||
|
dependency.Version,
|
||||||
|
dependency.AssemblyName)).ToArray(),
|
||||||
|
item.PublishedAt,
|
||||||
|
item.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator PluginCatalogItemInfo(PluginMarketPluginInfo plugin)
|
||||||
|
{
|
||||||
|
return new PluginCatalogItemInfo(
|
||||||
|
new PluginCatalogManifestInfo(
|
||||||
|
plugin.Id,
|
||||||
|
plugin.Name,
|
||||||
|
plugin.Description,
|
||||||
|
plugin.Author,
|
||||||
|
plugin.Version,
|
||||||
|
plugin.ApiVersion,
|
||||||
|
string.Empty,
|
||||||
|
plugin.Dependencies
|
||||||
|
.Select(dependency => new PluginCatalogSharedContractInfo(
|
||||||
|
dependency.Id,
|
||||||
|
dependency.Version,
|
||||||
|
dependency.AssemblyName))
|
||||||
|
.ToArray()),
|
||||||
|
new PluginCatalogCompatibilityInfo(
|
||||||
|
plugin.MinHostVersion,
|
||||||
|
plugin.ApiVersion),
|
||||||
|
new PluginCatalogRepositoryInfo(
|
||||||
|
plugin.IconUrl,
|
||||||
|
plugin.RepositoryUrl,
|
||||||
|
plugin.ReadmeUrl,
|
||||||
|
plugin.HomepageUrl,
|
||||||
|
plugin.RepositoryUrl,
|
||||||
|
plugin.Tags,
|
||||||
|
string.Empty),
|
||||||
|
new PluginCatalogPublicationInfo(
|
||||||
|
plugin.ReleaseTag,
|
||||||
|
plugin.ReleaseAssetName,
|
||||||
|
plugin.PublishedAt,
|
||||||
|
plugin.UpdatedAt,
|
||||||
|
0,
|
||||||
|
string.Empty,
|
||||||
|
null),
|
||||||
|
string.IsNullOrWhiteSpace(plugin.DownloadUrl)
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
new PluginPackageSourceInfo(
|
||||||
|
string.IsNullOrWhiteSpace(plugin.ReleaseTag)
|
||||||
|
? PluginPackageSourceKind.RawFallback
|
||||||
|
: PluginPackageSourceKind.ReleaseAsset,
|
||||||
|
plugin.DownloadUrl,
|
||||||
|
string.Empty,
|
||||||
|
0)
|
||||||
|
],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogIndexResult(
|
||||||
|
bool Success,
|
||||||
|
IReadOnlyList<PluginCatalogItemInfo> Plugins,
|
||||||
|
IReadOnlyList<PluginCatalogSourceInfo> Sources,
|
||||||
|
string? Source,
|
||||||
|
string? SourceLocation,
|
||||||
|
string? WarningMessage,
|
||||||
|
string? ErrorMessage)
|
||||||
|
{
|
||||||
|
public static implicit operator PluginMarketIndexResult(PluginCatalogIndexResult result)
|
||||||
|
{
|
||||||
|
return new PluginMarketIndexResult(
|
||||||
|
result.Success,
|
||||||
|
result.Plugins.Select(plugin => (PluginMarketPluginInfo)plugin).ToArray(),
|
||||||
|
result.Source,
|
||||||
|
result.SourceLocation,
|
||||||
|
result.WarningMessage,
|
||||||
|
result.ErrorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginInstallDiagnostic(
|
||||||
|
string Code,
|
||||||
|
string Message,
|
||||||
|
string? Details = null);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogInstallResult(
|
||||||
|
bool Success,
|
||||||
|
string? PluginId,
|
||||||
|
string? PluginName,
|
||||||
|
PluginManifest? InstalledManifest,
|
||||||
|
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
|
||||||
|
string? ErrorMessage)
|
||||||
|
{
|
||||||
|
public static implicit operator PluginMarketInstallResult(PluginCatalogInstallResult result)
|
||||||
|
{
|
||||||
|
return new PluginMarketInstallResult(
|
||||||
|
result.Success,
|
||||||
|
result.PluginId,
|
||||||
|
result.PluginName,
|
||||||
|
result.ErrorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogDependencyInfo(
|
||||||
|
string Id,
|
||||||
|
string Version,
|
||||||
|
string AssemblyName)
|
||||||
|
{
|
||||||
|
public static implicit operator PluginMarketDependencyInfo(PluginCatalogDependencyInfo dependency)
|
||||||
|
{
|
||||||
|
return new PluginMarketDependencyInfo(
|
||||||
|
dependency.Id,
|
||||||
|
dependency.Version,
|
||||||
|
dependency.AssemblyName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Obsolete("Use PluginCatalogSharedContractInfo and PluginCatalogItemInfo instead.")]
|
||||||
public sealed record PluginMarketDependencyInfo(
|
public sealed record PluginMarketDependencyInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Version,
|
string Version,
|
||||||
string AssemblyName);
|
string AssemblyName);
|
||||||
|
|
||||||
|
[Obsolete("Use PluginCatalogItemInfo instead.")]
|
||||||
public sealed record PluginMarketPluginInfo(
|
public sealed record PluginMarketPluginInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -89,6 +354,8 @@ public sealed record PluginMarketPluginInfo(
|
|||||||
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
|
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
|
||||||
DateTimeOffset PublishedAt,
|
DateTimeOffset PublishedAt,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
[Obsolete("Use PluginCatalogIndexResult instead.")]
|
||||||
public sealed record PluginMarketIndexResult(
|
public sealed record PluginMarketIndexResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
IReadOnlyList<PluginMarketPluginInfo> Plugins,
|
IReadOnlyList<PluginMarketPluginInfo> Plugins,
|
||||||
@@ -96,12 +363,39 @@ public sealed record PluginMarketIndexResult(
|
|||||||
string? SourceLocation,
|
string? SourceLocation,
|
||||||
string? WarningMessage,
|
string? WarningMessage,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
[Obsolete("Use PluginCatalogInstallResult instead.")]
|
||||||
public sealed record PluginMarketInstallResult(
|
public sealed record PluginMarketInstallResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
string? PluginId,
|
string? PluginId,
|
||||||
string? PluginName,
|
string? PluginName,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
public interface IPluginCatalogSourceProvider
|
||||||
|
{
|
||||||
|
Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginCatalogService : IPluginCatalogSourceProvider
|
||||||
|
{
|
||||||
|
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPackageSourceResolver
|
||||||
|
{
|
||||||
|
IReadOnlyList<PluginPackageSourceInfo> ResolveSources(PluginCatalogItemInfo item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginCompatibilityEvaluator
|
||||||
|
{
|
||||||
|
PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginInstallOrchestrator
|
||||||
|
{
|
||||||
|
Task<PluginCatalogInstallResult> InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
public interface IGridSettingsService
|
public interface IGridSettingsService
|
||||||
{
|
{
|
||||||
GridSettingsState Get();
|
GridSettingsState Get();
|
||||||
@@ -223,10 +517,17 @@ public interface IPluginManagementSettingsService
|
|||||||
bool DeleteInstalledPlugin(string pluginId);
|
bool DeleteInstalledPlugin(string pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IPluginMarketSettingsService
|
public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
|
||||||
|
{
|
||||||
|
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Obsolete("Use IPluginCatalogSettingsService instead.")]
|
||||||
|
public interface IPluginMarketSettingsService : IPluginCatalogSettingsService
|
||||||
{
|
{
|
||||||
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
|
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
|
||||||
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
new Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IApplicationInfoService
|
public interface IApplicationInfoService
|
||||||
@@ -252,6 +553,20 @@ public interface ISettingsFacadeService
|
|||||||
ILauncherCatalogService LauncherCatalog { get; }
|
ILauncherCatalogService LauncherCatalog { get; }
|
||||||
ILauncherPolicyService LauncherPolicy { get; }
|
ILauncherPolicyService LauncherPolicy { get; }
|
||||||
IPluginManagementSettingsService PluginManagement { get; }
|
IPluginManagementSettingsService PluginManagement { get; }
|
||||||
|
IPluginCatalogSettingsService PluginCatalog { get; }
|
||||||
|
[Obsolete("Use PluginCatalog instead.")]
|
||||||
IPluginMarketSettingsService PluginMarket { get; }
|
IPluginMarketSettingsService PluginMarket { get; }
|
||||||
IApplicationInfoService ApplicationInfo { get; }
|
IApplicationInfoService ApplicationInfo { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.PluginMarket
|
||||||
|
{
|
||||||
|
internal enum PluginPackageSourceKind
|
||||||
|
{
|
||||||
|
ReleaseAsset = 0,
|
||||||
|
RawFallback = 1,
|
||||||
|
WorkspaceLocal = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -870,14 +870,41 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
|||||||
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default)
|
public Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var result = await _indexService.LoadAsync(cancellationToken);
|
return LoadCatalogCoreAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<PluginMarketIndexResult> IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PluginCatalogInstallResult> InstallAsync(
|
||||||
|
string pluginId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return InstallCatalogCoreAsync(pluginId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<PluginMarketInstallResult> IPluginMarketSettingsService.InstallAsync(
|
||||||
|
string pluginId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sources = BuildCatalogSources(result.Source?.ToString(), result.SourceLocation, result.WarningMessage);
|
||||||
if (!result.Success || result.Document is null)
|
if (!result.Success || result.Document is null)
|
||||||
{
|
{
|
||||||
return new PluginMarketIndexResult(
|
_cachedPlugins.Clear();
|
||||||
|
return new PluginCatalogIndexResult(
|
||||||
false,
|
false,
|
||||||
[],
|
[],
|
||||||
|
sources,
|
||||||
result.Source?.ToString(),
|
result.Source?.ToString(),
|
||||||
result.SourceLocation,
|
result.SourceLocation,
|
||||||
result.WarningMessage,
|
result.WarningMessage,
|
||||||
@@ -889,81 +916,189 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
|||||||
.Select(entry =>
|
.Select(entry =>
|
||||||
{
|
{
|
||||||
_cachedPlugins[entry.Id] = entry;
|
_cachedPlugins[entry.Id] = entry;
|
||||||
return new PluginMarketPluginInfo(
|
return MapCatalogItem(entry);
|
||||||
entry.Id,
|
|
||||||
entry.Name,
|
|
||||||
entry.Description,
|
|
||||||
entry.Author,
|
|
||||||
entry.Version,
|
|
||||||
entry.ApiVersion,
|
|
||||||
entry.MinHostVersion,
|
|
||||||
entry.DownloadUrl,
|
|
||||||
entry.ReleaseTag,
|
|
||||||
entry.ReleaseAssetName,
|
|
||||||
entry.IconUrl,
|
|
||||||
entry.ReadmeUrl,
|
|
||||||
entry.HomepageUrl,
|
|
||||||
entry.RepositoryUrl,
|
|
||||||
entry.Tags,
|
|
||||||
entry.SharedContracts
|
|
||||||
.Select(contract => new PluginMarketDependencyInfo(
|
|
||||||
contract.Id,
|
|
||||||
contract.Version,
|
|
||||||
contract.AssemblyName))
|
|
||||||
.ToArray(),
|
|
||||||
entry.PublishedAt,
|
|
||||||
entry.UpdatedAt);
|
|
||||||
})
|
})
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return new PluginMarketIndexResult(
|
return new PluginCatalogIndexResult(
|
||||||
true,
|
true,
|
||||||
plugins,
|
plugins,
|
||||||
|
sources,
|
||||||
result.Source?.ToString(),
|
result.Source?.ToString(),
|
||||||
result.SourceLocation,
|
result.SourceLocation,
|
||||||
result.WarningMessage,
|
result.WarningMessage,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PluginMarketInstallResult> InstallAsync(
|
private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
|
||||||
string pluginId,
|
string pluginId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(pluginId))
|
if (string.IsNullOrWhiteSpace(pluginId))
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, null, null, "Plugin id is required.");
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("invalid_request", "Plugin id is required.")],
|
||||||
|
"Plugin id is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_installService is null || _pluginRuntimeService is null)
|
if (_installService is null || _pluginRuntimeService is null)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(
|
return new PluginCatalogInstallResult(
|
||||||
false,
|
false,
|
||||||
pluginId,
|
pluginId,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
|
||||||
"Plugin runtime is unavailable.");
|
"Plugin runtime is unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
|
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
|
||||||
{
|
{
|
||||||
var load = await LoadIndexAsync(cancellationToken);
|
var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!load.Success)
|
if (!load.Success)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage);
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
pluginId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("catalog_load_failed", load.ErrorMessage ?? "Failed to load the plugin catalog.")],
|
||||||
|
load.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
|
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index.");
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
pluginId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("not_found", "Plugin was not found in the official catalog.")],
|
||||||
|
"Plugin was not found in the official catalog.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _installService.InstallAsync(entry, cancellationToken);
|
var result = await _installService.InstallAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage);
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
entry.Id,
|
||||||
|
entry.Name,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("install_failed", result.ErrorMessage ?? "Plugin install failed.")],
|
||||||
|
result.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null);
|
return new PluginCatalogInstallResult(
|
||||||
|
true,
|
||||||
|
result.Manifest?.Id ?? entry.Id,
|
||||||
|
result.Manifest?.Name ?? entry.Name,
|
||||||
|
result.Manifest,
|
||||||
|
[],
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginCatalogItemInfo MapCatalogItem(AirAppMarketPluginEntry entry)
|
||||||
|
{
|
||||||
|
var manifest = new PluginCatalogManifestInfo(
|
||||||
|
entry.Id,
|
||||||
|
entry.Name,
|
||||||
|
entry.Description,
|
||||||
|
entry.Author,
|
||||||
|
entry.Version,
|
||||||
|
entry.ApiVersion,
|
||||||
|
string.Empty,
|
||||||
|
entry.SharedContracts
|
||||||
|
.Select(contract => new PluginCatalogSharedContractInfo(
|
||||||
|
contract.Id,
|
||||||
|
contract.Version,
|
||||||
|
contract.AssemblyName))
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
var compatibility = new PluginCatalogCompatibilityInfo(
|
||||||
|
entry.MinHostVersion,
|
||||||
|
entry.ApiVersion);
|
||||||
|
|
||||||
|
var repository = new PluginCatalogRepositoryInfo(
|
||||||
|
entry.IconUrl,
|
||||||
|
entry.ProjectUrl,
|
||||||
|
entry.ReadmeUrl,
|
||||||
|
entry.HomepageUrl,
|
||||||
|
entry.RepositoryUrl,
|
||||||
|
entry.Tags.ToArray(),
|
||||||
|
entry.ReleaseNotes);
|
||||||
|
|
||||||
|
var publication = new PluginCatalogPublicationInfo(
|
||||||
|
entry.ReleaseTag,
|
||||||
|
entry.ReleaseAssetName,
|
||||||
|
entry.PublishedAt,
|
||||||
|
entry.UpdatedAt,
|
||||||
|
entry.PackageSizeBytes,
|
||||||
|
entry.Sha256,
|
||||||
|
null);
|
||||||
|
|
||||||
|
var sources = BuildPackageSources(entry);
|
||||||
|
|
||||||
|
return new PluginCatalogItemInfo(
|
||||||
|
manifest,
|
||||||
|
compatibility,
|
||||||
|
repository,
|
||||||
|
publication,
|
||||||
|
sources,
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry.DownloadUrl))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceKind = entry.HasReleaseDownloadMetadata
|
||||||
|
? PluginPackageSourceKind.ReleaseAsset
|
||||||
|
: PluginPackageSourceKind.RawFallback;
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginPackageSourceInfo(
|
||||||
|
sourceKind,
|
||||||
|
entry.DownloadUrl,
|
||||||
|
entry.Sha256,
|
||||||
|
entry.PackageSizeBytes)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
|
||||||
|
string? sourceId,
|
||||||
|
string? sourceLocation,
|
||||||
|
string? warningMessage)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceId) && string.IsNullOrWhiteSpace(sourceLocation))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSourceId = string.IsNullOrWhiteSpace(sourceId)
|
||||||
|
? "plugin-catalog"
|
||||||
|
: sourceId.Trim();
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginCatalogSourceInfo(
|
||||||
|
normalizedSourceId,
|
||||||
|
normalizedSourceId,
|
||||||
|
string.IsNullOrWhiteSpace(warningMessage) ? null : warningMessage.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(sourceLocation) ? null : sourceLocation.Trim(),
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
0)
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -1054,6 +1189,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
|||||||
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
|
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
|
||||||
PluginManagement = _pluginManagementSettingsService;
|
PluginManagement = _pluginManagementSettingsService;
|
||||||
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
|
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
|
||||||
|
PluginCatalog = _pluginMarketSettingsService;
|
||||||
PluginMarket = _pluginMarketSettingsService;
|
PluginMarket = _pluginMarketSettingsService;
|
||||||
ApplicationInfo = new ApplicationInfoService();
|
ApplicationInfo = new ApplicationInfoService();
|
||||||
}
|
}
|
||||||
@@ -1086,6 +1222,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
|||||||
|
|
||||||
public IPluginManagementSettingsService PluginManagement { get; }
|
public IPluginManagementSettingsService PluginManagement { get; }
|
||||||
|
|
||||||
|
public IPluginCatalogSettingsService PluginCatalog { get; }
|
||||||
|
|
||||||
public IPluginMarketSettingsService PluginMarket { get; }
|
public IPluginMarketSettingsService PluginMarket { get; }
|
||||||
|
|
||||||
public IApplicationInfoService ApplicationInfo { get; }
|
public IApplicationInfoService ApplicationInfo { get; }
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<Styles.Resources>
|
<Styles.Resources>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.16</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.20</x:TimeSpan>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.20</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.28</x:TimeSpan>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.32</x:TimeSpan>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.40</x:TimeSpan>
|
||||||
|
|
||||||
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
|
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
|
||||||
</Styles.Resources>
|
</Styles.Resources>
|
||||||
|
|||||||
151
LanMountainDesktop/Styles/NavigationStyles.axaml
Normal file
151
LanMountainDesktop/Styles/NavigationStyles.axaml
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
|
||||||
|
|
||||||
|
<Styles.Resources>
|
||||||
|
<x:Double x:Key="PaneToggleButtonWidth">40</x:Double>
|
||||||
|
<x:Double x:Key="PaneToggleButtonHeight">40</x:Double>
|
||||||
|
<x:Double x:Key="NavigationViewItemIconBoxHeight">20</x:Double>
|
||||||
|
<GridLength x:Key="PaneToggleButtonHeightGridLength">40</GridLength>
|
||||||
|
</Styles.Resources>
|
||||||
|
|
||||||
|
<Style Selector="Button.pane-toggle-button">
|
||||||
|
<Setter Property="Width" Value="{DynamicResource PaneToggleButtonWidth}" />
|
||||||
|
<Setter Property="Height" Value="{DynamicResource PaneToggleButtonHeight}" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<Border x:Name="LayoutRoot"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="{TemplateBinding CornerRadius}">
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
<Grid x:Name="ContentRoot"
|
||||||
|
ColumnDefinitions="Auto,*">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="{DynamicResource PaneToggleButtonHeightGridLength}" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Border Width="{TemplateBinding Width}">
|
||||||
|
<ContentPresenter x:Name="IconPresenter"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Content="{TemplateBinding Content}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ContentPresenter x:Name="ContentPresenter"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
Content="{TemplateBinding Tag}"
|
||||||
|
FontSize="{TemplateBinding FontSize}"
|
||||||
|
Padding="4,0,0,0"
|
||||||
|
Grid.Column="1" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.pane-toggle-button:pointerover /template/ Border#LayoutRoot">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.pane-toggle-button:pressed /template/ Border#LayoutRoot">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.nav-back">
|
||||||
|
<Setter Property="Width" Value="{DynamicResource PaneToggleButtonWidth}" />
|
||||||
|
<Setter Property="Height" Value="{DynamicResource PaneToggleButtonHeight}" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<Border x:Name="LayoutRoot"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="{TemplateBinding CornerRadius}">
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
<Grid x:Name="ContentRoot"
|
||||||
|
ColumnDefinitions="Auto,*">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="{DynamicResource PaneToggleButtonHeightGridLength}" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Border Width="{TemplateBinding Width}">
|
||||||
|
<fi:FluentIcon Icon="ChevronLeft"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="16"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ContentPresenter x:Name="ContentPresenter"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
FontSize="{TemplateBinding FontSize}"
|
||||||
|
Padding="4,0,0,0"
|
||||||
|
Grid.Column="1" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.nav-back:pointerover /template/ Border#LayoutRoot">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.nav-back:pressed /template/ Border#LayoutRoot">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationView.settings-navigation-view">
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="Opacity" Duration="0:0:0.2" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationView.settings-navigation-view /template/ Border#NavigationViewBorder">
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.167" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationViewItem.settings-nav-item">
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationViewItem.settings-nav-item:pointerover">
|
||||||
|
<Setter Property="RenderTransform" Value="scale(1.01)" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationViewItem.settings-nav-item:pressed">
|
||||||
|
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</Styles>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
||||||
|
|
||||||
@@ -16,17 +16,17 @@
|
|||||||
<Setter Property="Opacity" Value="0" />
|
<Setter Property="Opacity" Value="0" />
|
||||||
<Setter Property="RenderTransform">
|
<Setter Property="RenderTransform">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<TranslateTransform Y="14" />
|
<TranslateTransform Y="24" />
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
||||||
<Style.Animations>
|
<Style.Animations>
|
||||||
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
|
<Animation Duration="0:0:0.65"
|
||||||
FillMode="Both"
|
FillMode="Both"
|
||||||
Easing="0.22,1,0.36,1">
|
Easing="0.05, 0.75, 0.10, 1.00">
|
||||||
<KeyFrame Cue="0%">
|
<KeyFrame Cue="0%">
|
||||||
<Setter Property="Opacity" Value="0" />
|
<Setter Property="Opacity" Value="0" />
|
||||||
<Setter Property="TranslateTransform.Y" Value="14" />
|
<Setter Property="TranslateTransform.Y" Value="24" />
|
||||||
</KeyFrame>
|
</KeyFrame>
|
||||||
<KeyFrame Cue="100%">
|
<KeyFrame Cue="100%">
|
||||||
<Setter Property="Opacity" Value="1" />
|
<Setter Property="Opacity" Value="1" />
|
||||||
@@ -53,9 +53,9 @@
|
|||||||
<Setter Property="MinHeight" Value="34" />
|
<Setter Property="MinHeight" Value="34" />
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
<Style Selector=".settings-scope ComboBox">
|
<Style Selector=".settings-scope ComboBox">
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -87,8 +87,8 @@
|
|||||||
<Style Selector=".settings-scope ToggleSwitch">
|
<Style Selector=".settings-scope ToggleSwitch">
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
||||||
|
|
||||||
<Style Selector="StackPanel.settings-page-container">
|
<Style Selector="StackPanel.settings-page-container">
|
||||||
<Setter Property="Spacing" Value="0" />
|
<Setter Property="Spacing" Value="0" />
|
||||||
@@ -9,6 +10,34 @@
|
|||||||
<Setter Property="MaxWidth" Value="{DynamicResource SettingsContainerMaxWidth}" />
|
<Setter Property="MaxWidth" Value="{DynamicResource SettingsContainerMaxWidth}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.settings-page-animated">
|
||||||
|
<Setter Property="behaviors:PanelIntroAnimationBehavior.IsEnabled" Value="True" />
|
||||||
|
<Style Selector="^ > :is(Control)[(behaviors|PanelIntroAnimationBehavior.CanPlayAnimation)=True]">
|
||||||
|
<Setter Property="Opacity" Value="0" />
|
||||||
|
<Setter Property="RenderTransform">
|
||||||
|
<Setter.Value>
|
||||||
|
<TranslateTransform Y="20" />
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
||||||
|
<Style.Animations>
|
||||||
|
<Animation Duration="0:0:0.55"
|
||||||
|
FillMode="Both"
|
||||||
|
Easing="0.05, 0.75, 0.10, 1.00">
|
||||||
|
<KeyFrame Cue="0%">
|
||||||
|
<Setter Property="Opacity" Value="0" />
|
||||||
|
<Setter Property="TranslateTransform.Y" Value="20" />
|
||||||
|
</KeyFrame>
|
||||||
|
<KeyFrame Cue="100%">
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
<Setter Property="TranslateTransform.Y" Value="0" />
|
||||||
|
</KeyFrame>
|
||||||
|
</Animation>
|
||||||
|
</Style.Animations>
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.settings-section-title">
|
<Style Selector="TextBlock.settings-section-title">
|
||||||
<Setter Property="FontSize" Value="30" />
|
<Setter Property="FontSize" Value="30" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
@@ -39,10 +68,10 @@
|
|||||||
<Transitions>
|
<Transitions>
|
||||||
<BrushTransition Property="Background"
|
<BrushTransition Property="Background"
|
||||||
Duration="{StaticResource FluttermotionToken.Duration.Standard}"
|
Duration="{StaticResource FluttermotionToken.Duration.Standard}"
|
||||||
Easing="0.22,1,0.36,1" />
|
Easing="0.05,0.75,0.10,1.00" />
|
||||||
<BoxShadowsTransition Property="BoxShadow"
|
<BoxShadowsTransition Property="BoxShadow"
|
||||||
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
|
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
|
||||||
Easing="0.22,1,0.36,1" />
|
Easing="0.05,0.75,0.10,1.00" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ namespace LanMountainDesktop.Theme;
|
|||||||
public static class FluttermotionToken
|
public static class FluttermotionToken
|
||||||
{
|
{
|
||||||
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
|
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
|
||||||
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160);
|
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(200);
|
||||||
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(200);
|
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(280);
|
||||||
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(240);
|
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(320);
|
||||||
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(320);
|
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(400);
|
||||||
|
|
||||||
public static readonly TimeSpan StaggerStepInterval = TimeSpan.FromMilliseconds(24);
|
public static readonly TimeSpan StaggerStepInterval = TimeSpan.FromMilliseconds(32);
|
||||||
public static readonly TimeSpan WeatherAnimationFrameInterval = TimeSpan.FromMilliseconds(64);
|
public static readonly TimeSpan WeatherAnimationFrameInterval = TimeSpan.FromMilliseconds(64);
|
||||||
|
|
||||||
public const string StandardBezier = "0.22, 1, 0.36, 1";
|
public const string StandardBezier = "0.05, 0.75, 0.10, 1.00";
|
||||||
|
public const string DecelerateBezier = "0.05, 0.75, 0.10, 1.00";
|
||||||
|
public const string AccelerateBezier = "0.30, 0.00, 0.60, 0.00";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using Avalonia.Controls;
|
using System.ComponentModel;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
using FluentIcons.Common;
|
using FluentIcons.Common;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace LanMountainDesktop.ViewModels;
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
|
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public string Title { get; set; } = "Widgets";
|
private string _title = "Widgets";
|
||||||
|
|
||||||
|
public string Title
|
||||||
|
{
|
||||||
|
get => _title;
|
||||||
|
set => SetProperty(ref _title, value);
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
|
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
|
||||||
|
|
||||||
@@ -38,20 +46,134 @@ public sealed class ComponentLibraryCategoryViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ComponentLibraryItemViewModel
|
public sealed class ComponentLibraryItemViewModel
|
||||||
|
: ObservableObject
|
||||||
{
|
{
|
||||||
|
private readonly string _loadingPreviewText;
|
||||||
|
private readonly string _previewUnavailableText;
|
||||||
|
private string _displayName;
|
||||||
|
private ComponentPreviewKey _previewKey;
|
||||||
|
private ComponentPreviewImageEntry? _previewImageEntry;
|
||||||
|
private ComponentPreviewImageState _previewState;
|
||||||
|
private string? _previewErrorMessage;
|
||||||
|
private string _previewStatusText;
|
||||||
|
|
||||||
public ComponentLibraryItemViewModel(
|
public ComponentLibraryItemViewModel(
|
||||||
string componentId,
|
string componentId,
|
||||||
string displayName,
|
string displayName,
|
||||||
Control? previewControl)
|
ComponentPreviewKey previewKey,
|
||||||
|
string loadingPreviewText = "Loading preview...",
|
||||||
|
string previewUnavailableText = "Preview unavailable",
|
||||||
|
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||||
{
|
{
|
||||||
ComponentId = componentId;
|
ComponentId = componentId;
|
||||||
DisplayName = displayName;
|
_displayName = displayName;
|
||||||
PreviewControl = previewControl;
|
_previewKey = previewKey;
|
||||||
|
_loadingPreviewText = loadingPreviewText;
|
||||||
|
_previewUnavailableText = previewUnavailableText;
|
||||||
|
_previewStatusText = loadingPreviewText;
|
||||||
|
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ComponentId { get; }
|
public string ComponentId { get; }
|
||||||
|
|
||||||
public string DisplayName { get; }
|
public string DisplayName
|
||||||
|
{
|
||||||
public Control? PreviewControl { get; }
|
get => _displayName;
|
||||||
|
set => SetProperty(ref _displayName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewKey PreviewKey
|
||||||
|
{
|
||||||
|
get => _previewKey;
|
||||||
|
set => SetProperty(ref _previewKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry;
|
||||||
|
|
||||||
|
public object? PreviewBitmap => _previewImageEntry?.Bitmap;
|
||||||
|
|
||||||
|
public ComponentPreviewImageState PreviewState => _previewState;
|
||||||
|
|
||||||
|
public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending;
|
||||||
|
|
||||||
|
public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null;
|
||||||
|
|
||||||
|
public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed;
|
||||||
|
|
||||||
|
public string? PreviewErrorMessage => _previewErrorMessage;
|
||||||
|
|
||||||
|
public string PreviewStatusText => _previewStatusText;
|
||||||
|
|
||||||
|
public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry)
|
||||||
|
{
|
||||||
|
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged)
|
||||||
|
{
|
||||||
|
if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_previewImageEntry is not null)
|
||||||
|
{
|
||||||
|
_previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_previewImageEntry = previewImageEntry;
|
||||||
|
_previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||||
|
_previewErrorMessage = previewImageEntry?.ErrorMessage;
|
||||||
|
|
||||||
|
_previewStatusText = _previewState switch
|
||||||
|
{
|
||||||
|
ComponentPreviewImageState.Ready => string.Empty,
|
||||||
|
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||||
|
? _previewUnavailableText
|
||||||
|
: _previewErrorMessage!,
|
||||||
|
_ => _loadingPreviewText
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_previewImageEntry is not null)
|
||||||
|
{
|
||||||
|
_previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
RaisePreviewDependentProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
if (string.IsNullOrWhiteSpace(e.PropertyName) ||
|
||||||
|
e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or
|
||||||
|
nameof(ComponentPreviewImageEntry.State) or
|
||||||
|
nameof(ComponentPreviewImageEntry.ErrorMessage))
|
||||||
|
{
|
||||||
|
_previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||||
|
_previewErrorMessage = _previewImageEntry?.ErrorMessage;
|
||||||
|
_previewStatusText = _previewState switch
|
||||||
|
{
|
||||||
|
ComponentPreviewImageState.Ready => string.Empty,
|
||||||
|
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||||
|
? _previewUnavailableText
|
||||||
|
: _previewErrorMessage!,
|
||||||
|
_ => _loadingPreviewText
|
||||||
|
};
|
||||||
|
|
||||||
|
RaisePreviewDependentProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaisePreviewDependentProperties()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(PreviewImageEntry));
|
||||||
|
OnPropertyChanged(nameof(PreviewBitmap));
|
||||||
|
OnPropertyChanged(nameof(PreviewState));
|
||||||
|
OnPropertyChanged(nameof(IsPreviewPending));
|
||||||
|
OnPropertyChanged(nameof(IsPreviewReady));
|
||||||
|
OnPropertyChanged(nameof(IsPreviewFailed));
|
||||||
|
OnPropertyChanged(nameof(PreviewErrorMessage));
|
||||||
|
OnPropertyChanged(nameof(PreviewStatusText));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
@@ -31,7 +31,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
private bool _isLoadingIcon;
|
private bool _isLoadingIcon;
|
||||||
|
|
||||||
public PluginMarketItemViewModel(
|
public PluginMarketItemViewModel(
|
||||||
PluginMarketPluginInfo plugin,
|
PluginCatalogItemInfo plugin,
|
||||||
LocalizationService localizationService,
|
LocalizationService localizationService,
|
||||||
string languageCode)
|
string languageCode)
|
||||||
{
|
{
|
||||||
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
ActionTooltip = L("market.button.install", "Install");
|
ActionTooltip = L("market.button.install", "Install");
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginMarketPluginInfo Info { get; }
|
public PluginCatalogItemInfo Info { get; }
|
||||||
|
|
||||||
public string PluginId => Info.Id;
|
public string PluginId => Info.Id;
|
||||||
|
|
||||||
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
|
|
||||||
public string ReadmeUrl => Info.ReadmeUrl;
|
public string ReadmeUrl => Info.ReadmeUrl;
|
||||||
|
|
||||||
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies;
|
public IReadOnlyList<PluginCatalogSharedContractInfo> Dependencies => Info.SharedContracts;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Info.PackageSources;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Info.Capabilities;
|
||||||
|
|
||||||
public string IconFallbackText { get; }
|
public string IconFallbackText { get; }
|
||||||
|
|
||||||
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
_readmeService = readmeService;
|
_readmeService = readmeService;
|
||||||
_primaryActionAsync = primaryActionAsync;
|
_primaryActionAsync = primaryActionAsync;
|
||||||
|
|
||||||
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies);
|
Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
|
||||||
VersionLabel = L("market.detail.version", "Version");
|
VersionLabel = L("market.detail.version", "Version");
|
||||||
PublisherLabel = L("market.detail.author", "Author");
|
PublisherLabel = L("market.detail.author", "Author");
|
||||||
ApiVersionLabel = L("market.detail.api_version", "API Version");
|
ApiVersionLabel = L("market.detail.api_version", "API Version");
|
||||||
@@ -271,7 +275,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public PluginMarketItemViewModel Item { get; }
|
public PluginMarketItemViewModel Item { get; }
|
||||||
|
|
||||||
public ObservableCollection<PluginMarketDependencyInfo> Dependencies { get; }
|
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
|
||||||
|
|
||||||
public string DrawerTitle => Item.Name;
|
public string DrawerTitle => Item.Name;
|
||||||
|
|
||||||
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
|
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
if (_isInitialized)
|
if (_isInitialized)
|
||||||
@@ -370,6 +378,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly IPluginCatalogSettingsService _pluginCatalog;
|
||||||
private readonly LocalizationService _localizationService;
|
private readonly LocalizationService _localizationService;
|
||||||
private readonly AirAppMarketIconService _iconService;
|
private readonly AirAppMarketIconService _iconService;
|
||||||
private readonly AirAppMarketReadmeService _readmeService;
|
private readonly AirAppMarketReadmeService _readmeService;
|
||||||
@@ -386,6 +395,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
AirAppMarketReadmeService readmeService)
|
AirAppMarketReadmeService readmeService)
|
||||||
{
|
{
|
||||||
_settingsFacade = settingsFacade;
|
_settingsFacade = settingsFacade;
|
||||||
|
_pluginCatalog = _settingsFacade.PluginCatalog;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
_iconService = iconService;
|
_iconService = iconService;
|
||||||
_readmeService = readmeService;
|
_readmeService = readmeService;
|
||||||
@@ -468,7 +478,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
||||||
RefreshInstalledSnapshot();
|
RefreshInstalledSnapshot();
|
||||||
|
|
||||||
var result = await _settingsFacade.PluginMarket.LoadIndexAsync();
|
var result = await _pluginCatalog.LoadCatalogAsync();
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
_hasLoadedMarket = false;
|
_hasLoadedMarket = false;
|
||||||
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
|
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
|
||||||
item.Name);
|
item.Name);
|
||||||
|
|
||||||
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
|
var result = await _pluginCatalog.InstallAsync(item.PluginId);
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
RefreshInstalledSnapshot();
|
RefreshInstalledSnapshot();
|
||||||
|
|||||||
@@ -327,7 +327,8 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
|||||||
[
|
[
|
||||||
new SelectionOption("zh-CN", L("settings.region.language_zh", "中文")),
|
new SelectionOption("zh-CN", L("settings.region.language_zh", "中文")),
|
||||||
new SelectionOption("en-US", L("settings.region.language_en", "English")),
|
new SelectionOption("en-US", L("settings.region.language_en", "English")),
|
||||||
new SelectionOption("ja-JP", L("settings.region.language_ja", "日本語"))
|
new SelectionOption("ja-JP", L("settings.region.language_ja", "日本語")),
|
||||||
|
new SelectionOption("ko-KR", L("settings.region.language_ko", "한국어"))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,9 +99,48 @@
|
|||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
Padding="8">
|
Padding="8">
|
||||||
<ContentControl HorizontalAlignment="Center"
|
<Grid>
|
||||||
|
<Image Source="{Binding PreviewBitmap}"
|
||||||
|
Stretch="Uniform"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||||
|
IsVisible="{Binding IsPreviewReady}" />
|
||||||
|
|
||||||
|
<Border IsVisible="{Binding IsPreviewPending}"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Content="{Binding PreviewControl}" />
|
Spacing="8">
|
||||||
|
<ProgressBar Width="96"
|
||||||
|
IsIndeterminate="True" />
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="{Binding PreviewStatusText}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border IsVisible="{Binding IsPreviewFailed}"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8">
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
Text="{Binding PreviewStatusText}" />
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="{Binding PreviewErrorMessage}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
private IComponentLibraryService? _componentLibraryService;
|
private IComponentLibraryService? _componentLibraryService;
|
||||||
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
|
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
|
||||||
private Func<string, string, string>? _localize;
|
private Func<string, string, string>? _localize;
|
||||||
|
private Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? _previewKeyResolver;
|
||||||
|
private Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? _previewEntryResolver;
|
||||||
|
private Action<ComponentPreviewKey>? _warmPreviewRequested;
|
||||||
|
private Action<ComponentPreviewKey>? _renderPreviewRequested;
|
||||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||||
|
|
||||||
public ComponentLibraryWindow()
|
public ComponentLibraryWindow()
|
||||||
@@ -25,12 +29,20 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
public ComponentLibraryWindow(
|
public ComponentLibraryWindow(
|
||||||
IComponentLibraryService componentLibraryService,
|
IComponentLibraryService componentLibraryService,
|
||||||
Func<double, ComponentLibraryCreateContext> createContextFactory,
|
Func<double, ComponentLibraryCreateContext> createContextFactory,
|
||||||
Func<string, string, string> localize)
|
Func<string, string, string> localize,
|
||||||
|
Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? previewKeyResolver = null,
|
||||||
|
Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? previewEntryResolver = null,
|
||||||
|
Action<ComponentPreviewKey>? warmPreviewRequested = null,
|
||||||
|
Action<ComponentPreviewKey>? renderPreviewRequested = null)
|
||||||
: this()
|
: this()
|
||||||
{
|
{
|
||||||
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
||||||
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
||||||
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
||||||
|
_previewKeyResolver = previewKeyResolver;
|
||||||
|
_previewEntryResolver = previewEntryResolver;
|
||||||
|
_warmPreviewRequested = warmPreviewRequested;
|
||||||
|
_renderPreviewRequested = renderPreviewRequested;
|
||||||
Reload();
|
Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +50,7 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
|
|
||||||
public void Reload()
|
public void Reload()
|
||||||
{
|
{
|
||||||
if (_componentLibraryService is null ||
|
if (_componentLibraryService is null || _localize is null)
|
||||||
_createContextFactory is null ||
|
|
||||||
_localize is null)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,32 +85,26 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
|
|
||||||
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
|
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
|
||||||
{
|
{
|
||||||
if (_componentLibraryService is null ||
|
var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
||||||
_createContextFactory is null ||
|
|
||||||
_localize is null)
|
|
||||||
{
|
|
||||||
return new ComponentLibraryItemViewModel(entry.ComponentId, entry.DisplayName, previewControl: null);
|
|
||||||
}
|
|
||||||
|
|
||||||
Control? previewControl = null;
|
|
||||||
_componentLibraryService.TryCreateControl(
|
|
||||||
entry.ComponentId,
|
|
||||||
_createContextFactory(42),
|
|
||||||
out previewControl,
|
|
||||||
out _);
|
|
||||||
|
|
||||||
if (previewControl is not null)
|
|
||||||
{
|
|
||||||
previewControl.IsHitTestVisible = false;
|
|
||||||
previewControl.Focusable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ComponentLibraryItemViewModel(
|
|
||||||
entry.ComponentId,
|
|
||||||
string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
|
||||||
? entry.DisplayName
|
? entry.DisplayName
|
||||||
: _localize(entry.DisplayNameLocalizationKey, entry.DisplayName),
|
: _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName;
|
||||||
previewControl);
|
var previewKey = ResolvePreviewKey(entry);
|
||||||
|
var previewEntry = _previewEntryResolver?.Invoke(previewKey);
|
||||||
|
var item = new ComponentLibraryItemViewModel(
|
||||||
|
entry.ComponentId,
|
||||||
|
displayName,
|
||||||
|
previewKey,
|
||||||
|
_localize?.Invoke("component_library.preview.loading", "Loading preview...") ?? "Loading preview...",
|
||||||
|
_localize?.Invoke("component_library.preview.unavailable", "Preview unavailable") ?? "Preview unavailable",
|
||||||
|
previewEntry);
|
||||||
|
|
||||||
|
if (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending)
|
||||||
|
{
|
||||||
|
_warmPreviewRequested?.Invoke(previewKey);
|
||||||
|
_renderPreviewRequested?.Invoke(previewKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
@@ -118,6 +122,8 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
{
|
{
|
||||||
_viewModel.Components.Add(component);
|
_viewModel.Components.Add(component);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RequestPreviewWarmup(selectedCategory.Components);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -140,6 +146,51 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
Hide();
|
Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void UpdatePreviewImage(ComponentPreviewImageEntry previewImageEntry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(previewImageEntry);
|
||||||
|
|
||||||
|
foreach (var category in _viewModel.Categories)
|
||||||
|
{
|
||||||
|
foreach (var component in category.Components)
|
||||||
|
{
|
||||||
|
if (component.PreviewKey.Equals(previewImageEntry.Key))
|
||||||
|
{
|
||||||
|
component.UpdatePreviewImageEntry(previewImageEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewKey ResolvePreviewKey(ComponentLibraryComponentEntry entry)
|
||||||
|
{
|
||||||
|
if (_previewKeyResolver is not null)
|
||||||
|
{
|
||||||
|
return _previewKeyResolver(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ComponentPreviewKey.ForComponentType(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestPreviewWarmup(IEnumerable<ComponentLibraryItemViewModel> components)
|
||||||
|
{
|
||||||
|
if (_warmPreviewRequested is null && _renderPreviewRequested is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var component in components)
|
||||||
|
{
|
||||||
|
if (!component.IsPreviewPending)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_warmPreviewRequested?.Invoke(component.PreviewKey);
|
||||||
|
_renderPreviewRequested?.Invoke(component.PreviewKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Symbol ResolveCategoryIcon(string categoryId)
|
private Symbol ResolveCategoryIcon(string categoryId)
|
||||||
{
|
{
|
||||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
105
LanMountainDesktop/Views/Components/DailyNewsView.axaml
Normal file
105
LanMountainDesktop/Views/Components/DailyNewsView.axaml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<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.Components.DailyNewsView">
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
<Style Selector="Button.link-button">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="4"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
|
<StackPanel x:Name="RootStackPanel" Spacing="16">
|
||||||
|
<Border x:Name="CoverImageBorder"
|
||||||
|
CornerRadius="12"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Background="#f8f5ec"
|
||||||
|
PointerPressed="OnCoverImagePointerPressed"
|
||||||
|
Cursor="Hand">
|
||||||
|
<Image x:Name="CoverImage"
|
||||||
|
Stretch="UniformToFill"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock x:Name="DateTextBlock"
|
||||||
|
Grid.Column="0"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="#bb5649"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Button x:Name="BilibiliButton"
|
||||||
|
Classes="link-button"
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="16"
|
||||||
|
Background="#FB7299"
|
||||||
|
Cursor="Hand"
|
||||||
|
Click="OnBilibiliButtonClick"
|
||||||
|
ToolTip.Tip="观看视频版">
|
||||||
|
<Path Stretch="Uniform"
|
||||||
|
Width="18"
|
||||||
|
Height="18"
|
||||||
|
Fill="White"
|
||||||
|
Data="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button x:Name="WechatButton"
|
||||||
|
Classes="link-button"
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="16"
|
||||||
|
Background="#07C160"
|
||||||
|
Cursor="Hand"
|
||||||
|
Click="OnWechatButtonClick"
|
||||||
|
ToolTip.Tip="阅读原文">
|
||||||
|
<Path Stretch="Uniform"
|
||||||
|
Width="18"
|
||||||
|
Height="18"
|
||||||
|
Fill="White"
|
||||||
|
Data="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border x:Name="OverviewBorder"
|
||||||
|
Background="#f8f5ec"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<StackPanel x:Name="OverviewStackPanel" Spacing="12"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button x:Name="ShowMoreButton"
|
||||||
|
Content="展开更多新闻 ▼"
|
||||||
|
FontSize="14"
|
||||||
|
Padding="16,8"
|
||||||
|
CornerRadius="8"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="#bb5649"
|
||||||
|
BorderThickness="1"
|
||||||
|
Foreground="#bb5649"
|
||||||
|
Cursor="Hand"
|
||||||
|
Click="OnShowMoreButtonClick"/>
|
||||||
|
|
||||||
|
<StackPanel x:Name="DetailedNewsStackPanel"
|
||||||
|
Spacing="16"
|
||||||
|
IsVisible="False"/>
|
||||||
|
|
||||||
|
<Border x:Name="DateSeparatorBorder"
|
||||||
|
Height="1"
|
||||||
|
Background="#e6e6e6"
|
||||||
|
Margin="0,8,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
526
LanMountainDesktop/Views/Components/DailyNewsView.axaml.cs
Normal file
526
LanMountainDesktop/Views/Components/DailyNewsView.axaml.cs
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class DailyNewsView : UserControl
|
||||||
|
{
|
||||||
|
private static readonly HttpClient HttpClient = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(10)
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly JuyaDailyNews _news;
|
||||||
|
private Bitmap? _coverBitmap;
|
||||||
|
private bool _isNightMode;
|
||||||
|
private bool _isExpanded;
|
||||||
|
|
||||||
|
public event EventHandler? CoverImageClicked;
|
||||||
|
public event EventHandler<string>? NewsItemClicked;
|
||||||
|
|
||||||
|
public DailyNewsView(JuyaDailyNews news, bool isNightMode)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_news = news;
|
||||||
|
_isNightMode = isNightMode;
|
||||||
|
|
||||||
|
var dateStr = news.Date.ToString("yyyy年M月d日");
|
||||||
|
var dayOfWeek = news.Date.ToString("dddd");
|
||||||
|
DateTextBlock.Text = $"{dateStr} {dayOfWeek}";
|
||||||
|
|
||||||
|
_ = LoadCoverImageAsync(news.CoverImageUrl);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(news.BilibiliUrl))
|
||||||
|
{
|
||||||
|
BilibiliButton.IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(news.IssueUrl))
|
||||||
|
{
|
||||||
|
WechatButton.IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (news.OverviewCategories.Any())
|
||||||
|
{
|
||||||
|
foreach (var category in news.OverviewCategories)
|
||||||
|
{
|
||||||
|
var categoryPanel = new StackPanel { Spacing = 6 };
|
||||||
|
|
||||||
|
var categoryHeader = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{category.Icon} {category.Name}",
|
||||||
|
FontSize = 15,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#d4736a") : Color.Parse("#bb5649"))
|
||||||
|
};
|
||||||
|
categoryPanel.Children.Add(categoryHeader);
|
||||||
|
|
||||||
|
foreach (var item in category.Items)
|
||||||
|
{
|
||||||
|
var itemPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
||||||
|
Spacing = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
var bulletText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "•",
|
||||||
|
FontSize = 13,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575"))
|
||||||
|
};
|
||||||
|
itemPanel.Children.Add(bulletText);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.Url))
|
||||||
|
{
|
||||||
|
var linkButton = new HyperlinkButton
|
||||||
|
{
|
||||||
|
Content = item.Title,
|
||||||
|
NavigateUri = new Uri(item.Url),
|
||||||
|
FontSize = 13,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575")),
|
||||||
|
Padding = new Thickness(0)
|
||||||
|
};
|
||||||
|
itemPanel.Children.Add(linkButton);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var titleText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Title,
|
||||||
|
FontSize = 13,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575"))
|
||||||
|
};
|
||||||
|
itemPanel.Children.Add(titleText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Number.HasValue)
|
||||||
|
{
|
||||||
|
var numberText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"#{item.Number}",
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#d4736a") : Color.Parse("#bb5649")),
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
itemPanel.Children.Add(numberText);
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryPanel.Children.Add(itemPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
OverviewStackPanel.Children.Add(categoryPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OverviewBorder.IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!news.DetailedNews.Any())
|
||||||
|
{
|
||||||
|
ShowMoreButton.IsVisible = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var detailedItem in news.DetailedNews)
|
||||||
|
{
|
||||||
|
var newsPanel = CreateDetailedNewsPanel(detailedItem, isNightMode);
|
||||||
|
DetailedNewsStackPanel.Children.Add(newsPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyNightMode(isNightMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateDetailedNewsPanel(JuyaDetailedNewsItem detailedItem, bool isNightMode)
|
||||||
|
{
|
||||||
|
var primaryColor = isNightMode ? "#d4736a" : "#bb5649";
|
||||||
|
var textColor = isNightMode ? "#e8e4e0" : "#34495e";
|
||||||
|
var secondaryTextColor = isNightMode ? "#9a9590" : "#757575";
|
||||||
|
|
||||||
|
var mainBorder = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#e6e6e6")),
|
||||||
|
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||||
|
Padding = new Thickness(0, 0, 0, 16)
|
||||||
|
};
|
||||||
|
|
||||||
|
var mainStack = new StackPanel { Spacing = 12 };
|
||||||
|
mainBorder.Child = mainStack;
|
||||||
|
|
||||||
|
var headerPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
||||||
|
Spacing = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
if (detailedItem.Number > 0)
|
||||||
|
{
|
||||||
|
var numberBadge = new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse(primaryColor)),
|
||||||
|
CornerRadius = new CornerRadius(4),
|
||||||
|
Padding = new Thickness(6, 2),
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
var numberText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"#{detailedItem.Number}",
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeight.Bold,
|
||||||
|
Foreground = Brushes.White
|
||||||
|
};
|
||||||
|
numberBadge.Child = numberText;
|
||||||
|
headerPanel.Children.Add(numberBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = detailedItem.Title,
|
||||||
|
FontSize = 16,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse(textColor)),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
headerPanel.Children.Add(titleText);
|
||||||
|
mainStack.Children.Add(headerPanel);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(detailedItem.BodyText))
|
||||||
|
{
|
||||||
|
var bodyText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = detailedItem.BodyText,
|
||||||
|
FontSize = 14,
|
||||||
|
LineHeight = 22,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse(textColor))
|
||||||
|
};
|
||||||
|
mainStack.Children.Add(bodyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailedItem.RelatedLinks.Any())
|
||||||
|
{
|
||||||
|
var linksPanel = new StackPanel { Spacing = 4 };
|
||||||
|
|
||||||
|
var linksHeader = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "相关链接:",
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor))
|
||||||
|
};
|
||||||
|
linksPanel.Children.Add(linksHeader);
|
||||||
|
|
||||||
|
foreach (var link in detailedItem.RelatedLinks.Take(3))
|
||||||
|
{
|
||||||
|
var linkButton = new HyperlinkButton
|
||||||
|
{
|
||||||
|
Content = link.Length > 50 ? link.Substring(0, 50) + "..." : link,
|
||||||
|
NavigateUri = new Uri(link),
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse(primaryColor))
|
||||||
|
};
|
||||||
|
linksPanel.Children.Add(linkButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainStack.Children.Add(linksPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainBorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnShowMoreButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_isExpanded = !_isExpanded;
|
||||||
|
DetailedNewsStackPanel.IsVisible = _isExpanded;
|
||||||
|
ShowMoreButton.Content = _isExpanded ? "收起新闻 ▲" : "展开更多新闻 ▼";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBilibiliButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_news.BilibiliUrl))
|
||||||
|
{
|
||||||
|
TryOpenUrl(_news.BilibiliUrl);
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWechatButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_news.IssueUrl))
|
||||||
|
{
|
||||||
|
TryOpenUrl(_news.IssueUrl);
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryOpenUrl(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = url,
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCoverImageAsync(string? imageUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await HttpClient.GetAsync(imageUrl);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
var bitmap = new Bitmap(stream);
|
||||||
|
_coverBitmap = bitmap;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
CoverImage.Source = bitmap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCoverImagePointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
{
|
||||||
|
CoverImageClicked?.Invoke(this, EventArgs.Empty);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyNightMode(bool isNightMode)
|
||||||
|
{
|
||||||
|
_isNightMode = isNightMode;
|
||||||
|
var primaryColor = isNightMode ? "#d4736a" : "#bb5649";
|
||||||
|
var textColor = isNightMode ? "#e8e4e0" : "#34495e";
|
||||||
|
var secondaryTextColor = isNightMode ? "#9a9590" : "#757575";
|
||||||
|
var separatorColor = isNightMode ? "#3d3a3a" : "#e6e6e6";
|
||||||
|
var coverBgColor = isNightMode ? "#3d3a3a" : "#f8f5ec";
|
||||||
|
var overviewBgColor = isNightMode ? "#3d3a3a" : "#f8f5ec";
|
||||||
|
|
||||||
|
DateTextBlock.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
DateSeparatorBorder.Background = new SolidColorBrush(Color.Parse(separatorColor));
|
||||||
|
CoverImageBorder.Background = new SolidColorBrush(Color.Parse(coverBgColor));
|
||||||
|
OverviewBorder.Background = new SolidColorBrush(Color.Parse(overviewBgColor));
|
||||||
|
|
||||||
|
ShowMoreButton.BorderBrush = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
ShowMoreButton.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
|
||||||
|
foreach (var child in OverviewStackPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is StackPanel categoryPanel && categoryPanel.Children.Count > 0)
|
||||||
|
{
|
||||||
|
if (categoryPanel.Children[0] is TextBlock categoryHeader)
|
||||||
|
{
|
||||||
|
categoryHeader.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 1; i < categoryPanel.Children.Count; i++)
|
||||||
|
{
|
||||||
|
if (categoryPanel.Children[i] is StackPanel itemPanel)
|
||||||
|
{
|
||||||
|
foreach (var itemChild in itemPanel.Children)
|
||||||
|
{
|
||||||
|
if (itemChild is TextBlock textBlock)
|
||||||
|
{
|
||||||
|
if (textBlock.Text.StartsWith("#"))
|
||||||
|
{
|
||||||
|
textBlock.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
textBlock.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (itemChild is HyperlinkButton linkBtn)
|
||||||
|
{
|
||||||
|
linkBtn.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in DetailedNewsStackPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is Border mainBorder && mainBorder.Child is StackPanel mainStack)
|
||||||
|
{
|
||||||
|
mainBorder.BorderBrush = new SolidColorBrush(Color.Parse(separatorColor));
|
||||||
|
|
||||||
|
foreach (var stackChild in mainStack.Children)
|
||||||
|
{
|
||||||
|
if (stackChild is StackPanel headerPanel)
|
||||||
|
{
|
||||||
|
foreach (var headerChild in headerPanel.Children)
|
||||||
|
{
|
||||||
|
if (headerChild is Border numberBadge && numberBadge.Child is TextBlock numberText)
|
||||||
|
{
|
||||||
|
numberBadge.Background = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
}
|
||||||
|
else if (headerChild is TextBlock titleText)
|
||||||
|
{
|
||||||
|
titleText.Foreground = new SolidColorBrush(Color.Parse(textColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (stackChild is TextBlock bodyText)
|
||||||
|
{
|
||||||
|
bodyText.Foreground = new SolidColorBrush(Color.Parse(textColor));
|
||||||
|
}
|
||||||
|
else if (stackChild is StackPanel linksPanel)
|
||||||
|
{
|
||||||
|
foreach (var linkChild in linksPanel.Children)
|
||||||
|
{
|
||||||
|
if (linkChild is TextBlock linksHeader)
|
||||||
|
{
|
||||||
|
linksHeader.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||||
|
}
|
||||||
|
else if (linkChild is HyperlinkButton linkButton)
|
||||||
|
{
|
||||||
|
linkButton.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateLayout(double scale, double availableWidth)
|
||||||
|
{
|
||||||
|
var coverHeight = availableWidth * 9 / 16;
|
||||||
|
CoverImageBorder.Width = availableWidth;
|
||||||
|
CoverImageBorder.Height = coverHeight;
|
||||||
|
|
||||||
|
DateTextBlock.FontSize = Math.Clamp(20 * scale, 16, 26);
|
||||||
|
|
||||||
|
ShowMoreButton.FontSize = Math.Clamp(14 * scale, 12, 16);
|
||||||
|
|
||||||
|
var buttonSize = Math.Clamp(32 * scale, 24, 40);
|
||||||
|
BilibiliButton.Width = buttonSize;
|
||||||
|
BilibiliButton.Height = buttonSize;
|
||||||
|
BilibiliButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||||
|
|
||||||
|
WechatButton.Width = buttonSize;
|
||||||
|
WechatButton.Height = buttonSize;
|
||||||
|
WechatButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||||
|
|
||||||
|
foreach (var child in OverviewStackPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is StackPanel categoryPanel && categoryPanel.Children.Count > 0)
|
||||||
|
{
|
||||||
|
if (categoryPanel.Children[0] is TextBlock categoryHeader)
|
||||||
|
{
|
||||||
|
categoryHeader.FontSize = Math.Clamp(15 * scale, 13, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 1; i < categoryPanel.Children.Count; i++)
|
||||||
|
{
|
||||||
|
if (categoryPanel.Children[i] is StackPanel itemPanel)
|
||||||
|
{
|
||||||
|
foreach (var itemChild in itemPanel.Children)
|
||||||
|
{
|
||||||
|
if (itemChild is TextBlock textBlock)
|
||||||
|
{
|
||||||
|
textBlock.FontSize = Math.Clamp(13 * scale, 11, 15);
|
||||||
|
}
|
||||||
|
else if (itemChild is HyperlinkButton linkBtn)
|
||||||
|
{
|
||||||
|
linkBtn.FontSize = Math.Clamp(13 * scale, 11, 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in DetailedNewsStackPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is Border mainBorder && mainBorder.Child is StackPanel mainStack)
|
||||||
|
{
|
||||||
|
foreach (var stackChild in mainStack.Children)
|
||||||
|
{
|
||||||
|
if (stackChild is StackPanel headerPanel)
|
||||||
|
{
|
||||||
|
foreach (var headerChild in headerPanel.Children)
|
||||||
|
{
|
||||||
|
if (headerChild is Border numberBadge && numberBadge.Child is TextBlock numberText)
|
||||||
|
{
|
||||||
|
numberText.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||||
|
}
|
||||||
|
else if (headerChild is TextBlock titleText)
|
||||||
|
{
|
||||||
|
titleText.FontSize = Math.Clamp(16 * scale, 14, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (stackChild is TextBlock bodyText)
|
||||||
|
{
|
||||||
|
bodyText.FontSize = Math.Clamp(14 * scale, 12, 16);
|
||||||
|
bodyText.LineHeight = 22 * scale;
|
||||||
|
}
|
||||||
|
else if (stackChild is StackPanel linksPanel)
|
||||||
|
{
|
||||||
|
foreach (var linkChild in linksPanel.Children)
|
||||||
|
{
|
||||||
|
if (linkChild is TextBlock linksHeader)
|
||||||
|
{
|
||||||
|
linksHeader.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||||
|
}
|
||||||
|
else if (linkChild is HyperlinkButton linkButton)
|
||||||
|
{
|
||||||
|
linkButton.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDetachedFromVisualTree(e);
|
||||||
|
_coverBitmap?.Dispose();
|
||||||
|
_coverBitmap = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -428,6 +428,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
BuiltInComponentIds.DesktopIfengNews,
|
BuiltInComponentIds.DesktopIfengNews,
|
||||||
"component.ifeng_news",
|
"component.ifeng_news",
|
||||||
() => new IfengNewsWidget()),
|
() => new IfengNewsWidget()),
|
||||||
|
new DesktopComponentRuntimeRegistration(
|
||||||
|
BuiltInComponentIds.DesktopJuyaNews,
|
||||||
|
"component.juya_news",
|
||||||
|
() => new JuyaNewsWidget()),
|
||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||||
"component.bilibili_hot_search",
|
"component.bilibili_hot_search",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
|
|
||||||
foreach (var visual in _itemVisuals)
|
foreach (var visual in _itemVisuals)
|
||||||
{
|
{
|
||||||
visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA"));
|
visual.Host.Background = Brushes.Transparent;
|
||||||
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
106
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
Normal file
106
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<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"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="640"
|
||||||
|
d:DesignHeight="640"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.JuyaNewsWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
CornerRadius="24"
|
||||||
|
Background="Transparent"
|
||||||
|
ClipToBounds="True"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0">
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="CardBorder"
|
||||||
|
Background="#fefefe"
|
||||||
|
CornerRadius="24"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="16,14,16,14">
|
||||||
|
<Grid x:Name="ContentGrid"
|
||||||
|
RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Grid x:Name="HeaderGrid"
|
||||||
|
Grid.Row="0"
|
||||||
|
ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="10"
|
||||||
|
Margin="0,0,0,12">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Spacing="10">
|
||||||
|
<Border x:Name="AvatarBorder"
|
||||||
|
Width="36"
|
||||||
|
Height="36"
|
||||||
|
CornerRadius="18"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Background="#f8f5ec">
|
||||||
|
<Image x:Name="AvatarImage"
|
||||||
|
Source="avares://LanMountainDesktop/Assets/juya_avatar.jpg"
|
||||||
|
Stretch="UniformToFill"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock x:Name="BrandTextBlock"
|
||||||
|
Text="橘鸦Juya"
|
||||||
|
Foreground="#bb5649"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button x:Name="RefreshButton"
|
||||||
|
Grid.Column="1"
|
||||||
|
Padding="8,4"
|
||||||
|
CornerRadius="8"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="#bb5649"
|
||||||
|
BorderThickness="1"
|
||||||
|
Foreground="#bb5649"
|
||||||
|
Focusable="False"
|
||||||
|
ToolTip.Tip="刷新今日新闻"
|
||||||
|
Click="OnRefreshButtonClick">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<fi:SymbolIcon x:Name="RefreshIcon"
|
||||||
|
Symbol="ArrowSync"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="#bb5649" />
|
||||||
|
<TextBlock x:Name="RefreshButtonText"
|
||||||
|
Text="刷新"
|
||||||
|
FontSize="13"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 滚动内容区 -->
|
||||||
|
<ScrollViewer x:Name="ContentScrollViewer"
|
||||||
|
Grid.Row="1"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
ScrollChanged="OnScrollChanged">
|
||||||
|
<StackPanel x:Name="NewsStackPanel" Spacing="16">
|
||||||
|
|
||||||
|
<!-- 加载提示 -->
|
||||||
|
<TextBlock x:Name="LoadingTextBlock"
|
||||||
|
Text="正在加载..."
|
||||||
|
Foreground="#757575"
|
||||||
|
FontSize="14"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsVisible="False" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="StatusTextBlock"
|
||||||
|
IsVisible="False"
|
||||||
|
Text="Loading"
|
||||||
|
Foreground="#757575"
|
||||||
|
FontSize="16"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
827
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
Normal file
827
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class JuyaNewsWidget : UserControl, IDesktopComponentWidget
|
||||||
|
{
|
||||||
|
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||||
|
private static readonly HttpClient HttpClient = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(15)
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string RssUrl = "https://imjuya.github.io/juya-ai-daily/rss.xml";
|
||||||
|
private const double BaseCellSize = 48d;
|
||||||
|
private const int BaseWidthCells = 4;
|
||||||
|
private const int BaseHeightCells = 4;
|
||||||
|
private const int InitialLoadDays = 3;
|
||||||
|
private const int LoadMoreDays = 3;
|
||||||
|
private const int MaxCachedDays = 30;
|
||||||
|
|
||||||
|
private readonly Dictionary<DateTime, JuyaDailyNews> _cachedNews = new();
|
||||||
|
private readonly List<DateTime> _loadedDates = new();
|
||||||
|
private readonly List<DailyNewsView> _dailyViews = new();
|
||||||
|
|
||||||
|
private double _currentCellSize = BaseCellSize;
|
||||||
|
private bool _isAttached;
|
||||||
|
private bool _isLoading;
|
||||||
|
private bool _isNightVisual;
|
||||||
|
private DateTime _earliestLoadedDate = DateTime.Today;
|
||||||
|
|
||||||
|
public JuyaNewsWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
BrandTextBlock.FontFamily = MiSansFontFamily;
|
||||||
|
LoadingTextBlock.FontFamily = MiSansFontFamily;
|
||||||
|
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||||
|
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||||
|
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
ApplyLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttached = true;
|
||||||
|
_ = LoadInitialNewsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttached = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_isNightVisual = ResolveNightMode();
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ResolveNightMode()
|
||||||
|
{
|
||||||
|
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ActualThemeVariant == ThemeVariant.Light)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||||
|
value is ISolidColorBrush brush)
|
||||||
|
{
|
||||||
|
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateRelativeLuminance(Color color)
|
||||||
|
{
|
||||||
|
static double ToLinear(double channel)
|
||||||
|
{
|
||||||
|
return channel <= 0.03928
|
||||||
|
? channel / 12.92
|
||||||
|
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = ToLinear(color.R / 255d);
|
||||||
|
var g = ToLinear(color.G / 255d);
|
||||||
|
var b = ToLinear(color.B / 255d);
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyNightModeVisual()
|
||||||
|
{
|
||||||
|
// 卡片背景
|
||||||
|
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2d2a2a") : Color.Parse("#fefefe"));
|
||||||
|
|
||||||
|
// 品牌标题
|
||||||
|
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||||
|
|
||||||
|
// 刷新按钮
|
||||||
|
RefreshButton.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||||
|
RefreshButton.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||||
|
|
||||||
|
// 头像背景
|
||||||
|
AvatarBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3d3a3a") : Color.Parse("#f8f5ec"));
|
||||||
|
|
||||||
|
// 状态文字
|
||||||
|
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
|
||||||
|
LoadingTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
|
||||||
|
|
||||||
|
// 更新所有日期视图的样式
|
||||||
|
foreach (var view in _dailyViews)
|
||||||
|
{
|
||||||
|
view.ApplyNightMode(_isNightVisual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadInitialNewsAsync()
|
||||||
|
{
|
||||||
|
if (!_isAttached || _isLoading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
LoadingTextBlock.IsVisible = true;
|
||||||
|
StatusTextBlock.IsVisible = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 解析RSS获取所有新闻
|
||||||
|
var allNews = await FetchJuyaNewsAsync();
|
||||||
|
|
||||||
|
if (!_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存新闻数据
|
||||||
|
foreach (var news in allNews)
|
||||||
|
{
|
||||||
|
_cachedNews[news.Date.Date] = news;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载最近几天的新闻
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var datesToLoad = Enumerable.Range(0, InitialLoadDays)
|
||||||
|
.Select(i => today.AddDays(-i))
|
||||||
|
.Where(d => _cachedNews.ContainsKey(d))
|
||||||
|
.OrderByDescending(d => d)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
|
||||||
|
NewsStackPanel.Children.Clear();
|
||||||
|
_dailyViews.Clear();
|
||||||
|
_loadedDates.Clear();
|
||||||
|
|
||||||
|
foreach (var date in datesToLoad)
|
||||||
|
{
|
||||||
|
AddDailyNewsToView(_cachedNews[date]);
|
||||||
|
_loadedDates.Add(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_loadedDates.Any())
|
||||||
|
{
|
||||||
|
_earliestLoadedDate = _loadedDates.Min();
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingTextBlock.IsVisible = false;
|
||||||
|
StatusTextBlock.IsVisible = false;
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
StatusTextBlock.Text = "加载失败";
|
||||||
|
StatusTextBlock.IsVisible = true;
|
||||||
|
LoadingTextBlock.IsVisible = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<JuyaDailyNews>> FetchJuyaNewsAsync()
|
||||||
|
{
|
||||||
|
var result = new List<JuyaDailyNews>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 使用字节数组获取内容,确保正确解码 UTF-8
|
||||||
|
var response = await HttpClient.GetByteArrayAsync(RssUrl);
|
||||||
|
var rssContent = System.Text.Encoding.UTF8.GetString(response);
|
||||||
|
var doc = XDocument.Parse(rssContent);
|
||||||
|
|
||||||
|
var contentNs = XNamespace.Get("http://purl.org/rss/1.0/modules/content/");
|
||||||
|
|
||||||
|
var items = doc.Descendants("item");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var title = item.Element("title")?.Value ?? "";
|
||||||
|
var link = item.Element("link")?.Value ?? "";
|
||||||
|
var pubDate = item.Element("pubDate")?.Value ?? "";
|
||||||
|
var contentEncoded = item.Element(contentNs + "encoded")?.Value ?? "";
|
||||||
|
|
||||||
|
// 解析日期
|
||||||
|
if (!DateTime.TryParse(pubDate, out var date))
|
||||||
|
{
|
||||||
|
date = DateTime.Today;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取封面图URL
|
||||||
|
var coverImageUrl = ExtractCoverImageUrl(contentEncoded);
|
||||||
|
|
||||||
|
// 提取视频链接
|
||||||
|
var (bilibiliUrl, youtubeUrl) = ExtractVideoUrls(contentEncoded);
|
||||||
|
|
||||||
|
// 解析概览(简短列表)
|
||||||
|
var overviewCategories = ParseOverview(contentEncoded);
|
||||||
|
|
||||||
|
// 解析详细内容
|
||||||
|
var detailedNews = ParseDetailedNews(contentEncoded);
|
||||||
|
|
||||||
|
var news = new JuyaDailyNews(
|
||||||
|
Date: date,
|
||||||
|
Title: title,
|
||||||
|
CoverImageUrl: coverImageUrl,
|
||||||
|
IssueUrl: link,
|
||||||
|
BilibiliUrl: bilibiliUrl,
|
||||||
|
YoutubeUrl: youtubeUrl,
|
||||||
|
OverviewCategories: overviewCategories,
|
||||||
|
DetailedNews: detailedNews,
|
||||||
|
FetchedAt: DateTimeOffset.Now
|
||||||
|
);
|
||||||
|
|
||||||
|
result.Add(news);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 返回空列表
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.OrderByDescending(n => n.Date).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractCoverImageUrl(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = Regex.Match(content, @"<img[^>]+src=[""']([^""']+)[""']", RegexOptions.IgnoreCase);
|
||||||
|
return match.Success ? match.Groups[1].Value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string bilibili, string youtube) ExtractVideoUrls(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return ("", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
string bilibiliUrl = "";
|
||||||
|
string youtubeUrl = "";
|
||||||
|
|
||||||
|
var bilibiliMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?bilibili\.com/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||||
|
if (bilibiliMatch.Success)
|
||||||
|
{
|
||||||
|
bilibiliUrl = bilibiliMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var youtubeMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?(?:youtube\.com|youtu\.be)/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||||
|
if (youtubeMatch.Success)
|
||||||
|
{
|
||||||
|
youtubeUrl = youtubeMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bilibiliUrl, youtubeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<JuyaOverviewCategory> ParseOverview(string content)
|
||||||
|
{
|
||||||
|
var categories = new List<JuyaOverviewCategory>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryIcons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["要闻"] = "📌",
|
||||||
|
["开发生态"] = "💻",
|
||||||
|
["产品应用"] = "📱",
|
||||||
|
["产品发布"] = "🚀",
|
||||||
|
["模型发布"] = "🤖",
|
||||||
|
["行业动态"] = "📈",
|
||||||
|
["技术与洞察"] = "🔍",
|
||||||
|
["学术研究"] = "📚",
|
||||||
|
["研究"] = "🔬",
|
||||||
|
["开源"] = "🔓",
|
||||||
|
["投资"] = "💰",
|
||||||
|
["融资"] = "💵",
|
||||||
|
["商业"] = "💼",
|
||||||
|
["市场"] = "📊",
|
||||||
|
["AI绘画"] = "🎨",
|
||||||
|
["设计"] = "✏️",
|
||||||
|
["创意"] = "💡",
|
||||||
|
["前瞻与传闻"] = "🔮",
|
||||||
|
["趋势"] = "📉",
|
||||||
|
["预测"] = "🔭",
|
||||||
|
["政策"] = "📋",
|
||||||
|
["法规"] = "⚖️",
|
||||||
|
["监管"] = "🛡️",
|
||||||
|
["硬件"] = "🔧",
|
||||||
|
["芯片"] = "🖥️",
|
||||||
|
["基础设施"] = "🏗️",
|
||||||
|
["其他"] = "•",
|
||||||
|
["要点"] = "📋",
|
||||||
|
["摘要"] = "📝"
|
||||||
|
};
|
||||||
|
|
||||||
|
var overviewMatch = Regex.Match(content, @"<h2>\s*概览\s*</h2>(.*?)(?:<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (!overviewMatch.Success)
|
||||||
|
{
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
var overviewContent = overviewMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var h3Matches = Regex.Matches(overviewContent, @"<h3>([^<]+)</h3>\s*<ul>(.*?)</ul>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
foreach (Match match in h3Matches)
|
||||||
|
{
|
||||||
|
var categoryName = match.Groups[1].Value.Trim();
|
||||||
|
var listContent = match.Groups[2].Value;
|
||||||
|
|
||||||
|
var icon = categoryIcons.GetValueOrDefault(categoryName, "•");
|
||||||
|
|
||||||
|
var items = new List<JuyaOverviewItem>();
|
||||||
|
var itemMatches = Regex.Matches(listContent, @"<li>(.*?)</li>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
foreach (Match itemMatch in itemMatches)
|
||||||
|
{
|
||||||
|
var itemText = itemMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
string itemTitle;
|
||||||
|
string itemUrl;
|
||||||
|
int? number = null;
|
||||||
|
|
||||||
|
var linkMatch = Regex.Match(itemText, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (linkMatch.Success)
|
||||||
|
{
|
||||||
|
itemUrl = linkMatch.Groups[1].Value;
|
||||||
|
var linkText = Regex.Replace(linkMatch.Groups[2].Value, @"<[^>]+>", "").Trim();
|
||||||
|
|
||||||
|
var beforeLink = itemText.Substring(0, itemText.IndexOf("<a", StringComparison.OrdinalIgnoreCase));
|
||||||
|
itemTitle = Regex.Replace(beforeLink, @"<[^>]+>", "").Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(itemTitle))
|
||||||
|
{
|
||||||
|
itemTitle = linkText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
itemTitle = Regex.Replace(itemText, @"<[^>]+>", "").Trim();
|
||||||
|
itemUrl = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var numberMatch = Regex.Match(itemText, @"<code>\s*#(\d+)\s*</code>|#(\d+)");
|
||||||
|
if (numberMatch.Success)
|
||||||
|
{
|
||||||
|
number = int.Parse(numberMatch.Groups[1].Success ? numberMatch.Groups[1].Value : numberMatch.Groups[2].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
itemTitle = Regex.Replace(itemTitle, @"^\s*#\d+\s*", "").Trim();
|
||||||
|
itemTitle = Regex.Replace(itemTitle, @"[→↗\s]+$", "").Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(itemTitle) && itemTitle.Length > 1)
|
||||||
|
{
|
||||||
|
items.Add(new JuyaOverviewItem(itemTitle, itemUrl, number));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.Any())
|
||||||
|
{
|
||||||
|
categories.Add(new JuyaOverviewCategory(categoryName, icon, items));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<JuyaDetailedNewsItem> ParseDetailedNews(string content)
|
||||||
|
{
|
||||||
|
var newsItems = new List<JuyaDetailedNewsItem>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return newsItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailedMatch = Regex.Match(content, @"<hr>(.*)$", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
if (!detailedMatch.Success)
|
||||||
|
{
|
||||||
|
return newsItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailedContent = detailedMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var newsMatches = Regex.Matches(detailedContent, @"<h2>(.*?)</h2>(.*?)(?=<h2>|<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
foreach (Match match in newsMatches)
|
||||||
|
{
|
||||||
|
var headerContent = match.Groups[1].Value;
|
||||||
|
var bodyContent = match.Groups[2].Value;
|
||||||
|
|
||||||
|
var numberMatch = Regex.Match(headerContent, @"<code>\s*#(\d+)\s*</code>");
|
||||||
|
if (!numberMatch.Success)
|
||||||
|
{
|
||||||
|
numberMatch = Regex.Match(headerContent, @"#(\d+)");
|
||||||
|
}
|
||||||
|
|
||||||
|
int? number = numberMatch.Success ? int.Parse(numberMatch.Groups[1].Value) : null;
|
||||||
|
|
||||||
|
string title;
|
||||||
|
var linkMatch = Regex.Match(headerContent, @"<a[^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
if (linkMatch.Success)
|
||||||
|
{
|
||||||
|
title = Regex.Replace(linkMatch.Groups[1].Value, @"<[^>]+>", "").Trim();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
title = Regex.Replace(headerContent, @"<code>.*?</code>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
title = Regex.Replace(title, @"<[^>]+>", "").Trim();
|
||||||
|
title = Regex.Replace(title, @"#\d+", "").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyText = ExtractBodyText(bodyContent);
|
||||||
|
|
||||||
|
var relatedLinks = new List<string>();
|
||||||
|
var linkMatches = Regex.Matches(bodyContent, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||||
|
foreach (Match linkMatch2 in linkMatches)
|
||||||
|
{
|
||||||
|
var url = linkMatch2.Groups[1].Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(url) && !relatedLinks.Contains(url))
|
||||||
|
{
|
||||||
|
relatedLinks.Add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(bodyText))
|
||||||
|
{
|
||||||
|
newsItems.Add(new JuyaDetailedNewsItem(title, number ?? 0, bodyText, relatedLinks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newsItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractBodyText(string htmlContent)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(htmlContent))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 blockquote 内容
|
||||||
|
var blockquoteMatch = Regex.Match(htmlContent, @"<blockquote>(.*?)</blockquote>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
if (blockquoteMatch.Success)
|
||||||
|
{
|
||||||
|
var text = blockquoteMatch.Groups[1].Value;
|
||||||
|
// 移除 <p> 标签但保留内容
|
||||||
|
text = Regex.Replace(text, @"<p>(.*?)</p>", "$1\n\n", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
// 移除其他 HTML 标签
|
||||||
|
text = Regex.Replace(text, @"<[^>]+>", "");
|
||||||
|
// 清理多余空白
|
||||||
|
text = Regex.Replace(text, @"\n{3,}", "\n\n");
|
||||||
|
return text.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 blockquote,提取所有 <p> 标签内容
|
||||||
|
var paragraphs = Regex.Matches(htmlContent, @"<p>(.*?)</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
if (paragraphs.Count > 0)
|
||||||
|
{
|
||||||
|
var text = string.Join("\n\n", paragraphs.Cast<Match>().Select(m =>
|
||||||
|
Regex.Replace(m.Groups[1].Value, @"<[^>]+>", "").Trim()));
|
||||||
|
return text.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后尝试直接移除所有 HTML 标签
|
||||||
|
return Regex.Replace(htmlContent, @"<[^>]+>", "").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddDailyNewsToView(JuyaDailyNews news)
|
||||||
|
{
|
||||||
|
var view = new DailyNewsView(news, _isNightVisual);
|
||||||
|
view.CoverImageClicked += (s, e) => TryOpenUrl(news.IssueUrl);
|
||||||
|
view.NewsItemClicked += (s, url) => TryOpenUrl(url);
|
||||||
|
NewsStackPanel.Children.Add(view);
|
||||||
|
_dailyViews.Add(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isLoading || !_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scrollViewer = (ScrollViewer)sender!;
|
||||||
|
|
||||||
|
var offset = scrollViewer.Offset;
|
||||||
|
var extent = scrollViewer.Extent;
|
||||||
|
var viewport = scrollViewer.Viewport;
|
||||||
|
|
||||||
|
if (offset.Y >= extent.Height - viewport.Height - 200)
|
||||||
|
{
|
||||||
|
await LoadMoreNewsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMoreNewsAsync()
|
||||||
|
{
|
||||||
|
if (_isLoading || !_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextDates = Enumerable.Range(1, LoadMoreDays)
|
||||||
|
.Select(i => _earliestLoadedDate.AddDays(-i))
|
||||||
|
.Where(d => _cachedNews.ContainsKey(d) && !_loadedDates.Contains(d))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!nextDates.Any())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
LoadingTextBlock.IsVisible = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
|
||||||
|
foreach (var date in nextDates.OrderByDescending(d => d))
|
||||||
|
{
|
||||||
|
AddDailyNewsToView(_cachedNews[date]);
|
||||||
|
_loadedDates.Add(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
_earliestLoadedDate = _loadedDates.Min();
|
||||||
|
LoadingTextBlock.IsVisible = false;
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
if (_isLoading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
RefreshButtonText.Text = "刷新中...";
|
||||||
|
RefreshIcon.IsEnabled = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var allNews = await FetchJuyaNewsAsync();
|
||||||
|
|
||||||
|
if (!_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var todayNews = allNews.FirstOrDefault(n => n.Date.Date == today);
|
||||||
|
|
||||||
|
if (todayNews != null)
|
||||||
|
{
|
||||||
|
_cachedNews[today] = todayNews;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
|
||||||
|
var existingIndex = _loadedDates.IndexOf(today);
|
||||||
|
if (existingIndex >= 0 && _dailyViews.Count > existingIndex)
|
||||||
|
{
|
||||||
|
var oldView = _dailyViews[existingIndex];
|
||||||
|
var insertIndex = NewsStackPanel.Children.IndexOf(oldView);
|
||||||
|
|
||||||
|
if (insertIndex >= 0)
|
||||||
|
{
|
||||||
|
NewsStackPanel.Children.RemoveAt(insertIndex);
|
||||||
|
_dailyViews.RemoveAt(existingIndex);
|
||||||
|
|
||||||
|
var newView = new DailyNewsView(todayNews, _isNightVisual);
|
||||||
|
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
|
||||||
|
|
||||||
|
NewsStackPanel.Children.Insert(insertIndex, newView);
|
||||||
|
_dailyViews.Insert(existingIndex, newView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newView = new DailyNewsView(todayNews, _isNightVisual);
|
||||||
|
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
|
||||||
|
|
||||||
|
NewsStackPanel.Children.Insert(0, newView);
|
||||||
|
_dailyViews.Insert(0, newView);
|
||||||
|
_loadedDates.Insert(0, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryOpenUrl(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = url,
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLoadingState()
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = "加载中...";
|
||||||
|
StatusTextBlock.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAdaptiveLayout()
|
||||||
|
{
|
||||||
|
var scale = ResolveScale();
|
||||||
|
var softScale = Math.Clamp(scale, 0.80, 1.32);
|
||||||
|
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||||
|
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||||
|
|
||||||
|
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
|
||||||
|
RootBorder.CornerRadius = unifiedMainRectangle;
|
||||||
|
CardBorder.CornerRadius = unifiedMainRectangle;
|
||||||
|
|
||||||
|
var horizontalPadding = Math.Clamp(16 * softScale, 10, 24);
|
||||||
|
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
|
||||||
|
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||||
|
|
||||||
|
var headerHeight = Math.Clamp(40 * softScale, 28, 56);
|
||||||
|
HeaderGrid.Height = headerHeight;
|
||||||
|
|
||||||
|
BrandTextBlock.FontSize = Math.Clamp(20 * softScale, 14, 26);
|
||||||
|
|
||||||
|
var avatarSize = Math.Clamp(36 * softScale, 24, 48);
|
||||||
|
AvatarBorder.Width = avatarSize;
|
||||||
|
AvatarBorder.Height = avatarSize;
|
||||||
|
AvatarBorder.CornerRadius = new CornerRadius(avatarSize / 2);
|
||||||
|
|
||||||
|
var buttonFontSize = Math.Clamp(13 * softScale, 10, 16);
|
||||||
|
RefreshButton.FontSize = buttonFontSize;
|
||||||
|
RefreshButton.Padding = new Thickness(
|
||||||
|
Math.Clamp(8 * softScale, 6, 12),
|
||||||
|
Math.Clamp(4 * softScale, 2, 6)
|
||||||
|
);
|
||||||
|
|
||||||
|
StatusTextBlock.FontSize = Math.Clamp(16 * softScale, 12, 22);
|
||||||
|
LoadingTextBlock.FontSize = Math.Clamp(14 * softScale, 11, 18);
|
||||||
|
|
||||||
|
foreach (var view in _dailyViews)
|
||||||
|
{
|
||||||
|
view.UpdateLayout(softScale, totalWidth - horizontalPadding * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyNightModeVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveScale()
|
||||||
|
{
|
||||||
|
var expectedWidth = _currentCellSize * BaseWidthCells;
|
||||||
|
var expectedHeight = _currentCellSize * BaseHeightCells;
|
||||||
|
if (expectedWidth <= 0 || expectedHeight <= 0)
|
||||||
|
{
|
||||||
|
return 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
|
||||||
|
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
|
||||||
|
var scaleX = actualWidth / expectedWidth;
|
||||||
|
var scaleY = actualHeight / expectedHeight;
|
||||||
|
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||||
|
|
||||||
|
private static double ResolveUnifiedMainRadiusValue() =>
|
||||||
|
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据模型
|
||||||
|
public sealed record JuyaDailyNews(
|
||||||
|
DateTime Date,
|
||||||
|
string Title,
|
||||||
|
string CoverImageUrl,
|
||||||
|
string IssueUrl,
|
||||||
|
string BilibiliUrl,
|
||||||
|
string YoutubeUrl,
|
||||||
|
IReadOnlyList<JuyaOverviewCategory> OverviewCategories,
|
||||||
|
IReadOnlyList<JuyaDetailedNewsItem> DetailedNews,
|
||||||
|
DateTimeOffset FetchedAt);
|
||||||
|
|
||||||
|
public sealed record JuyaOverviewCategory(
|
||||||
|
string Name,
|
||||||
|
string Icon,
|
||||||
|
IReadOnlyList<JuyaOverviewItem> Items);
|
||||||
|
|
||||||
|
public sealed record JuyaOverviewItem(
|
||||||
|
string Title,
|
||||||
|
string Url,
|
||||||
|
int? Number);
|
||||||
|
|
||||||
|
public sealed record JuyaDetailedNewsItem(
|
||||||
|
string Title,
|
||||||
|
int Number,
|
||||||
|
string BodyText,
|
||||||
|
IReadOnlyList<string> RelatedLinks);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
d:DesignHeight="480"
|
d:DesignHeight="480"
|
||||||
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
|
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
|
||||||
|
|
||||||
|
<Grid>
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Background="#F1F4F9"
|
Background="#F1F4F9"
|
||||||
CornerRadius="20"
|
CornerRadius="20"
|
||||||
@@ -91,4 +92,41 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Popup x:Name="ColorPickerPopup"
|
||||||
|
Placement="Top"
|
||||||
|
PlacementTarget="{Binding #PenButton}"
|
||||||
|
IsLightDismissEnabled="True"
|
||||||
|
WindowManagerAddShadowHint="False">
|
||||||
|
<Border Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<ColorView x:Name="InkColorPicker"
|
||||||
|
IsAlphaEnabled="False"
|
||||||
|
IsColorSpectrumVisible="True"
|
||||||
|
IsColorPaletteVisible="True"
|
||||||
|
IsHexInputVisible="True"
|
||||||
|
ColorChanged="OnColorPickerColorChanged" />
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="粗细"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="12" />
|
||||||
|
<Slider x:Name="InkThicknessSlider"
|
||||||
|
Grid.Column="1"
|
||||||
|
Minimum="1"
|
||||||
|
Maximum="8"
|
||||||
|
Value="2.5"
|
||||||
|
SmallChange="0.5"
|
||||||
|
LargeChange="1"
|
||||||
|
ValueChanged="OnInkThicknessSliderValueChanged" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Reflection;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
@@ -38,7 +39,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
private double _currentCellSize = 48;
|
private double _currentCellSize = 48;
|
||||||
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
||||||
private bool? _isNightModeApplied;
|
private bool? _isNightModeApplied;
|
||||||
private SKColor _currentInkColor = SKColors.Black;
|
private SKColor _selectedInkColor = SKColors.Black;
|
||||||
|
private float _selectedInkThickness = 2.5f;
|
||||||
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
||||||
private string _placementId = string.Empty;
|
private string _placementId = string.Empty;
|
||||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
@@ -66,9 +68,27 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
RefreshFromSettings();
|
RefreshFromSettings();
|
||||||
ApplyThemeVisual(force: true);
|
ApplyThemeVisual(force: true);
|
||||||
|
InitializeColorPicker();
|
||||||
SetToolMode(WhiteboardToolMode.Pen);
|
SetToolMode(WhiteboardToolMode.Pen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InitializeColorPicker()
|
||||||
|
{
|
||||||
|
if (InkColorPicker is not null)
|
||||||
|
{
|
||||||
|
InkColorPicker.Color = new Color(
|
||||||
|
_selectedInkColor.Alpha,
|
||||||
|
_selectedInkColor.Red,
|
||||||
|
_selectedInkColor.Green,
|
||||||
|
_selectedInkColor.Blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (InkThicknessSlider is not null)
|
||||||
|
{
|
||||||
|
InkThicknessSlider.Value = _selectedInkThickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int NoteRetentionDays => _noteRetentionDays;
|
public int NoteRetentionDays => _noteRetentionDays;
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
@@ -97,7 +117,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
|
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
|
||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||||
settings.IgnorePressure = true;
|
settings.IgnorePressure = true;
|
||||||
settings.InkThickness = 2.5f;
|
settings.InkThickness = _selectedInkThickness;
|
||||||
settings.EraserSize = new Size(20, 20);
|
settings.EraserSize = new Size(20, 20);
|
||||||
settings.IsBitmapCacheEnabled = true;
|
settings.IsBitmapCacheEnabled = true;
|
||||||
settings.MaxBitmapCacheSize = 2048;
|
settings.MaxBitmapCacheSize = 2048;
|
||||||
@@ -135,7 +155,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||||
settings.InkThickness = (float)Math.Clamp(_currentCellSize * 0.06, 2.0, 6.0);
|
|
||||||
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
||||||
settings.EraserSize = new Size(eraserSize, eraserSize);
|
settings.EraserSize = new Size(eraserSize, eraserSize);
|
||||||
}
|
}
|
||||||
@@ -149,7 +168,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isNightModeApplied = isNightMode;
|
_isNightModeApplied = isNightMode;
|
||||||
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
|
|
||||||
|
|
||||||
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
||||||
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
|
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
|
||||||
@@ -157,8 +175,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
||||||
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
||||||
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
|
|
||||||
RecolorAllStrokes(_currentInkColor);
|
|
||||||
RefreshToolButtonVisuals();
|
RefreshToolButtonVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +220,30 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ForceSaveNote()
|
||||||
|
{
|
||||||
|
if (_disposed || !HasValidPersistenceContext())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_noteDirty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteDirty = false;
|
||||||
|
_noteSaveTimer.Stop();
|
||||||
|
var noteSnapshot = BuildNoteSnapshot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -300,12 +340,31 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
|
|
||||||
if (mode == WhiteboardToolMode.Pen)
|
if (mode == WhiteboardToolMode.Pen)
|
||||||
{
|
{
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshToolButtonVisuals();
|
RefreshToolButtonVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetInkColor(SKColor color)
|
||||||
|
{
|
||||||
|
_selectedInkColor = color;
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen)
|
||||||
|
{
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||||
|
}
|
||||||
|
RefreshToolButtonVisuals();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetInkThickness(float thickness)
|
||||||
|
{
|
||||||
|
_selectedInkThickness = Math.Clamp(thickness, 1.0f, 8.0f);
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen)
|
||||||
|
{
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkThickness = _selectedInkThickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshToolButtonVisuals()
|
private void RefreshToolButtonVisuals()
|
||||||
{
|
{
|
||||||
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
||||||
@@ -349,9 +408,34 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
|
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen && ColorPickerPopup is not null)
|
||||||
|
{
|
||||||
|
if (ColorPickerPopup.IsOpen)
|
||||||
|
{
|
||||||
|
ColorPickerPopup.Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ColorPickerPopup.Open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
SetToolMode(WhiteboardToolMode.Pen);
|
SetToolMode(WhiteboardToolMode.Pen);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var color = e.NewColor;
|
||||||
|
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
|
{
|
||||||
|
SetInkThickness((float)e.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
|
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -509,14 +593,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
_noteDirty = false;
|
_noteDirty = false;
|
||||||
_noteSaveTimer.Stop();
|
_noteSaveTimer.Stop();
|
||||||
var noteSnapshot = BuildNoteSnapshot();
|
var noteSnapshot = BuildNoteSnapshot();
|
||||||
var componentId = _componentId;
|
try
|
||||||
var placementId = _placementId;
|
{
|
||||||
var retentionDays = _noteRetentionDays;
|
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
||||||
_ = Task.Run(() => _notePersistenceService.SaveNote(
|
}
|
||||||
componentId,
|
catch
|
||||||
placementId,
|
{
|
||||||
noteSnapshot,
|
}
|
||||||
retentionDays));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void SchedulePersistedNoteLoad()
|
private async void SchedulePersistedNoteLoad()
|
||||||
@@ -553,7 +636,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
{
|
{
|
||||||
ClearAllStrokes();
|
ClearAllStrokes();
|
||||||
ApplyNoteSnapshot(noteSnapshot);
|
ApplyNoteSnapshot(noteSnapshot);
|
||||||
RecolorAllStrokes(_currentInkColor);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
600
LanMountainDesktop/Views/MainWindow.ComponentPreviewImages.cs
Normal file
600
LanMountainDesktop/Views/MainWindow.ComponentPreviewImages.cs
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private const double PreviewRenderCellSizeMin = 42;
|
||||||
|
private const double PreviewRenderCellSizeMax = 112;
|
||||||
|
|
||||||
|
private readonly IComponentPreviewImageService _componentPreviewImageService = new ComponentPreviewImageService();
|
||||||
|
private readonly Dictionary<ComponentPreviewKey, List<ComponentLibraryPreviewVisualTarget>> _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance);
|
||||||
|
private bool _componentLibraryPreviewWarmupStarted;
|
||||||
|
|
||||||
|
private sealed record ComponentLibraryPreviewVisualTarget(Image Image, Control Fallback);
|
||||||
|
|
||||||
|
private void EnsureComponentLibraryPreviewWarmup()
|
||||||
|
{
|
||||||
|
if (_componentLibraryCategories.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeCategoryId = _componentLibraryActiveCategoryId ??
|
||||||
|
_componentLibraryCategories[Math.Clamp(_componentLibraryCategoryIndex, 0, _componentLibraryCategories.Count - 1)].Id;
|
||||||
|
if (!_componentLibraryPreviewWarmupStarted)
|
||||||
|
{
|
||||||
|
_componentLibraryPreviewWarmupStarted = true;
|
||||||
|
_ = WarmComponentLibraryPreviewsSeriallyAsync(activeCategoryId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeCategory = _componentLibraryCategories.FirstOrDefault(category =>
|
||||||
|
string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (activeCategory is not null)
|
||||||
|
{
|
||||||
|
_ = WarmComponentLibraryCategoryPreviewsAsync(activeCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WarmComponentLibraryPreviewsSeriallyAsync(string activeCategoryId)
|
||||||
|
{
|
||||||
|
var prioritized = _componentLibraryCategories
|
||||||
|
.OrderBy(category => string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var category in prioritized)
|
||||||
|
{
|
||||||
|
await WarmComponentLibraryCategoryPreviewsAsync(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WarmComponentLibraryCategoryPreviewsAsync(ComponentLibraryCategory category)
|
||||||
|
{
|
||||||
|
foreach (var component in category.Components)
|
||||||
|
{
|
||||||
|
var span = NormalizeComponentCellSpan(
|
||||||
|
component.ComponentId,
|
||||||
|
(component.MinWidthCells, component.MinHeightCells));
|
||||||
|
await EnsureComponentTypePreviewImageAsync(component.ComponentId, span.WidthCells, span.HeightCells);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IImage?> EnsureComponentTypePreviewImageAsync(string componentId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(componentId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||||
|
var cached = ResolvePreviewImageFromService(key);
|
||||||
|
if (cached is not null)
|
||||||
|
{
|
||||||
|
ApplyPreviewEntryToEmbeddedVisuals(key);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await QueuePreviewGenerationAsync(
|
||||||
|
key,
|
||||||
|
pageIndex: null,
|
||||||
|
action: "ComponentTypePreview",
|
||||||
|
forceRefresh: false);
|
||||||
|
return entry.Bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IImage?> RefreshPlacementPreviewImageAsync(DesktopComponentPlacementSnapshot? placement, bool forceRefresh)
|
||||||
|
{
|
||||||
|
if (placement is null ||
|
||||||
|
string.IsNullOrWhiteSpace(placement.ComponentId) ||
|
||||||
|
string.IsNullOrWhiteSpace(placement.PlacementId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsPlacementPresent(placement.PlacementId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = ClonePlacementSnapshot(placement);
|
||||||
|
var key = CreatePlacementPreviewKey(
|
||||||
|
snapshot.ComponentId,
|
||||||
|
snapshot.PlacementId,
|
||||||
|
snapshot.WidthCells,
|
||||||
|
snapshot.HeightCells);
|
||||||
|
if (!forceRefresh)
|
||||||
|
{
|
||||||
|
var cached = ResolvePreviewImageFromService(key);
|
||||||
|
if (cached is not null)
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_componentPreviewImageService.RemovePlacementPreviews(snapshot.PlacementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await QueuePreviewGenerationAsync(
|
||||||
|
key,
|
||||||
|
snapshot.PageIndex,
|
||||||
|
action: "PlacementPreview",
|
||||||
|
forceRefresh: false);
|
||||||
|
if (!IsPlacementPresent(snapshot.PlacementId))
|
||||||
|
{
|
||||||
|
RemovePlacementPreviewImage(snapshot.PlacementId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ComponentPreviewImageEntry> QueuePreviewGenerationAsync(
|
||||||
|
ComponentPreviewKey key,
|
||||||
|
int? pageIndex,
|
||||||
|
string action,
|
||||||
|
bool forceRefresh,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
|
||||||
|
var visualSignature = BuildPreviewVisualSignature(key, renderCellSize);
|
||||||
|
if (forceRefresh)
|
||||||
|
{
|
||||||
|
_componentPreviewImageService.Invalidate(key, visualSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await _componentPreviewImageService.QueueGenerationAsync(
|
||||||
|
key,
|
||||||
|
visualSignature,
|
||||||
|
async ct =>
|
||||||
|
{
|
||||||
|
_ = ct;
|
||||||
|
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
|
||||||
|
!IsPlacementPresent(key.PlacementId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bitmap = await CapturePreviewImageAsync(
|
||||||
|
key.ComponentTypeId,
|
||||||
|
key.PlacementId,
|
||||||
|
pageIndex,
|
||||||
|
key.WidthCells,
|
||||||
|
key.HeightCells,
|
||||||
|
renderCellSize,
|
||||||
|
action);
|
||||||
|
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
|
||||||
|
!IsPlacementPresent(key.PlacementId))
|
||||||
|
{
|
||||||
|
DisposeImageIfNeeded(bitmap);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitmap;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
NotifyPreviewEntryUpdated(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IImage?> CapturePreviewImageAsync(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int? pageIndex,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells,
|
||||||
|
double renderCellSize,
|
||||||
|
string action)
|
||||||
|
{
|
||||||
|
if (ComponentPreviewStagingHost is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeWidthCells = Math.Max(1, widthCells);
|
||||||
|
var safeHeightCells = Math.Max(1, heightCells);
|
||||||
|
var safeCellSize = Math.Clamp(renderCellSize, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
|
||||||
|
var previewWidth = safeWidthCells * safeCellSize;
|
||||||
|
var previewHeight = safeHeightCells * safeCellSize;
|
||||||
|
|
||||||
|
var previewControl = CreateDesktopComponentControl(
|
||||||
|
componentId,
|
||||||
|
safeCellSize,
|
||||||
|
placementId,
|
||||||
|
pageIndex,
|
||||||
|
action);
|
||||||
|
if (previewControl is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewControl.IsHitTestVisible = false;
|
||||||
|
previewControl.Focusable = false;
|
||||||
|
|
||||||
|
var stage = new Border
|
||||||
|
{
|
||||||
|
Width = previewWidth,
|
||||||
|
Height = previewHeight,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
ClipToBounds = true,
|
||||||
|
Child = previewControl
|
||||||
|
};
|
||||||
|
|
||||||
|
Canvas.SetLeft(stage, -20000);
|
||||||
|
Canvas.SetTop(stage, -20000);
|
||||||
|
ComponentPreviewStagingHost.Children.Add(stage);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stage.Measure(new Size(previewWidth, previewHeight));
|
||||||
|
stage.Arrange(new Rect(0, 0, previewWidth, previewHeight));
|
||||||
|
stage.UpdateLayout();
|
||||||
|
await WaitForPreviewRenderPassAsync();
|
||||||
|
|
||||||
|
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||||
|
var pixelSize = new PixelSize(
|
||||||
|
Math.Max(1, (int)Math.Ceiling(previewWidth * renderScale)),
|
||||||
|
Math.Max(1, (int)Math.Ceiling(previewHeight * renderScale)));
|
||||||
|
var bitmap = new RenderTargetBitmap(pixelSize, new Vector(96 * renderScale, 96 * renderScale));
|
||||||
|
bitmap.Render(stage);
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"ComponentPreview",
|
||||||
|
$"Action={action}; ComponentId={componentId}; PlacementId={placementId ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false",
|
||||||
|
ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ComponentPreviewStagingHost.Children.Remove(stage);
|
||||||
|
ClearTimeZoneServiceBindings(stage);
|
||||||
|
if (previewControl is IDisposable disposableControl)
|
||||||
|
{
|
||||||
|
disposableControl.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForPreviewRenderPassAsync()
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Background);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolvePreviewRenderCellSize(int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
var baseCellSize = _currentDesktopCellSize > 0
|
||||||
|
? _currentDesktopCellSize * 1.10
|
||||||
|
: 74;
|
||||||
|
var densityBoost = Math.Max(widthCells, heightCells) >= 4 ? 8 : 0;
|
||||||
|
return Math.Clamp(baseCellSize + densityBoost, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPreviewVisualSignature(ComponentPreviewKey key, double renderCellSize)
|
||||||
|
{
|
||||||
|
var appearance = _appearanceThemeService.GetCurrent();
|
||||||
|
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.GlobalCornerRadiusScale:F3}|Accent={FormatSignatureColor(appearance.AccentColor)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||||
|
return ComponentPreviewKey.ForComponentType(componentId, span.WidthCells, span.HeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewKey CreatePlacementPreviewKey(string componentId, string placementId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||||
|
return ComponentPreviewKey.ForPlacementInstance(componentId, placementId, span.WidthCells, span.HeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPlacementPresent(string? placementId)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(placementId) &&
|
||||||
|
_desktopComponentPlacements.Any(candidate =>
|
||||||
|
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildCurrentVisualSignature(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
|
||||||
|
return BuildPreviewVisualSignature(key, renderCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetReusablePreviewEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||||
|
{
|
||||||
|
if (!_componentPreviewImageService.TryGetEntry(key, out entry) ||
|
||||||
|
entry is null ||
|
||||||
|
entry.State != ComponentPreviewImageState.Ready ||
|
||||||
|
entry.Bitmap is null)
|
||||||
|
{
|
||||||
|
entry = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedSignature = BuildCurrentVisualSignature(key);
|
||||||
|
if (!string.Equals(entry.VisualSignature, expectedSignature, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
entry = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IImage? ResolvePreviewImageFromService(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
if (!TryGetReusablePreviewEntry(key, out var entry) || entry is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewImageEntry? ResolvePreviewEntry(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
if (!_componentPreviewImageService.TryGetEntry(key, out var entry) || entry is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State != ComponentPreviewImageState.Ready)
|
||||||
|
{
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryGetReusablePreviewEntry(key, out var reusable) ? reusable : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IImage? ResolveComponentTypePreviewImage(string componentId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||||
|
return ResolvePreviewImageFromService(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IImage? ResolveDesktopEditPreviewImage(string componentId, string? placementId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(placementId))
|
||||||
|
{
|
||||||
|
var placementKey = CreatePlacementPreviewKey(componentId, placementId, widthCells, heightCells);
|
||||||
|
var placementImage = ResolvePreviewImageFromService(placementKey);
|
||||||
|
if (placementImage is not null)
|
||||||
|
{
|
||||||
|
return placementImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentTypeKey = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||||
|
return ResolvePreviewImageFromService(componentTypeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (int WidthCells, int HeightCells) ResolveOverlayPreviewSpan(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int? widthCells,
|
||||||
|
int? heightCells)
|
||||||
|
{
|
||||||
|
if (widthCells is > 0 && heightCells is > 0)
|
||||||
|
{
|
||||||
|
return NormalizeComponentCellSpan(componentId, (widthCells.Value, heightCells.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||||
|
TryGetDesktopPlacementById(placementId, out var placement))
|
||||||
|
{
|
||||||
|
return NormalizeComponentCellSpan(componentId, (placement.WidthCells, placement.HeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) &&
|
||||||
|
string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
_desktopEditSession.WidthCells > 0 &&
|
||||||
|
_desktopEditSession.HeightCells > 0)
|
||||||
|
{
|
||||||
|
return NormalizeComponentCellSpan(componentId, (_desktopEditSession.WidthCells, _desktopEditSession.HeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
|
||||||
|
{
|
||||||
|
return NormalizeComponentCellSpan(
|
||||||
|
componentId,
|
||||||
|
(descriptor.Definition.MinWidthCells, descriptor.Definition.MinHeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDesktopEditOverlayPreviewImage(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int? widthCells = null,
|
||||||
|
int? heightCells = null)
|
||||||
|
{
|
||||||
|
var span = ResolveOverlayPreviewSpan(componentId, placementId, widthCells, heightCells);
|
||||||
|
EnsureDesktopEditOverlayPresenter();
|
||||||
|
_desktopEditOverlayPresenter?.SetPreviewImage(ResolveDesktopEditPreviewImage(componentId, placementId, span.WidthCells, span.HeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrimeDesktopEditPreviewImage(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int pageIndex,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells)
|
||||||
|
{
|
||||||
|
_ = pageIndex;
|
||||||
|
var normalized = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||||
|
_ = EnsureComponentTypePreviewImageAsync(componentId, normalized.WidthCells, normalized.HeightCells);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||||
|
TryGetDesktopPlacementById(placementId, out var placement))
|
||||||
|
{
|
||||||
|
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueuePlacementPreviewRefresh(DesktopComponentPlacementSnapshot? placement)
|
||||||
|
{
|
||||||
|
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemovePlacementPreviewImage(string? placementId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(placementId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentPreviewImageService.RemovePlacementPreviews(placementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemovePlacementPreviewImages(IEnumerable<DesktopComponentPlacementSnapshot> placements)
|
||||||
|
{
|
||||||
|
foreach (var placementId in placements
|
||||||
|
.Select(placement => placement.PlacementId)
|
||||||
|
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
RemovePlacementPreviewImage(placementId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterComponentLibraryPreviewVisual(ComponentPreviewKey key, Image image, Control fallback)
|
||||||
|
{
|
||||||
|
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var visuals))
|
||||||
|
{
|
||||||
|
visuals = [];
|
||||||
|
_componentLibraryPreviewVisualTargets[key] = visuals;
|
||||||
|
}
|
||||||
|
|
||||||
|
visuals.Add(new ComponentLibraryPreviewVisualTarget(image, fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearComponentLibraryPreviewVisualTargets()
|
||||||
|
{
|
||||||
|
_componentLibraryPreviewVisualTargets.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPreviewEntryToEmbeddedVisuals(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var targets))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewImage = ResolvePreviewImageFromService(key);
|
||||||
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
target.Image.Source = previewImage;
|
||||||
|
target.Image.IsVisible = previewImage is not null;
|
||||||
|
target.Fallback.IsVisible = previewImage is null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyPreviewEntryUpdated(ComponentPreviewImageEntry entry)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
ApplyPreviewEntryToEmbeddedVisuals(entry.Key);
|
||||||
|
_detachedComponentLibraryWindow?.UpdatePreviewImage(entry);
|
||||||
|
|
||||||
|
if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||||
|
{
|
||||||
|
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, entry.Key.PlacementId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, placementId: null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeImageIfNeeded(IImage? image)
|
||||||
|
{
|
||||||
|
if (image is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatSignatureColor(Color color)
|
||||||
|
{
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshDesktopEditOverlayPreviewIfActive(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
if (_desktopEditOverlayPresenter is null ||
|
||||||
|
(!_desktopEditSession.IsActive && !_isDesktopEditCommitPending) ||
|
||||||
|
string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) ||
|
||||||
|
!string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||||
|
!string.Equals(_desktopEditSession.PlacementId, placementId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyDesktopEditOverlayPreviewImage(
|
||||||
|
_desktopEditSession.ComponentId,
|
||||||
|
_desktopEditSession.PlacementId,
|
||||||
|
_desktopEditSession.WidthCells,
|
||||||
|
_desktopEditSession.HeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewKey ResolveDetachedLibraryPreviewKey(ComponentLibraryComponentEntry entry)
|
||||||
|
{
|
||||||
|
return CreateComponentTypePreviewKey(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewImageEntry? ResolveDetachedLibraryPreviewEntry(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
return ResolvePreviewEntry(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestDetachedLibraryPreviewWarm(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
_ = QueuePreviewGenerationAsync(
|
||||||
|
key,
|
||||||
|
pageIndex: null,
|
||||||
|
action: "DetachedLibraryWarm",
|
||||||
|
forceRefresh: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestDetachedLibraryPreviewRender(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
_ = QueuePreviewGenerationAsync(
|
||||||
|
key,
|
||||||
|
pageIndex: null,
|
||||||
|
action: "DetachedLibraryRender",
|
||||||
|
forceRefresh: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Shapes;
|
using Avalonia.Controls.Shapes;
|
||||||
@@ -10,6 +11,7 @@ using Avalonia.Layout;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
|
using FluentAvalonia.UI.Controls;
|
||||||
using FluentIcons.Avalonia;
|
using FluentIcons.Avalonia;
|
||||||
using FluentIcons.Common;
|
using FluentIcons.Common;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
@@ -22,6 +24,8 @@ using LanMountainDesktop.Settings.Core;
|
|||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LanMountainDesktop.Views.Components;
|
using LanMountainDesktop.Views.Components;
|
||||||
using PathShape = Avalonia.Controls.Shapes.Path;
|
using PathShape = Avalonia.Controls.Shapes.Path;
|
||||||
|
using Symbol = FluentIcons.Common.Symbol;
|
||||||
|
using SymbolIcon = FluentIcons.Avalonia.SymbolIcon;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views;
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
@@ -555,7 +559,11 @@ public partial class MainWindow
|
|||||||
_calculatorDataService,
|
_calculatorDataService,
|
||||||
_settingsFacade);
|
_settingsFacade);
|
||||||
},
|
},
|
||||||
L);
|
L,
|
||||||
|
previewKeyResolver: ResolveDetachedLibraryPreviewKey,
|
||||||
|
previewEntryResolver: ResolveDetachedLibraryPreviewEntry,
|
||||||
|
warmPreviewRequested: RequestDetachedLibraryPreviewWarm,
|
||||||
|
renderPreviewRequested: RequestDetachedLibraryPreviewRender);
|
||||||
window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested;
|
window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested;
|
||||||
window.Closed += OnDetachedComponentLibraryClosed;
|
window.Closed += OnDetachedComponentLibraryClosed;
|
||||||
return window;
|
return window;
|
||||||
@@ -822,7 +830,7 @@ public partial class MainWindow
|
|||||||
AddDesktopPage();
|
AddDesktopPage();
|
||||||
break;
|
break;
|
||||||
case "desktop.delete_page":
|
case "desktop.delete_page":
|
||||||
DeleteCurrentDesktopPage();
|
ConfirmAndDeleteCurrentDesktopPage();
|
||||||
break;
|
break;
|
||||||
case "component.delete":
|
case "component.delete":
|
||||||
DeleteSelectedComponent();
|
DeleteSelectedComponent();
|
||||||
@@ -836,6 +844,29 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void ConfirmAndDeleteCurrentDesktopPage()
|
||||||
|
{
|
||||||
|
if (_desktopPageCount <= MinDesktopPageCount)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialog = new ContentDialog
|
||||||
|
{
|
||||||
|
Title = L("desktop.delete_page_confirm.title", "确认删除页面"),
|
||||||
|
Content = L("desktop.delete_page_confirm.message", "确定要删除当前页面吗?\n\n此操作将删除当前页面上的所有组件,且无法撤销。"),
|
||||||
|
PrimaryButtonText = L("desktop.delete_page_confirm.close", "取消"),
|
||||||
|
SecondaryButtonText = L("desktop.delete_page_confirm.primary", "删除"),
|
||||||
|
DefaultButton = ContentDialogButton.Primary
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await dialog.ShowAsync(this);
|
||||||
|
if (result == ContentDialogResult.Secondary)
|
||||||
|
{
|
||||||
|
DeleteCurrentDesktopPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void DeleteSelectedComponent()
|
private void DeleteSelectedComponent()
|
||||||
{
|
{
|
||||||
if (_selectedDesktopComponentHost is null || _selectedDesktopComponentHost.Tag is not string placementId)
|
if (_selectedDesktopComponentHost is null || _selectedDesktopComponentHost.Tag is not string placementId)
|
||||||
@@ -867,6 +898,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
_desktopComponentPlacements.Remove(placement);
|
_desktopComponentPlacements.Remove(placement);
|
||||||
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
||||||
|
RemovePlacementPreviewImage(placement.PlacementId);
|
||||||
|
|
||||||
ClearDesktopComponentSelection();
|
ClearDesktopComponentSelection();
|
||||||
|
|
||||||
@@ -935,6 +967,7 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
RestoreDesktopPageComponents(placement.PageIndex);
|
RestoreDesktopPageComponents(placement.PageIndex);
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -961,6 +994,8 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
ApplySelectionStateToHost(host, true);
|
ApplySelectionStateToHost(host, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DisposeComponentIfNeeded(Border host)
|
private static void DisposeComponentIfNeeded(Border host)
|
||||||
@@ -1017,6 +1052,7 @@ public partial class MainWindow
|
|||||||
_desktopComponentPlacements.Remove(placement);
|
_desktopComponentPlacements.Remove(placement);
|
||||||
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
||||||
}
|
}
|
||||||
|
RemovePlacementPreviewImages(placementsToRemove);
|
||||||
|
|
||||||
_desktopPageCount = Math.Clamp(_desktopPageCount - 1, MinDesktopPageCount, MaxDesktopPageCount);
|
_desktopPageCount = Math.Clamp(_desktopPageCount - 1, MinDesktopPageCount, MaxDesktopPageCount);
|
||||||
|
|
||||||
@@ -1197,6 +1233,7 @@ public partial class MainWindow
|
|||||||
pageGrid.Children.Add(host);
|
pageGrid.Children.Add(host);
|
||||||
|
|
||||||
_desktopComponentPlacements.Add(placement);
|
_desktopComponentPlacements.Add(placement);
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
InvalidateDesktopPageAwareComponentContextCache();
|
InvalidateDesktopPageAwareComponentContextCache();
|
||||||
UpdateDesktopPageAwareComponentContext();
|
UpdateDesktopPageAwareComponentContext();
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
@@ -2063,6 +2100,13 @@ public partial class MainWindow
|
|||||||
SetDesktopEditSourceHost(sourceHost, 0.22);
|
SetDesktopEditSourceHost(sourceHost, 0.22);
|
||||||
EnsureDesktopEditOverlayPresenter();
|
EnsureDesktopEditOverlayPresenter();
|
||||||
UpdateDesktopEditOverlayMetadata(placement.ComponentId, widthCells, heightCells, L("component.move", "Move"));
|
UpdateDesktopEditOverlayMetadata(placement.ComponentId, widthCells, heightCells, L("component.move", "Move"));
|
||||||
|
ApplyDesktopEditOverlayPreviewImage(placement.ComponentId, placement.PlacementId, widthCells, heightCells);
|
||||||
|
PrimeDesktopEditPreviewImage(
|
||||||
|
placement.ComponentId,
|
||||||
|
placement.PlacementId,
|
||||||
|
placement.PageIndex,
|
||||||
|
widthCells,
|
||||||
|
heightCells);
|
||||||
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
@@ -2109,6 +2153,13 @@ public partial class MainWindow
|
|||||||
|
|
||||||
EnsureDesktopEditOverlayPresenter();
|
EnsureDesktopEditOverlayPresenter();
|
||||||
UpdateDesktopEditOverlayMetadata(componentId, widthCells, heightCells, L("component_library.drag_hint", "Drag to place"));
|
UpdateDesktopEditOverlayMetadata(componentId, widthCells, heightCells, L("component_library.drag_hint", "Drag to place"));
|
||||||
|
ApplyDesktopEditOverlayPreviewImage(componentId, placementId: null, widthCells, heightCells);
|
||||||
|
PrimeDesktopEditPreviewImage(
|
||||||
|
componentId,
|
||||||
|
placementId: null,
|
||||||
|
_currentDesktopSurfaceIndex,
|
||||||
|
widthCells,
|
||||||
|
heightCells);
|
||||||
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetCandidateRect(null);
|
_desktopEditOverlayPresenter?.SetCandidateRect(null);
|
||||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
@@ -2216,6 +2267,13 @@ public partial class MainWindow
|
|||||||
SetDesktopEditSourceHost(sourceHost, 0.22);
|
SetDesktopEditSourceHost(sourceHost, 0.22);
|
||||||
EnsureDesktopEditOverlayPresenter();
|
EnsureDesktopEditOverlayPresenter();
|
||||||
UpdateDesktopEditOverlayMetadata(placement.ComponentId, startSpan.WidthCells, startSpan.HeightCells, L("component.resize", "Resize"));
|
UpdateDesktopEditOverlayMetadata(placement.ComponentId, startSpan.WidthCells, startSpan.HeightCells, L("component.resize", "Resize"));
|
||||||
|
ApplyDesktopEditOverlayPreviewImage(placement.ComponentId, placement.PlacementId, startSpan.WidthCells, startSpan.HeightCells);
|
||||||
|
PrimeDesktopEditPreviewImage(
|
||||||
|
placement.ComponentId,
|
||||||
|
placement.PlacementId,
|
||||||
|
placement.PageIndex,
|
||||||
|
startSpan.WidthCells,
|
||||||
|
startSpan.HeightCells);
|
||||||
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
@@ -2484,6 +2542,8 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
ComponentLibraryBackTextBlock.Text = L("common.back", "Back");
|
ComponentLibraryBackTextBlock.Text = L("common.back", "Back");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureComponentLibraryPreviewWarmup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<ComponentLibraryCategory> GetComponentLibraryCategories()
|
private IReadOnlyList<ComponentLibraryCategory> GetComponentLibraryCategories()
|
||||||
@@ -2659,6 +2719,7 @@ public partial class MainWindow
|
|||||||
var category = _componentLibraryCategories[_componentLibraryCategoryIndex];
|
var category = _componentLibraryCategories[_componentLibraryCategoryIndex];
|
||||||
_componentLibraryActiveCategoryId = category.Id;
|
_componentLibraryActiveCategoryId = category.Id;
|
||||||
_componentLibraryComponentIndex = 0;
|
_componentLibraryComponentIndex = 0;
|
||||||
|
_ = WarmComponentLibraryCategoryPreviewsAsync(category);
|
||||||
BuildComponentLibraryComponentPages(category);
|
BuildComponentLibraryComponentPages(category);
|
||||||
ShowComponentLibraryComponentsView();
|
ShowComponentLibraryComponentsView();
|
||||||
}
|
}
|
||||||
@@ -2679,6 +2740,7 @@ public partial class MainWindow
|
|||||||
ComponentLibraryComponentPagesContainer.Children.Clear();
|
ComponentLibraryComponentPagesContainer.Children.Clear();
|
||||||
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
||||||
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
||||||
|
ClearComponentLibraryPreviewVisualTargets();
|
||||||
if (componentCount == 0)
|
if (componentCount == 0)
|
||||||
{
|
{
|
||||||
_componentLibraryComponentIndex = 0;
|
_componentLibraryComponentIndex = 0;
|
||||||
@@ -2752,37 +2814,49 @@ public partial class MainWindow
|
|||||||
|
|
||||||
var previewWidth = previewSpan.WidthCells * previewCellSize;
|
var previewWidth = previewSpan.WidthCells * previewCellSize;
|
||||||
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
||||||
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
|
var previewKey = CreateComponentTypePreviewKey(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||||
|
var cachedPreviewImage = ResolveComponentTypePreviewImage(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||||
|
|
||||||
var previewControl = CreateDesktopComponentControl(
|
var previewImage = new Image
|
||||||
component.ComponentId,
|
|
||||||
renderCellSize,
|
|
||||||
placementId: null,
|
|
||||||
pageIndex: null,
|
|
||||||
action: "ComponentLibraryPreview");
|
|
||||||
if (previewControl is null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Component library previews must stay non-interactive so drag gesture is reliable.
|
|
||||||
previewControl.IsHitTestVisible = false;
|
|
||||||
previewControl.Focusable = false;
|
|
||||||
|
|
||||||
var previewSurface = new Border
|
|
||||||
{
|
|
||||||
Width = previewSpan.WidthCells * renderCellSize,
|
|
||||||
Height = previewSpan.HeightCells * renderCellSize,
|
|
||||||
Background = Brushes.Transparent,
|
|
||||||
IsHitTestVisible = false,
|
|
||||||
Child = previewControl
|
|
||||||
};
|
|
||||||
|
|
||||||
var previewViewbox = new Viewbox
|
|
||||||
{
|
{
|
||||||
Width = previewWidth,
|
Width = previewWidth,
|
||||||
Height = previewHeight,
|
Height = previewHeight,
|
||||||
Stretch = Stretch.Uniform,
|
Stretch = Stretch.Uniform,
|
||||||
Child = previewSurface
|
Source = cachedPreviewImage,
|
||||||
|
IsVisible = cachedPreviewImage is not null,
|
||||||
|
IsHitTestVisible = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var previewFallback = new Border
|
||||||
|
{
|
||||||
|
Width = previewWidth,
|
||||||
|
Height = previewHeight,
|
||||||
|
Background = GetThemeBrush("AdaptiveCardBackgroundBrush"),
|
||||||
|
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
CornerRadius = new CornerRadius(Math.Clamp(Math.Min(previewWidth, previewHeight) * 0.18, 12, 28)),
|
||||||
|
IsVisible = cachedPreviewImage is null,
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = L("component_library.preview_loading", "Preparing preview"),
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush"),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
}
|
||||||
|
};
|
||||||
|
RegisterComponentLibraryPreviewVisual(previewKey, previewImage, previewFallback);
|
||||||
|
|
||||||
|
var previewSurface = new Grid
|
||||||
|
{
|
||||||
|
Width = previewWidth,
|
||||||
|
Height = previewHeight,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
previewImage,
|
||||||
|
previewFallback
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var previewBorder = new Border
|
var previewBorder = new Border
|
||||||
@@ -2792,7 +2866,7 @@ public partial class MainWindow
|
|||||||
ClipToBounds = false,
|
ClipToBounds = false,
|
||||||
Background = Brushes.Transparent,
|
Background = Brushes.Transparent,
|
||||||
BorderThickness = new Thickness(0),
|
BorderThickness = new Thickness(0),
|
||||||
Child = previewViewbox,
|
Child = previewSurface,
|
||||||
Tag = component.ComponentId
|
Tag = component.ComponentId
|
||||||
};
|
};
|
||||||
previewBorder.PointerPressed += OnComponentLibraryComponentPreviewPointerPressed;
|
previewBorder.PointerPressed += OnComponentLibraryComponentPreviewPointerPressed;
|
||||||
@@ -2832,6 +2906,15 @@ public partial class MainWindow
|
|||||||
Grid.SetRow(page, 0);
|
Grid.SetRow(page, 0);
|
||||||
Grid.SetColumn(page, i);
|
Grid.SetColumn(page, i);
|
||||||
ComponentLibraryComponentPagesContainer.Children.Add(page);
|
ComponentLibraryComponentPagesContainer.Children.Add(page);
|
||||||
|
|
||||||
|
if (cachedPreviewImage is null)
|
||||||
|
{
|
||||||
|
_ = EnsureComponentTypePreviewImageAsync(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyPreviewEntryToEmbeddedVisuals(previewKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_componentLibraryComponentHostTransform = ComponentLibraryComponentPagesHost.RenderTransform as TranslateTransform;
|
_componentLibraryComponentHostTransform = ComponentLibraryComponentPagesHost.RenderTransform as TranslateTransform;
|
||||||
@@ -2856,6 +2939,7 @@ public partial class MainWindow
|
|||||||
ComponentLibraryComponentPagesContainer.Children.Clear();
|
ComponentLibraryComponentPagesContainer.Children.Clear();
|
||||||
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
||||||
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
||||||
|
ClearComponentLibraryPreviewVisualTargets();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component)
|
private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component)
|
||||||
@@ -3192,4 +3276,19 @@ public partial class MainWindow
|
|||||||
_isComponentLibraryComponentGestureActive = false;
|
_isComponentLibraryComponentGestureActive = false;
|
||||||
ApplyComponentLibraryComponentOffset();
|
ApplyComponentLibraryComponentOffset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void SaveAllWhiteboardNotes()
|
||||||
|
{
|
||||||
|
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
||||||
|
{
|
||||||
|
foreach (var host in pageGrid.Children.OfType<Border>())
|
||||||
|
{
|
||||||
|
var contentHost = TryGetContentHost(host);
|
||||||
|
if (contentHost?.Child is WhiteboardWidget whiteboard)
|
||||||
|
{
|
||||||
|
whiteboard.ForceSaveNote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ namespace LanMountainDesktop.Views;
|
|||||||
|
|
||||||
public partial class MainWindow
|
public partial class MainWindow
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan DesktopEditOverlayAnimationDuration = FluttermotionToken.Fast;
|
private static readonly TimeSpan DesktopEditCommitAnimationDuration = FluttermotionToken.Standard;
|
||||||
|
private static readonly TimeSpan DesktopEditCancelAnimationDuration = FluttermotionToken.Fast;
|
||||||
|
|
||||||
private DesktopEditSession _desktopEditSession;
|
private DesktopEditSession _desktopEditSession;
|
||||||
private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter;
|
private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter;
|
||||||
@@ -328,7 +329,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
ResetDesktopEditState();
|
ResetDesktopEditState();
|
||||||
},
|
},
|
||||||
DesktopEditOverlayAnimationDuration);
|
DesktopEditCancelAnimationDuration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +370,7 @@ public partial class MainWindow
|
|||||||
RestoreComponentLibraryAfterDesktopEdit();
|
RestoreComponentLibraryAfterDesktopEdit();
|
||||||
ResetDesktopEditState();
|
ResetDesktopEditState();
|
||||||
},
|
},
|
||||||
DesktopEditOverlayAnimationDuration);
|
DesktopEditCommitAnimationDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateDesktopEditSession(Point pointerInViewport)
|
private void UpdateDesktopEditSession(Point pointerInViewport)
|
||||||
@@ -707,6 +708,7 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
|
TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -552,7 +552,6 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
if (node is Control control)
|
if (node is Control control)
|
||||||
{
|
{
|
||||||
// Avoid swiping pages when interacting with desktop components/widgets.
|
|
||||||
if (control.Classes.Contains("desktop-component") ||
|
if (control.Classes.Contains("desktop-component") ||
|
||||||
control.Classes.Contains("desktop-component-host"))
|
control.Classes.Contains("desktop-component-host"))
|
||||||
{
|
{
|
||||||
@@ -560,7 +559,31 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node is Button or TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch)
|
if (node is Button button && IsLauncherTileButton(button))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLauncherTileButton(Button? button)
|
||||||
|
{
|
||||||
|
if (button is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var node in button.GetSelfAndVisualAncestors())
|
||||||
|
{
|
||||||
|
if (node is WrapPanel panel &&
|
||||||
|
(panel.Name == "LauncherRootTilePanel" || panel.Name == "LauncherFolderTilePanel"))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -611,7 +634,17 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private static bool IsDesktopSwipeBlockingNode(object node)
|
private static bool IsDesktopSwipeBlockingNode(object node)
|
||||||
{
|
{
|
||||||
if (node is Button or TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem or ScrollViewer)
|
if (node is ScrollViewer scrollViewer && IsLauncherScrollViewer(scrollViewer))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is Button button && IsLauncherTileButton(button))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -625,13 +658,23 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
var typeName = node.GetType().Name;
|
var typeName = node.GetType().Name;
|
||||||
return typeName.Contains("Button", StringComparison.OrdinalIgnoreCase) ||
|
return typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
|
||||||
typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
|
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
|
||||||
typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) ||
|
typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) ||
|
||||||
typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase);
|
typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsLauncherScrollViewer(ScrollViewer? scrollViewer)
|
||||||
|
{
|
||||||
|
if (scrollViewer is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrollViewer.Name == "LauncherRootScrollViewer" ||
|
||||||
|
scrollViewer.Name == "LauncherFolderScrollViewer";
|
||||||
|
}
|
||||||
|
|
||||||
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
||||||
{
|
{
|
||||||
point = default;
|
point = default;
|
||||||
|
|||||||
@@ -243,6 +243,15 @@
|
|||||||
|
|
||||||
<Canvas x:Name="DesktopEditDragLayer"
|
<Canvas x:Name="DesktopEditDragLayer"
|
||||||
IsHitTestVisible="False" />
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
|
<Canvas x:Name="ComponentPreviewStagingHost"
|
||||||
|
Width="1"
|
||||||
|
Height="1"
|
||||||
|
Opacity="0"
|
||||||
|
ClipToBounds="True"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
var wasVisible = IsVisible;
|
var wasVisible = IsVisible;
|
||||||
var windowState = WindowState.ToString();
|
var windowState = WindowState.ToString();
|
||||||
|
|
||||||
|
SaveAllWhiteboardNotes();
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
_componentEditorWindowService.Close();
|
_componentEditorWindowService.Close();
|
||||||
if (_detachedComponentLibraryWindow is not null)
|
if (_detachedComponentLibraryWindow is not null)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.AppearanceSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.AppearanceSettingsPage"
|
||||||
x:DataType="vm:AppearanceSettingsPageViewModel">
|
x:DataType="vm:AppearanceSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<controls:IconText Icon="Color"
|
<controls:IconText Icon="Color"
|
||||||
Text="{Binding ThemeHeader}"
|
Text="{Binding ThemeHeader}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.ComponentsSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.ComponentsSettingsPage"
|
||||||
x:DataType="vm:ComponentsSettingsPageViewModel">
|
x:DataType="vm:ComponentsSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<controls:IconText Icon="Apps"
|
<controls:IconText Icon="Apps"
|
||||||
Text="{Binding ComponentsHeader}"
|
Text="{Binding ComponentsHeader}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.GeneralSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.GeneralSettingsPage"
|
||||||
x:DataType="vm:GeneralSettingsPageViewModel">
|
x:DataType="vm:GeneralSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<!-- 区域设置分组 -->
|
<!-- 区域设置分组 -->
|
||||||
<controls:IconText Icon="Globe"
|
<controls:IconText Icon="Globe"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.GeneratedPluginSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.GeneratedPluginSettingsPage"
|
||||||
x:DataType="vm:PluginGeneratedSettingsPageViewModel">
|
x:DataType="vm:PluginGeneratedSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<TextBlock Classes="settings-section-title"
|
<TextBlock Classes="settings-section-title"
|
||||||
Text="{Binding Title}" />
|
Text="{Binding Title}" />
|
||||||
<TextBlock x:Name="DescriptionTextBlock"
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
|
||||||
x:DataType="vm:LauncherSettingsPageViewModel">
|
x:DataType="vm:LauncherSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<Border Classes="settings-section-card">
|
<Border Classes="settings-section-card">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Name="Root"
|
x:Name="Root"
|
||||||
x:DataType="vm:PluginMarketSettingsPageViewModel">
|
x:DataType="vm:PluginMarketSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||||
Description="{Binding StatusMessage}">
|
Description="{Binding StatusMessage}">
|
||||||
<ui:SettingsExpander.IconSource>
|
<ui:SettingsExpander.IconSource>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
x:Name="Root"
|
x:Name="Root"
|
||||||
x:DataType="vm:PluginsSettingsPageViewModel">
|
x:DataType="vm:PluginsSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||||
Description="{Binding StatusMessage}">
|
Description="{Binding StatusMessage}">
|
||||||
<ui:SettingsExpander.IconSource>
|
<ui:SettingsExpander.IconSource>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacySettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacySettingsPage"
|
||||||
x:DataType="vm:PrivacySettingsPageViewModel">
|
x:DataType="vm:PrivacySettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<controls:IconText Icon="Info"
|
<controls:IconText Icon="Info"
|
||||||
Text="{Binding PrivacyHeader}"
|
Text="{Binding PrivacyHeader}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.StatusBarSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.StatusBarSettingsPage"
|
||||||
x:DataType="vm:StatusBarSettingsPageViewModel">
|
x:DataType="vm:StatusBarSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<controls:IconText Icon="Apps"
|
<controls:IconText Icon="Apps"
|
||||||
Text="{Binding ComponentsHeader}"
|
Text="{Binding ComponentsHeader}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<TextBlock Classes="settings-section-title"
|
<TextBlock Classes="settings-section-title"
|
||||||
Text="{Binding PageTitle}" />
|
Text="{Binding PageTitle}" />
|
||||||
<TextBlock Classes="settings-section-description"
|
<TextBlock Classes="settings-section-description"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage"
|
||||||
x:DataType="vm:WallpaperSettingsPageViewModel">
|
x:DataType="vm:WallpaperSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<!-- 预览与颜色选择区域 -->
|
<!-- 预览与颜色选择区域 -->
|
||||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="32" Margin="0,0,0,32">
|
<Grid ColumnDefinitions="*,*" ColumnSpacing="32" Margin="0,0,0,32">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage"
|
||||||
x:DataType="vm:WeatherSettingsPageViewModel">
|
x:DataType="vm:WeatherSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<Border Classes="settings-section-card">
|
<Border Classes="settings-section-card">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
x:Class="LanMountainDesktop.Views.SettingsWindow"
|
x:Class="LanMountainDesktop.Views.SettingsWindow"
|
||||||
x:DataType="vm:SettingsWindowViewModel"
|
x:DataType="vm:SettingsWindowViewModel"
|
||||||
Width="1120"
|
Width="1120"
|
||||||
@@ -36,7 +36,8 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<Grid Classes="settings-scope"
|
<Grid x:Name="RootGrid"
|
||||||
|
Classes="settings-scope"
|
||||||
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
|
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
|
||||||
RowDefinitions="Auto,*">
|
RowDefinitions="Auto,*">
|
||||||
<Border x:Name="WindowTitleBarHost"
|
<Border x:Name="WindowTitleBarHost"
|
||||||
@@ -50,15 +51,14 @@
|
|||||||
ColumnSpacing="8"
|
ColumnSpacing="8"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Button x:Name="TogglePaneButton"
|
<Button x:Name="TogglePaneButton"
|
||||||
Width="40"
|
Classes="pane-toggle-button"
|
||||||
Height="32"
|
|
||||||
Padding="0"
|
|
||||||
Background="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
Click="OnTogglePaneButtonClick">
|
Click="OnTogglePaneButtonClick">
|
||||||
|
<Grid>
|
||||||
<fi:FluentIcon x:Name="TogglePaneButtonIcon"
|
<fi:FluentIcon x:Name="TogglePaneButtonIcon"
|
||||||
Icon="PanelLeftExpand"
|
Icon="Navigation"
|
||||||
IconVariant="Regular" />
|
IconVariant="Regular"
|
||||||
|
FontSize="16" />
|
||||||
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<fi:FluentIcon x:Name="WindowBrandIcon"
|
<fi:FluentIcon x:Name="WindowBrandIcon"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle;
|
private readonly IHostApplicationLifecycle _hostApplicationLifecycle;
|
||||||
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
||||||
private readonly Dictionary<string, Control> _cachedPages = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, Control> _cachedPages = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly bool _useSystemChrome;
|
private bool _useSystemChrome;
|
||||||
private bool _isResponsiveRefreshPending;
|
private bool _isResponsiveRefreshPending;
|
||||||
private bool _isRestartPromptVisible;
|
private bool _isRestartPromptVisible;
|
||||||
|
|
||||||
@@ -152,12 +152,19 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
|
|
||||||
public void ApplyChromeMode(bool useSystemChrome)
|
public void ApplyChromeMode(bool useSystemChrome)
|
||||||
{
|
{
|
||||||
if (useSystemChrome || OperatingSystem.IsMacOS())
|
_useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();
|
||||||
|
|
||||||
|
if (_useSystemChrome)
|
||||||
{
|
{
|
||||||
ExtendClientAreaToDecorationsHint = true;
|
ExtendClientAreaToDecorationsHint = true;
|
||||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
||||||
ExtendClientAreaTitleBarHeightHint = -1;
|
ExtendClientAreaTitleBarHeightHint = -1;
|
||||||
SystemDecorations = SystemDecorations.Full;
|
SystemDecorations = SystemDecorations.Full;
|
||||||
|
|
||||||
|
if (WindowTitleBarHost is { })
|
||||||
|
{
|
||||||
|
WindowTitleBarHost.IsVisible = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +172,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
ExtendClientAreaToDecorationsHint = true;
|
ExtendClientAreaToDecorationsHint = true;
|
||||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
||||||
ExtendClientAreaTitleBarHeightHint = 48;
|
ExtendClientAreaTitleBarHeightHint = 48;
|
||||||
|
|
||||||
|
if (WindowTitleBarHost is { })
|
||||||
|
{
|
||||||
|
WindowTitleBarHost.IsVisible = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshShellText()
|
public void RefreshShellText()
|
||||||
@@ -563,10 +575,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
TogglePaneButtonIcon.Icon = RootNavigationView.IsPaneOpen
|
|
||||||
? FluentIcons.Common.Icon.PanelLeftContract
|
|
||||||
: FluentIcons.Common.Icon.PanelLeftExpand;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateChromeMetrics()
|
private void UpdateChromeMetrics()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
@@ -12,6 +13,8 @@ namespace LanMountainDesktop.Services.PluginMarket;
|
|||||||
|
|
||||||
internal sealed class AirAppMarketInstallService : IDisposable
|
internal sealed class AirAppMarketInstallService : IDisposable
|
||||||
{
|
{
|
||||||
|
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||||
|
|
||||||
private readonly PluginRuntimeService _runtime;
|
private readonly PluginRuntimeService _runtime;
|
||||||
private readonly PluginsInstallHelperClient _helperClient = new();
|
private readonly PluginsInstallHelperClient _helperClient = new();
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
@@ -38,107 +41,226 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(plugin);
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
|
|
||||||
Directory.CreateDirectory(_downloadsDirectory);
|
if (OperatingSystem.IsWindows())
|
||||||
var downloadPath = Path.Combine(
|
|
||||||
_downloadsDirectory,
|
|
||||||
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
AppLogger.Info(
|
var helperPath = ResolveHelperPath();
|
||||||
"PluginMarket",
|
if (!File.Exists(helperPath))
|
||||||
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
|
|
||||||
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
|
|
||||||
AppLogger.Info(
|
|
||||||
"PluginMarket",
|
|
||||||
$"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'.");
|
|
||||||
|
|
||||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
|
||||||
{
|
{
|
||||||
var localCopyResult = await _downloadService.DownloadAsync(
|
|
||||||
localPackagePath,
|
|
||||||
downloadPath,
|
|
||||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
if (!localCopyResult.Success)
|
|
||||||
{
|
|
||||||
return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var downloadResult = await _downloadService.DownloadAsync(
|
|
||||||
resolvedDownloadUrl,
|
|
||||||
downloadPath,
|
|
||||||
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
if (!downloadResult.Success)
|
|
||||||
{
|
|
||||||
return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var actualSize = new FileInfo(downloadPath).Length;
|
|
||||||
string actualHash;
|
|
||||||
await using (var hashStream = File.OpenRead(downloadPath))
|
|
||||||
{
|
|
||||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
|
|
||||||
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
AppLogger.Error(
|
|
||||||
"PluginMarket",
|
|
||||||
$"SHA-256 verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadUrl='{resolvedDownloadUrl}'; DownloadPath='{downloadPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
|
||||||
File.Delete(downloadPath);
|
|
||||||
return new AirAppMarketInstallResult(
|
return new AirAppMarketInstallResult(
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}. Source {resolvedDownloadUrl}.");
|
$"Plugins install helper was not found at '{helperPath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_downloadsDirectory);
|
||||||
|
var sources = plugin.GetPackageSourcesInInstallOrder();
|
||||||
|
if (sources.Count == 0)
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
"Plugin does not declare any package sources.");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'.");
|
||||||
|
|
||||||
|
var sourceErrors = new List<string>();
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
var attemptResult = await TryInstallFromSourceAsync(plugin, source, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (attemptResult.Success)
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(true, attemptResult.Manifest, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptResult.Fatal)
|
||||||
|
{
|
||||||
|
return new AirAppMarketInstallResult(false, null, attemptResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(attemptResult.ErrorMessage))
|
||||||
|
{
|
||||||
|
sourceErrors.Add($"{source.SourceKind}: {attemptResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinedMessage = sourceErrors.Count == 0
|
||||||
|
? $"Failed to install plugin '{plugin.Id}' from all available package sources."
|
||||||
|
: $"Failed to install plugin '{plugin.Id}' from all available package sources. {string.Join(" ", sourceErrors)}";
|
||||||
|
return new AirAppMarketInstallResult(false, null, combinedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AirAppMarketInstallAttemptResult> TryInstallFromSourceAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
AirAppMarketPluginPackageSourceEntry source,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var attemptPath = Path.Combine(
|
||||||
|
_downloadsDirectory,
|
||||||
|
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false);
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Resolved package source for '{plugin.Id}' to '{resolvedDownloadUrl}' using '{source.SourceKind}'.");
|
||||||
|
|
||||||
|
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, attemptPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!acquireResult.Success)
|
||||||
|
{
|
||||||
|
TryDeleteFile(attemptPath);
|
||||||
|
return new AirAppMarketInstallAttemptResult(false, false, null, acquireResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
var verificationResult = await VerifyPackageAsync(plugin, attemptPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!verificationResult.Success)
|
||||||
|
{
|
||||||
|
TryDeleteFile(attemptPath);
|
||||||
|
return new AirAppMarketInstallAttemptResult(false, false, null, verificationResult.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
PluginManifest manifest;
|
PluginManifest manifest;
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
var helperResult = await _helperClient.InstallPackageAsync(
|
var helperResult = await _helperClient.InstallPackageAsync(
|
||||||
downloadPath,
|
attemptPath,
|
||||||
_runtime.PluginsDirectory,
|
_runtime.PluginsDirectory,
|
||||||
cancellationToken);
|
cancellationToken).ConfigureAwait(false);
|
||||||
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
|
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
|
||||||
{
|
{
|
||||||
return new AirAppMarketInstallResult(
|
var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed.";
|
||||||
false,
|
AppLogger.Error(
|
||||||
null,
|
"PluginMarket",
|
||||||
helperResult.ErrorMessage ?? "Plugins install helper failed.");
|
$"Windows install helper failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
|
||||||
|
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
|
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
manifest = _runtime.InstallPluginPackage(downloadPath);
|
manifest = _runtime.InstallPluginPackage(attemptPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginMarket",
|
"PluginMarket",
|
||||||
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
|
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{attemptPath}'; SourceKind='{source.SourceKind}'.");
|
||||||
return new AirAppMarketInstallResult(true, manifest, null);
|
return new AirAppMarketInstallAttemptResult(true, true, manifest, null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
AppLogger.Warn(
|
AppLogger.Warn(
|
||||||
"PluginMarket",
|
"PluginMarket",
|
||||||
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
|
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Error(
|
AppLogger.Error(
|
||||||
"PluginMarket",
|
"PluginMarket",
|
||||||
$"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.",
|
$"Install attempt failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.",
|
||||||
ex);
|
ex);
|
||||||
return new AirAppMarketInstallResult(false, null, ex.Message);
|
TryDeleteFile(attemptPath);
|
||||||
|
return new AirAppMarketInstallAttemptResult(false, false, null, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AirAppMarketAcquisitionResult> AcquirePackageAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
AirAppMarketPluginPackageSourceEntry source,
|
||||||
|
string resolvedDownloadUrl,
|
||||||
|
string attemptPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
|
||||||
|
{
|
||||||
|
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Copying workspace package for '{plugin.Id}' from '{localPackagePath}' to '{attemptPath}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var localCopyResult = await _downloadService.DownloadAsync(
|
||||||
|
localPackagePath,
|
||||||
|
attemptPath,
|
||||||
|
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!localCopyResult.Success)
|
||||||
|
{
|
||||||
|
return new AirAppMarketAcquisitionResult(false, localCopyResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppMarketAcquisitionResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
|
||||||
|
{
|
||||||
|
return new AirAppMarketAcquisitionResult(
|
||||||
|
false,
|
||||||
|
$"Workspace package source '{source.Url}' could not be resolved to a local file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadResult = await _downloadService.DownloadAsync(
|
||||||
|
resolvedDownloadUrl,
|
||||||
|
attemptPath,
|
||||||
|
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!downloadResult.Success)
|
||||||
|
{
|
||||||
|
return new AirAppMarketAcquisitionResult(false, downloadResult.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppMarketAcquisitionResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AirAppMarketVerificationResult> VerifyPackageAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
string attemptPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var actualSize = new FileInfo(attemptPath).Length;
|
||||||
|
string actualHash;
|
||||||
|
await using (var hashStream = File.OpenRead(attemptPath))
|
||||||
|
{
|
||||||
|
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken).ConfigureAwait(false);
|
||||||
|
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualSize != plugin.PackageSizeBytes || !string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
AppLogger.Error(
|
||||||
|
"PluginMarket",
|
||||||
|
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
|
||||||
|
return new AirAppMarketVerificationResult(
|
||||||
|
false,
|
||||||
|
$"Package verification failed. Expected SHA-256 {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AirAppMarketVerificationResult(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveHelperPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteFile(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore cleanup failures for temporary install artifacts.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,4 +274,18 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
var invalidChars = Path.GetInvalidFileNameChars();
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record AirAppMarketInstallAttemptResult(
|
||||||
|
bool Success,
|
||||||
|
bool Fatal,
|
||||||
|
PluginManifest? Manifest,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
private sealed record AirAppMarketAcquisitionResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
private sealed record AirAppMarketVerificationResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,46 @@ internal sealed class AirAppMarketReleaseResolverService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(plugin);
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
|
|
||||||
if (!plugin.HasReleaseDownloadMetadata)
|
var firstSource = plugin.GetPackageSourcesInInstallOrder().FirstOrDefault();
|
||||||
|
if (firstSource is null)
|
||||||
{
|
{
|
||||||
return plugin.DownloadUrl;
|
return plugin.DownloadUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await ResolveDownloadUrlAsync(plugin, firstSource, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ResolveDownloadUrlAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
AirAppMarketPluginPackageSourceEntry source,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
|
||||||
|
return source.SourceKind switch
|
||||||
|
{
|
||||||
|
PluginPackageSourceKind.ReleaseAsset => await ResolveReleaseAssetDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false),
|
||||||
|
PluginPackageSourceKind.RawFallback => source.Url,
|
||||||
|
PluginPackageSourceKind.WorkspaceLocal => source.Url,
|
||||||
|
_ => source.Url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveReleaseAssetDownloadUrlAsync(
|
||||||
|
AirAppMarketPluginEntry plugin,
|
||||||
|
AirAppMarketPluginPackageSourceEntry source,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sourceUrl = source.Url;
|
||||||
|
if (!plugin.HasReleaseDownloadMetadata)
|
||||||
|
{
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
|
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
|
||||||
{
|
{
|
||||||
return plugin.DownloadUrl;
|
return sourceUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
|
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
|
||||||
@@ -46,15 +78,15 @@ internal sealed class AirAppMarketReleaseResolverService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
|
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
|
||||||
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken);
|
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken).ConfigureAwait(false);
|
||||||
var asset = release?.Assets.FirstOrDefault(candidate =>
|
var asset = release?.Assets.FirstOrDefault(candidate =>
|
||||||
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
|
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl;
|
return asset?.BrowserDownloadUrl ?? releaseDownloadUrl;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return plugin.DownloadUrl;
|
return releaseDownloadUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# 阑山桌面 产品说明
|
|
||||||
|
|
||||||
## 1. 目标人群
|
|
||||||
|
|
||||||
- **学生群体**:大学生、研究生、备考人员
|
|
||||||
- **办公用户**:白领、远程工作者
|
|
||||||
- **桌面美化爱好者**:追求个性化桌面布局与视觉体验的用户
|
|
||||||
|
|
||||||
## 2. 使用场景
|
|
||||||
|
|
||||||
| 场景 | 说明 |
|
|
||||||
| ---- | ---------------------- |
|
|
||||||
| 学习辅助 | 查看课程表、记录自习时长、获取每日诗词单词 |
|
|
||||||
| 办公效率 | 查看日历日程、快速访问最近文档、获取新闻资讯 |
|
|
||||||
| 信息聚合 | 桌面一站式查看天气、日历、热搜、新闻 |
|
|
||||||
| 个性美化 | 自由定制桌面组件布局、主题、壁纸 |
|
|
||||||
|
|
||||||
## 3. 解决方案
|
|
||||||
|
|
||||||
**核心方案**:可编排的桌面组件系统 + 插件扩展生态
|
|
||||||
|
|
||||||
- **20+ 内置组件**:时钟、天气、日历、新闻、课程表、计时器等
|
|
||||||
- **网格化布局**:自由拖拽摆放,支持多页桌面
|
|
||||||
- **主题系统**:日夜模式、Monet 取色、玻璃效果
|
|
||||||
- **插件市场**:支持第三方插件扩展功能
|
|
||||||
- **跨平台**:Windows / Linux / macOS
|
|
||||||
|
|
||||||
## 4. 解决的问题
|
|
||||||
|
|
||||||
| 痛点 | 解决方案 |
|
|
||||||
| -------------- | -------------------- |
|
|
||||||
| 信息分散,需打开多个应用 | 桌面聚合展示天气、日历、新闻等信息 |
|
|
||||||
| 桌面单调,缺乏个性化 | 丰富的组件和主题自由定制 |
|
|
||||||
| 学习管理不便 | 课程表、自习监测专为学生设计 |
|
|
||||||
| 功能单一,需安装多个独立应用 | 一个应用整合考试看板、噪音监测等多种功能 |
|
|
||||||
| 功能无法满足个性需求 | 插件系统支持无限扩展 |
|
|
||||||
|
|
||||||
## 5. 产品进度
|
|
||||||
|
|
||||||
- **当前版本**:v0.7.0(插件 API 3.0.0)
|
|
||||||
- **开发状态**:功能开发中,预计 1\~2 个月内发布 v1.0 正式版
|
|
||||||
- **用户统计**:通过 PostHog 收集匿名数据(具体数据需后台查看)
|
|
||||||
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
# 阑山桌面 (LanMountainDesktop) 产品说明文档
|
|
||||||
|
|
||||||
**文档版本**: 1.0
|
|
||||||
**最后更新**: 2026年3月20日
|
|
||||||
**产品版本**: 1.0.0
|
|
||||||
**插件 API 基线**: 3.0.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、产品定位
|
|
||||||
|
|
||||||
### 1.1 一句话介绍
|
|
||||||
|
|
||||||
**阑山桌面是一款可编排的桌面信息与交互空间,让用户能够自由定制个性化桌面,整合信息展示与效率工具于一体。**
|
|
||||||
|
|
||||||
### 1.2 核心定位
|
|
||||||
|
|
||||||
- **产品类型**: 跨平台桌面环境增强工具
|
|
||||||
- **技术架构**: 基于 Avalonia UI 的 .NET 跨平台桌面应用
|
|
||||||
- **支持平台**: Windows、Linux、macOS
|
|
||||||
- **开发语言**: C# (.NET 10)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、目标用户群体
|
|
||||||
|
|
||||||
### 2.1 核心用户画像
|
|
||||||
|
|
||||||
| 用户群体 | 特征描述 | 核心需求 |
|
|
||||||
|---------|---------|---------|
|
|
||||||
| **学生群体** | 大学生、研究生、备考人员 | 课程表管理、自习环境监测、学习计时、每日诗词/单词 |
|
|
||||||
| **办公用户** | 白领、远程工作者、知识工作者 | 日历日程、天气信息、最近文档、资讯获取 |
|
|
||||||
| **效率爱好者** | 工具控、桌面美化爱好者 | 高度自定义、插件扩展、个性化布局 |
|
|
||||||
| **中文用户** | 以中文为母语的用户 | 完整的本地化体验、农历/节假日支持 |
|
|
||||||
|
|
||||||
### 2.2 用户场景分析
|
|
||||||
|
|
||||||
#### 场景一:学生学习桌面
|
|
||||||
> 小张是一名大学生,每天需要查看课程表、记录自习时间、查看天气决定穿衣。阑山桌面的课程表组件帮他管理课表,自习监测组件记录学习时长,天气组件提供实时天气信息,让他在学习时无需切换多个应用。
|
|
||||||
|
|
||||||
#### 场景二:办公效率桌面
|
|
||||||
> 李女士是一名产品经理,需要随时查看日程、关注行业资讯、快速访问最近文档。阑山桌面的日历组件展示日程安排,新闻组件聚合央广网/凤凰网资讯,最近文档组件一键打开工作文件,提升工作效率。
|
|
||||||
|
|
||||||
#### 场景三:个性化展示桌面
|
|
||||||
> 小王是一名桌面美化爱好者,喜欢打造独特的桌面环境。阑山桌面提供丰富的组件库和插件系统,支持自定义布局、主题色、壁纸,让他能够打造独一无二的个性化桌面。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、使用场景
|
|
||||||
|
|
||||||
### 3.1 主要使用场景
|
|
||||||
|
|
||||||
| 场景 | 描述 | 核心组件 |
|
|
||||||
|-----|------|---------|
|
|
||||||
| **学习辅助** | 课程管理、自习监测、学习计时 | 课程表、自习环境监测、计时器、每日诗词/单词 |
|
|
||||||
| **信息聚合** | 一站式获取天气、新闻、日历信息 | 天气、新闻、日历、热搜 |
|
|
||||||
| **效率提升** | 快速访问文档、应用启动、工具使用 | 最近文档、应用启动台、汇率换算、浏览器 |
|
|
||||||
| **桌面美化** | 个性化桌面布局与视觉呈现 | 时钟、天气、每日名画、主题系统 |
|
|
||||||
| **音乐控制** | 桌面音乐播放控制 | 音乐控制组件 |
|
|
||||||
|
|
||||||
### 3.2 典型使用流程
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 安装阑山桌面 → 2. 选择主题与壁纸 → 3. 添加桌面组件 → 4. 自定义布局
|
|
||||||
↓
|
|
||||||
5. 日常使用(查看信息、使用工具)
|
|
||||||
↓
|
|
||||||
6. 按需安装插件扩展功能
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、解决方案
|
|
||||||
|
|
||||||
### 4.1 产品架构
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ 阑山桌面 (LanMountainDesktop) │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ 用户界面层 │ 桌面宿主 │ 组件系统 │ 插件系统 │ 设置中心 │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ 跨平台运行时 (Avalonia UI + .NET 10) │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ Windows │ Linux │ macOS │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 核心功能模块
|
|
||||||
|
|
||||||
#### 4.2.1 桌面组件系统
|
|
||||||
阑山桌面提供丰富的内置组件,涵盖多个类别:
|
|
||||||
|
|
||||||
| 类别 | 组件列表 |
|
|
||||||
|-----|---------|
|
|
||||||
| **时钟类** | 桌面时钟、世界时钟、天气时钟、模拟时钟 |
|
|
||||||
| **天气类** | 天气组件、小时天气、多日天气、扩展天气 |
|
|
||||||
| **日历类** | 月历、农历、节假日日历 |
|
|
||||||
| **信息类** | 每日诗词、每日名画、每日单词、央广网新闻、凤凰网新闻、B站热搜、百度热搜 |
|
|
||||||
| **学习类** | 课程表、自习环境监测、录音、自习时段控制、历史数据 |
|
|
||||||
| **工具类** | 计时器、汇率换算、浏览器、最近文档、可移动存储、音乐控制 |
|
|
||||||
| **白板类** | 竖向小黑板、横向小黑板 |
|
|
||||||
|
|
||||||
#### 4.2.2 插件扩展系统
|
|
||||||
- **插件 API 基线**: 3.0.0
|
|
||||||
- **插件格式**: `.laapp` 插件包
|
|
||||||
- **插件市场**: 官方插件市场 (LanAirApp)
|
|
||||||
- **开发支持**: 完整的 PluginSdk 和开发文档
|
|
||||||
|
|
||||||
#### 4.2.3 主题与个性化
|
|
||||||
- **主题系统**: 支持日夜模式切换
|
|
||||||
- **主题色**: 支持 Monet 取色和自定义主题色
|
|
||||||
- **玻璃效果**: 多层级玻璃视觉效果
|
|
||||||
- **壁纸系统**: 支持图片壁纸和动态效果
|
|
||||||
|
|
||||||
#### 4.2.4 布局系统
|
|
||||||
- **网格化布局**: 支持多页桌面
|
|
||||||
- **自由拖拽**: 组件可自由摆放
|
|
||||||
- **尺寸自适应**: 组件支持多种尺寸规格
|
|
||||||
|
|
||||||
### 4.3 技术亮点
|
|
||||||
|
|
||||||
| 特性 | 说明 |
|
|
||||||
|-----|------|
|
|
||||||
| **跨平台** | 基于 Avalonia UI,支持 Windows/Linux/macOS |
|
|
||||||
| **现代化 UI** | Fluent Design + Material Design 融合 |
|
|
||||||
| **插件化架构** | 支持第三方插件扩展,API 基线 3.0.0 |
|
|
||||||
| **数据安全** | 本地 SQLite 存储,隐私数据不上传 |
|
|
||||||
| **性能优化** | 组件懒加载、资源按需加载 |
|
|
||||||
| **无障碍支持** | 对比度优化、语义化界面 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、解决的问题
|
|
||||||
|
|
||||||
### 5.1 用户痛点
|
|
||||||
|
|
||||||
| 痛点 | 阑山桌面解决方案 |
|
|
||||||
|-----|----------------|
|
|
||||||
| **信息分散** | 整合天气、日历、新闻等信息于桌面 |
|
|
||||||
| **桌面单调** | 丰富的组件和主题让桌面个性化 |
|
|
||||||
| **效率低下** | 常用工具和信息一触即达 |
|
|
||||||
| **学习管理难** | 课程表、自习监测专为学生设计 |
|
|
||||||
| **功能不足** | 插件系统支持无限扩展 |
|
|
||||||
|
|
||||||
### 5.2 竞品差异化
|
|
||||||
|
|
||||||
| 对比维度 | 传统桌面工具 | 阑山桌面 |
|
|
||||||
|---------|-------------|---------|
|
|
||||||
| **组件丰富度** | 有限组件 | 20+ 内置组件 + 插件扩展 |
|
|
||||||
| **定制化** | 固定布局 | 自由拖拽、网格化布局 |
|
|
||||||
| **跨平台** | 单一平台 | Windows/Linux/macOS |
|
|
||||||
| **插件生态** | 不支持 | 完整插件 SDK 和市场 |
|
|
||||||
| **本地化** | 一般 | 完整中文本地化 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、用户量与数据统计
|
|
||||||
|
|
||||||
### 6.1 数据收集说明
|
|
||||||
|
|
||||||
根据隐私政策,阑山桌面收集以下匿名数据用于统计:
|
|
||||||
|
|
||||||
- ✅ **应用启动事件**: 用于统计日活跃用户
|
|
||||||
- ✅ **设备标识符**: 匿名生成,用于区分用户(不含个人信息)
|
|
||||||
- ✅ **应用版本**: 用于统计版本分布
|
|
||||||
- ✅ **崩溃报告**: 用于提升应用稳定性(可选)
|
|
||||||
- ✅ **使用统计**: 用于功能优化(可选)
|
|
||||||
|
|
||||||
### 6.2 隐私承诺
|
|
||||||
|
|
||||||
- ❌ 不收集个人身份信息(姓名、邮箱、电话等)
|
|
||||||
- ❌ 不收集地理位置
|
|
||||||
- ❌ 不收集文件内容
|
|
||||||
- ❌ 不出售用户数据
|
|
||||||
- ❌ 不用于广告目的
|
|
||||||
|
|
||||||
### 6.3 当前状态
|
|
||||||
|
|
||||||
**当前版本**: 1.0.0
|
|
||||||
**插件 API 基线**: 3.0.0
|
|
||||||
**数据收集服务**: PostHog(用户分析)、Sentry(崩溃报告)
|
|
||||||
|
|
||||||
> **注**: 具体用户量数据需从 PostHog 后台获取,此处未展示具体数字。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、产品开发进度
|
|
||||||
|
|
||||||
### 7.1 当前开发状态
|
|
||||||
|
|
||||||
| 模块 | 状态 | 说明 |
|
|
||||||
|-----|------|------|
|
|
||||||
| **核心桌面功能** | ✅ 已完成 | 网格布局、组件系统、主题系统 |
|
|
||||||
| **内置组件** | ✅ 已完成 | 20+ 组件已上线 |
|
|
||||||
| **插件系统** | ✅ 已完成 | API 3.0.0 已稳定 |
|
|
||||||
| **插件市场** | ✅ 已完成 | 官方市场已运营 |
|
|
||||||
| **多平台支持** | ✅ 已完成 | Windows/Linux/macOS |
|
|
||||||
| **自动更新** | ✅ 已完成 | 内置更新系统 |
|
|
||||||
| **应用启动台** | ✅ 已完成 | Windows 开始菜单集成 |
|
|
||||||
|
|
||||||
### 7.2 版本里程碑
|
|
||||||
|
|
||||||
| 版本 | 目标 | 状态 |
|
|
||||||
|-----|------|------|
|
|
||||||
| v1.0.0 | 核心功能完整、插件系统稳定 | ✅ 已发布 |
|
|
||||||
| v1.x.x | 组件扩展、性能优化 | 🔄 进行中 |
|
|
||||||
| v2.0.0 | 重大功能升级(规划中) | 📋 规划中 |
|
|
||||||
|
|
||||||
### 7.3 近期开发计划
|
|
||||||
|
|
||||||
根据 `.trae/specs` 中的规格文档,近期开发任务包括:
|
|
||||||
|
|
||||||
1. **设置页面 Fluent 重设计** - 提升设置界面体验
|
|
||||||
2. **课程表功能增强** - 增加更多课程管理功能
|
|
||||||
3. **视频壁纸功能移除** - 优化产品定位
|
|
||||||
|
|
||||||
### 7.4 生态建设
|
|
||||||
|
|
||||||
| 项目 | 状态 | 说明 |
|
|
||||||
|-----|------|------|
|
|
||||||
| **LanMountainDesktop** | ✅ 主仓库 | 桌面宿主、插件运行时 |
|
|
||||||
| **LanAirApp** | ✅ 独立仓库 | 插件市场、开发文档 |
|
|
||||||
| **SamplePlugin** | ✅ 独立仓库 | 权威示例插件 |
|
|
||||||
| **PluginSdk** | ✅ 已发布 | 插件开发 SDK |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、产品优势总结
|
|
||||||
|
|
||||||
### 8.1 核心价值
|
|
||||||
|
|
||||||
1. **个性化桌面**: 自由定制组件布局,打造专属桌面空间
|
|
||||||
2. **信息聚合**: 一站式获取天气、日历、新闻等实用信息
|
|
||||||
3. **效率提升**: 常用工具触手可及,减少应用切换
|
|
||||||
4. **学习辅助**: 专为学生群体设计的课程表、自习监测功能
|
|
||||||
5. **无限扩展**: 插件系统支持功能无限扩展
|
|
||||||
|
|
||||||
### 8.2 技术保障
|
|
||||||
|
|
||||||
- 跨平台架构,一次开发多端运行
|
|
||||||
- 现代化 UI 框架,流畅的用户体验
|
|
||||||
- 严格的隐私保护,数据安全有保障
|
|
||||||
- 完善的插件生态,功能持续扩展
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、联系我们
|
|
||||||
|
|
||||||
- **GitHub**: https://github.com/wwiinnddyy/LanMountainDesktop
|
|
||||||
- **Issues**: https://github.com/wwiinnddyy/LanMountainDesktop/issues
|
|
||||||
- **插件市场**: LanAirApp 官方市场
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**阑山桌面,让你的桌面更有温度。**
|
|
||||||
152
README.md
152
README.md
@@ -1,53 +1,133 @@
|
|||||||
# LanMountainDesktop
|
# 阑山桌面 / LanMountainDesktop
|
||||||
|
|
||||||
`LanMountainDesktop` is the authoritative host repository for the desktop app and the host-side Plugin SDK.
|
> 你的桌面,不止一面
|
||||||
|
|
||||||
## Repository Ownership
|
[](https://dotnet.microsoft.com/)
|
||||||
|
[](https://avaloniaui.net/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
This repository owns:
|
> [!IMPORTANT]
|
||||||
|
> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。
|
||||||
|
>
|
||||||
|
> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。
|
||||||
|
|
||||||
- `LanMountainDesktop/`: desktop host app and plugin runtime
|
## 简介
|
||||||
- `LanMountainDesktop.PluginSdk/`: canonical plugin API baseline (`4.0.0`)
|
|
||||||
- `LanMountainDesktop.Shared.Contracts/`: shared host/plugin contract types
|
|
||||||
- `LanMountainDesktop.Appearance/`: host appearance and radius token generation
|
|
||||||
- `LanMountainDesktop.Settings.Core/`: host settings primitives
|
|
||||||
- `LanMountainDesktop.Tests/`: host and SDK tests
|
|
||||||
|
|
||||||
This repository does not own:
|
**阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
|
||||||
|
|
||||||
- plugin market metadata or developer portal content
|
基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
|
||||||
- official sample plugin release source
|
|
||||||
- independent ecosystem documentation hub
|
|
||||||
|
|
||||||
## Ecosystem Boundaries
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
- Host and SDK source of truth: `LanMountainDesktop` (this repo)
|
## 核心特性
|
||||||
- Plugin market and developer materials: standalone `LanAirApp` repo
|
|
||||||
- Official sample plugin source of truth: standalone `LanMountainDesktop.SamplePlugin` repo
|
|
||||||
- `ClassIsland`: reference-only project, not part of build or release flow
|
|
||||||
|
|
||||||
## Plugin SDK v4 Baseline
|
### 📊 信息聚合
|
||||||
|
- 课程表、日历、天气、新闻、热搜
|
||||||
|
- 所有信息一目了然,无需频繁切换窗口
|
||||||
|
|
||||||
- API baseline: `4.0.0`
|
### 🎯 效率工具
|
||||||
- Manifest file: `plugin.json`
|
- 自习环境监测、计时器、知识卡片
|
||||||
- Package extension: `.laapp`
|
- 最近文档、浏览器快捷入口
|
||||||
- Entry model: `Initialize(HostBuilderContext, IServiceCollection)`
|
- 常用工具组件一键触达
|
||||||
- Appearance model: `IPluginAppearanceContext`, `PluginAppearanceSnapshot`, `PluginCornerRadiusTokens`, `PluginCornerRadiusPreset`
|
|
||||||
- Component registration model: `AddPluginDesktopComponent<TControl>(PluginDesktopComponentOptions options)`
|
|
||||||
|
|
||||||
## Plugin Package Surfaces
|
### 🎨 个性化桌面
|
||||||
|
- 自由布局,随心所欲摆放组件
|
||||||
|
- 多页桌面,工作学习场景分离
|
||||||
|
- 主题切换、玻璃效果、圆角风格
|
||||||
|
|
||||||
- `LanMountainDesktop.PluginSdk`: official plugin SDK package (includes `buildTransitive` default `.laapp` packaging targets)
|
### 🔌 插件生态
|
||||||
- `LanMountainDesktop.Shared.Contracts`: shared contract package for host/plugin boundaries
|
- 通过 `.laapp` 插件扩展功能
|
||||||
- `LanMountainDesktop.PluginTemplate`: official `dotnet new` template package (`shortName`: `lmd-plugin`)
|
- 官方 Plugin SDK 支持自定义组件
|
||||||
|
- 设置页、组件、集成功能一站式接入
|
||||||
|
|
||||||
Use `scripts/Pack-PluginPackages.ps1` to generate local-feed packages for CI or workspace integration tests.
|
## 为谁而设计
|
||||||
|
|
||||||
## Workspace Market Resolution
|
| 用户类型 | 典型场景 |
|
||||||
|
|---------|---------|
|
||||||
|
| 🎓 学生用户 | 课程表、自习监测、计时、天气和日常信息聚合 |
|
||||||
|
| 💼 办公用户 | 日历、资讯、最近文档、常用工具入口 |
|
||||||
|
| 🎨 效率爱好者 | 自由布局、主题切换、插件扩展 |
|
||||||
|
| 🇨🇳 中文用户 | 本地化界面、农历和节假日等本地语境支持 |
|
||||||
|
|
||||||
For local market debugging, the host resolves workspace files from the sibling repository path (`..\\LanAirApp`) instead of reading the in-repo mirror folder.
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- .NET SDK 10
|
||||||
|
|
||||||
|
### 构建与运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 还原依赖
|
||||||
|
dotnet restore
|
||||||
|
|
||||||
|
# 构建项目
|
||||||
|
dotnet build LanMountainDesktop.slnx -c Debug
|
||||||
|
|
||||||
|
# 运行桌面宿主
|
||||||
|
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test LanMountainDesktop.slnx -c Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## 插件开发
|
||||||
|
|
||||||
|
阑山桌面支持通过 Plugin SDK 开发自定义插件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装插件模板
|
||||||
|
dotnet new install LanMountainDesktop.PluginTemplate
|
||||||
|
|
||||||
|
# 创建新插件
|
||||||
|
dotnet new lmd-plugin -n MyPlugin
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
|
||||||
|
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
|
||||||
|
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
LanMountainDesktop/
|
||||||
|
├── LanMountainDesktop/ # 桌面宿主应用
|
||||||
|
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
|
||||||
|
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
|
||||||
|
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施
|
||||||
|
├── LanMountainDesktop.Settings.Core/# 设置持久化基础设施
|
||||||
|
└── LanMountainDesktop.Tests/ # 测试项目
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生态边界
|
||||||
|
|
||||||
|
| 项目 | 职责 |
|
||||||
|
|-----|------|
|
||||||
|
| **本仓库** | 桌面宿主、插件运行时、Plugin SDK、共享契约 |
|
||||||
|
| [LanAirApp](https://github.com/yourorg/LanAirApp) | 插件市场元数据、开发者生态材料 |
|
||||||
|
| [LanMountainDesktop.SamplePlugin](https://github.com/yourorg/LanMountainDesktop.SamplePlugin) | 官方示例插件 |
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
- [产品定位](docs/PRODUCT.md) - 产品愿景与目标用户
|
||||||
|
- [架构说明](docs/ARCHITECTURE.md) - 仓库结构与运行时主线
|
||||||
|
- [开发指南](docs/DEVELOPMENT.md) - 构建、测试、调试
|
||||||
|
- [视觉规范](docs/VISUAL_SPEC.md) - 主题、颜色、玻璃层级
|
||||||
|
- [圆角规范](docs/CORNER_RADIUS_SPEC.md) - 圆角层级与动态规则
|
||||||
|
- [贡献指南](docs/CONTRIBUTING.md) - PR、spec、文档协作规则
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **UI 框架**: [Avalonia UI](https://avaloniaui.net/)
|
||||||
|
- **开发平台**: [.NET 10](https://dotnet.microsoft.com/)
|
||||||
|
- **支持平台**: Windows 10+, Linux, macOS
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|
||||||
See:
|
|
||||||
|
|
||||||
- `docs/ECOSYSTEM_BOUNDARIES.md`
|
|
||||||
- `docs/PLUGIN_SDK_V4_MIGRATION.md`
|
|
||||||
|
|||||||
18
VoiceHubLanDesktop/Localization/en-US.json
Normal file
18
VoiceHubLanDesktop/Localization/en-US.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"widget.display_name": "Radio Station Schedule",
|
||||||
|
"widget.category": "Info",
|
||||||
|
"widget.loading": "Loading schedule...",
|
||||||
|
"widget.retry": "Retry",
|
||||||
|
"widget.no_schedule": "No schedule data",
|
||||||
|
"widget.network_error": "Network error",
|
||||||
|
"settings.title": "VoiceHub Settings",
|
||||||
|
"settings.description": "Configure radio station schedule data source and display options",
|
||||||
|
"settings.apiUrl.title": "API URL",
|
||||||
|
"settings.apiUrl.description": "VoiceHub backend API URL for fetching schedule data",
|
||||||
|
"settings.showRequester.title": "Show Requester",
|
||||||
|
"settings.showRequester.description": "Display requester information in the schedule list",
|
||||||
|
"settings.showVoteCount.title": "Show Vote Count",
|
||||||
|
"settings.showVoteCount.description": "Display song vote count in the schedule list",
|
||||||
|
"settings.refreshInterval.title": "Refresh Interval",
|
||||||
|
"settings.refreshInterval.description": "Time interval for automatic schedule data refresh"
|
||||||
|
}
|
||||||
18
VoiceHubLanDesktop/Localization/zh-CN.json
Normal file
18
VoiceHubLanDesktop/Localization/zh-CN.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"widget.display_name": "广播站排期",
|
||||||
|
"widget.category": "信息",
|
||||||
|
"widget.loading": "正在加载排期...",
|
||||||
|
"widget.retry": "重试",
|
||||||
|
"widget.no_schedule": "暂无排期数据",
|
||||||
|
"widget.network_error": "网络错误",
|
||||||
|
"settings.title": "VoiceHub 设置",
|
||||||
|
"settings.description": "配置广播站排期数据源和显示选项",
|
||||||
|
"settings.apiUrl.title": "API 地址",
|
||||||
|
"settings.apiUrl.description": "VoiceHub 后端 API 地址,用于获取排期数据",
|
||||||
|
"settings.showRequester.title": "显示点歌人",
|
||||||
|
"settings.showRequester.description": "在排期列表中显示点歌人信息",
|
||||||
|
"settings.showVoteCount.title": "显示投票数",
|
||||||
|
"settings.showVoteCount.description": "在排期列表中显示歌曲投票数",
|
||||||
|
"settings.refreshInterval.title": "刷新间隔",
|
||||||
|
"settings.refreshInterval.description": "自动刷新排期数据的时间间隔"
|
||||||
|
}
|
||||||
27
VoiceHubLanDesktop/Models/PluginSettings.cs
Normal file
27
VoiceHubLanDesktop/Models/PluginSettings.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件设置
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// API 地址
|
||||||
|
/// </summary>
|
||||||
|
public string ApiUrl { get; set; } = "https://voicehub.lao-shui.top/api/songs/public";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否显示点歌人
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowRequester { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否显示投票数
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowVoteCount { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新间隔(分钟)
|
||||||
|
/// </summary>
|
||||||
|
public int RefreshIntervalMinutes { get; set; } = 60;
|
||||||
|
}
|
||||||
113
VoiceHubLanDesktop/Models/SongModels.cs
Normal file
113
VoiceHubLanDesktop/Models/SongModels.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 歌曲信息
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Song
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 歌曲标题
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 艺术家/歌手
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("artist")]
|
||||||
|
public string Artist { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 点歌人
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("requester")]
|
||||||
|
public string Requester { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 投票数/热度
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("voteCount")]
|
||||||
|
public int VoteCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期歌曲项目
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SongItem
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 播放日期 (yyyy-MM-dd)
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("playDate")]
|
||||||
|
public string PlayDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 播放序号
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("sequence")]
|
||||||
|
public int Sequence { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 歌曲信息
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("song")]
|
||||||
|
public Song Song { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取播放日期
|
||||||
|
/// </summary>
|
||||||
|
public DateTime GetPlayDate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PlayDate))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
|
||||||
|
System.Globalization.DateTimeStyles.None, out var result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 组件状态
|
||||||
|
/// </summary>
|
||||||
|
public enum ComponentState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 加载中
|
||||||
|
/// </summary>
|
||||||
|
Loading,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 正常显示
|
||||||
|
/// </summary>
|
||||||
|
Normal,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 网络错误
|
||||||
|
/// </summary>
|
||||||
|
NetworkError,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 暂无排期
|
||||||
|
/// </summary>
|
||||||
|
NoSchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 显示数据
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DisplayData
|
||||||
|
{
|
||||||
|
public ComponentState State { get; set; }
|
||||||
|
public IReadOnlyList<SongItem> Songs { get; set; } = [];
|
||||||
|
public DateTime? DisplayDate { get; set; }
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
62
VoiceHubLanDesktop/README.md
Normal file
62
VoiceHubLanDesktop/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# VoiceHubLanDesktop
|
||||||
|
|
||||||
|
VoiceHub 广播站排期插件,用于 LanMountainDesktop 桌面应用。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 📻 **排期显示**:展示 VoiceHub 广播站当日排期歌曲
|
||||||
|
- 🔄 **自动刷新**:支持自定义刷新间隔(5分钟 ~ 2小时)
|
||||||
|
- ⚙️ **灵活配置**:可自定义 API 地址、显示选项
|
||||||
|
- 🌐 **多语言支持**:支持中文和英文
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
将 `.laapp` 包放入 LanMountainDesktop 的插件目录:
|
||||||
|
```
|
||||||
|
%LocalAppData%\LanMountainDesktop\Extensions\Plugins\
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
在 LanMountainDesktop 设置中找到 "VoiceHub 设置":
|
||||||
|
|
||||||
|
| 选项 | 说明 | 默认值 |
|
||||||
|
|-----|------|--------|
|
||||||
|
| API 地址 | VoiceHub 后端 API 地址 | `https://voicehub.lao-shui.top/api/songs/public` |
|
||||||
|
| 显示点歌人 | 是否显示点歌人信息 | 是 |
|
||||||
|
| 显示投票数 | 是否显示歌曲投票数 | 否 |
|
||||||
|
| 刷新间隔 | 自动刷新时间间隔 | 1小时 |
|
||||||
|
|
||||||
|
## 组件规格
|
||||||
|
|
||||||
|
- **最小尺寸**:3 × 4 网格
|
||||||
|
- **缩放模式**:等比例缩放
|
||||||
|
- **放置位置**:桌面
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd VoiceHubLanDesktop
|
||||||
|
dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet pack
|
||||||
|
# 或使用脚本
|
||||||
|
../scripts/Pack-PluginPackages.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- .NET 10
|
||||||
|
- Avalonia UI 11.3.12
|
||||||
|
- LanMountainDesktop.PluginSdk 4.0.0
|
||||||
|
- CommunityToolkit.Mvvm 8.2.1
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
113
VoiceHubLanDesktop/Services/VoiceHubApiService.cs
Normal file
113
VoiceHubLanDesktop/Services/VoiceHubApiService.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VoiceHub API 服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubApiService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
|
||||||
|
private const string DefaultApiUrl = "https://voicehub.lao-shui.top/api/songs/public";
|
||||||
|
private const int MaxRetryCount = 3;
|
||||||
|
private readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
public VoiceHubApiService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取公开排期数据
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ApiResult<IReadOnlyList<SongItem>>> GetPublicScheduleAsync(
|
||||||
|
string? apiUrl = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var url = string.IsNullOrWhiteSpace(apiUrl) ? DefaultApiUrl : apiUrl.Trim();
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < MaxRetryCount; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(_requestTimeout);
|
||||||
|
|
||||||
|
var jsonResponse = await _httpClient.GetStringAsync(url, cts.Token);
|
||||||
|
var items = JsonSerializer.Deserialize<List<SongItem>>(jsonResponse, _jsonOptions);
|
||||||
|
|
||||||
|
if (items is null)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("数据解析失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Success(items);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
if (attempt == MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"网络错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
if (attempt == MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("请求超时");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"数据格式错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"未知错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指数退避
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("获取数据失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API 结果
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiResult<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public T? Data { get; }
|
||||||
|
public string? ErrorMessage { get; }
|
||||||
|
|
||||||
|
private ApiResult(bool isSuccess, T? data, string? errorMessage)
|
||||||
|
{
|
||||||
|
IsSuccess = isSuccess;
|
||||||
|
Data = data;
|
||||||
|
ErrorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApiResult<T> Success(T data) => new(true, data, null);
|
||||||
|
public static ApiResult<T> Failure(string errorMessage) => new(false, default, errorMessage);
|
||||||
|
}
|
||||||
164
VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs
Normal file
164
VoiceHubLanDesktop/Services/VoiceHubScheduleService.cs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
using VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期管理服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubScheduleService
|
||||||
|
{
|
||||||
|
private readonly VoiceHubApiService _apiService;
|
||||||
|
private readonly VoiceHubSettingsService _settingsService;
|
||||||
|
private IReadOnlyList<SongItem> _cachedSchedule = [];
|
||||||
|
private DateTime _cacheTime = DateTime.MinValue;
|
||||||
|
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public event EventHandler<ScheduleUpdatedEventArgs>? ScheduleUpdated;
|
||||||
|
|
||||||
|
public VoiceHubScheduleService(VoiceHubApiService apiService, VoiceHubSettingsService settingsService)
|
||||||
|
{
|
||||||
|
_apiService = apiService;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取今日排期
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DisplayData> GetTodayScheduleAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = _settingsService.GetSettings();
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
if (_cachedSchedule.Count > 0 && DateTime.Now - _cacheTime < _cacheExpiry)
|
||||||
|
{
|
||||||
|
return BuildDisplayData(_cachedSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 API 获取
|
||||||
|
var result = await _apiService.GetPublicScheduleAsync(settings.ApiUrl, cancellationToken);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NetworkError,
|
||||||
|
ErrorMessage = result.ErrorMessage ?? "获取排期失败"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = result.Data ?? [];
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
_cachedSchedule = items;
|
||||||
|
_cacheTime = DateTime.Now;
|
||||||
|
|
||||||
|
return BuildDisplayData(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 强制刷新
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DisplayData> RefreshAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_cachedSchedule = [];
|
||||||
|
_cacheTime = DateTime.MinValue;
|
||||||
|
return await GetTodayScheduleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清除缓存
|
||||||
|
/// </summary>
|
||||||
|
public void ClearCache()
|
||||||
|
{
|
||||||
|
_cachedSchedule = [];
|
||||||
|
_cacheTime = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayData BuildDisplayData(IReadOnlyList<SongItem> items)
|
||||||
|
{
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤有效日期
|
||||||
|
var validItems = items.Where(s => s.GetPlayDate() != DateTime.MinValue).ToList();
|
||||||
|
|
||||||
|
if (validItems.Count == 0)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无有效排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到今天或最近未来的排期
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var todaySchedule = validItems
|
||||||
|
.Where(s => s.GetPlayDate() == today)
|
||||||
|
.OrderBy(s => s.Sequence)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
List<SongItem> displayItems;
|
||||||
|
DateTime actualDate;
|
||||||
|
|
||||||
|
if (todaySchedule.Count > 0)
|
||||||
|
{
|
||||||
|
displayItems = todaySchedule;
|
||||||
|
actualDate = today;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 找最近的未来排期
|
||||||
|
var futureSchedule = validItems
|
||||||
|
.Where(s => s.GetPlayDate() > today)
|
||||||
|
.GroupBy(s => s.GetPlayDate())
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (futureSchedule != null)
|
||||||
|
{
|
||||||
|
displayItems = futureSchedule.OrderBy(s => s.Sequence).ToList();
|
||||||
|
actualDate = futureSchedule.Key;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发更新事件
|
||||||
|
ScheduleUpdated?.Invoke(this, new ScheduleUpdatedEventArgs(displayItems, actualDate));
|
||||||
|
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.Normal,
|
||||||
|
Songs = displayItems,
|
||||||
|
DisplayDate = actualDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期更新事件参数
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScheduleUpdatedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public IReadOnlyList<SongItem> Songs { get; }
|
||||||
|
public DateTime DisplayDate { get; }
|
||||||
|
|
||||||
|
public ScheduleUpdatedEventArgs(IReadOnlyList<SongItem> songs, DateTime displayDate)
|
||||||
|
{
|
||||||
|
Songs = songs;
|
||||||
|
DisplayDate = displayDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs
Normal file
97
VoiceHubLanDesktop/Services/VoiceHubSettingsService.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using VoiceHubLanDesktop.Models;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件设置服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubSettingsService
|
||||||
|
{
|
||||||
|
private readonly IPluginSettingsService _settingsService;
|
||||||
|
private const string SettingsSectionId = "voicehub-settings";
|
||||||
|
private PluginSettings? _cachedSettings;
|
||||||
|
|
||||||
|
public event EventHandler<PluginSettings>? SettingsChanged;
|
||||||
|
|
||||||
|
public VoiceHubSettingsService(IPluginSettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取设置
|
||||||
|
/// </summary>
|
||||||
|
public PluginSettings GetSettings()
|
||||||
|
{
|
||||||
|
if (_cachedSettings != null)
|
||||||
|
{
|
||||||
|
return _cachedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = new PluginSettings();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var apiUrl = _settingsService.GetValue<string>(SettingsScope.Plugin, "apiUrl", SettingsSectionId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiUrl))
|
||||||
|
{
|
||||||
|
settings.ApiUrl = apiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
var showRequester = _settingsService.GetValue<bool?>(SettingsScope.Plugin, "showRequester", SettingsSectionId);
|
||||||
|
if (showRequester.HasValue)
|
||||||
|
{
|
||||||
|
settings.ShowRequester = showRequester.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var showVoteCount = _settingsService.GetValue<bool?>(SettingsScope.Plugin, "showVoteCount", SettingsSectionId);
|
||||||
|
if (showVoteCount.HasValue)
|
||||||
|
{
|
||||||
|
settings.ShowVoteCount = showVoteCount.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshInterval = _settingsService.GetValue<string>(SettingsScope.Plugin, "refreshInterval", SettingsSectionId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(refreshInterval) && int.TryParse(refreshInterval, out var minutes))
|
||||||
|
{
|
||||||
|
settings.RefreshIntervalMinutes = minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 使用默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
_cachedSettings = settings;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存设置
|
||||||
|
/// </summary>
|
||||||
|
public void SaveSettings(PluginSettings settings)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_settingsService.SetValue(SettingsScope.Plugin, "apiUrl", settings.ApiUrl, sectionId: SettingsSectionId);
|
||||||
|
_settingsService.SetValue(SettingsScope.Plugin, "showRequester", settings.ShowRequester, sectionId: SettingsSectionId);
|
||||||
|
_settingsService.SetValue(SettingsScope.Plugin, "showVoteCount", settings.ShowVoteCount, sectionId: SettingsSectionId);
|
||||||
|
_settingsService.SetValue(SettingsScope.Plugin, "refreshInterval", settings.RefreshIntervalMinutes.ToString(), sectionId: SettingsSectionId);
|
||||||
|
|
||||||
|
_cachedSettings = settings;
|
||||||
|
SettingsChanged?.Invoke(this, settings);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略保存错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清除缓存
|
||||||
|
/// </summary>
|
||||||
|
public void ClearCache()
|
||||||
|
{
|
||||||
|
_cachedSettings = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
VoiceHubLanDesktop/SongModels.cs
Normal file
52
VoiceHubLanDesktop/SongModels.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 歌曲信息
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Song
|
||||||
|
{
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("artist")]
|
||||||
|
public string Artist { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("requester")]
|
||||||
|
public string Requester { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("voteCount")]
|
||||||
|
public int VoteCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期歌曲项目
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SongItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("playDate")]
|
||||||
|
public string PlayDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("sequence")]
|
||||||
|
public int Sequence { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("song")]
|
||||||
|
public Song Song { get; set; } = new();
|
||||||
|
|
||||||
|
public DateTime GetPlayDate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PlayDate))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
|
||||||
|
System.Globalization.DateTimeStyles.None, out var result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml
Normal file
144
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<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="300" d:DesignHeight="400"
|
||||||
|
x:Class="VoiceHubLanDesktop.Views.VoiceHubScheduleControl"
|
||||||
|
x:DataType="VoiceHubLanDesktop.Views.VoiceHubScheduleControl">
|
||||||
|
|
||||||
|
<Design.DataContext>
|
||||||
|
<VoiceHubLanDesktop.Views.VoiceHubScheduleControl/>
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
Padding="12,8"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="16"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}"/>
|
||||||
|
<TextBlock Text="{Binding TitleText}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
FontSize="14"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding DateText}"
|
||||||
|
FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
|
||||||
|
Margin="8,0,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<Grid Grid.Row="1">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<StackPanel x:Name="LoadingPanel"
|
||||||
|
Orientation="Vertical"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="12"
|
||||||
|
IsVisible="{Binding IsLoading}">
|
||||||
|
<ProgressBar IsIndeterminate="True"
|
||||||
|
Width="100"
|
||||||
|
Height="4"/>
|
||||||
|
<TextBlock Text="正在加载排期..."
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 排期列表 -->
|
||||||
|
<ScrollViewer x:Name="SchedulePanel"
|
||||||
|
IsVisible="{Binding IsNormal}"
|
||||||
|
Padding="8,8,8,8">
|
||||||
|
<ItemsControl ItemsSource="{Binding Songs}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12,10"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<!-- 序号 -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Background="{DynamicResource SystemAccentColor}"
|
||||||
|
CornerRadius="12"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Sequence}"
|
||||||
|
FontSize="11"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="White"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 歌曲信息 -->
|
||||||
|
<StackPanel Grid.Column="1" Spacing="4">
|
||||||
|
<TextBlock Text="{Binding Song.Title}"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="Medium"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1"/>
|
||||||
|
<TextBlock FontSize="12"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1">
|
||||||
|
<Run Text="{Binding Song.Artist}"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<StackPanel x:Name="EmptyPanel"
|
||||||
|
Orientation="Vertical"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8"
|
||||||
|
IsVisible="{Binding IsEmpty}">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="48"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"/>
|
||||||
|
<TextBlock Text="{Binding EmptyMessage}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<StackPanel x:Name="ErrorPanel"
|
||||||
|
Orientation="Vertical"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8"
|
||||||
|
IsVisible="{Binding IsError}">
|
||||||
|
<TextBlock Text=""
|
||||||
|
FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="48"
|
||||||
|
Foreground="#FFB00020"/>
|
||||||
|
<TextBlock Text="{Binding ErrorMessage}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="#FFB00020"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MaxWidth="200"
|
||||||
|
TextAlignment="Center"/>
|
||||||
|
<Button Content="重试"
|
||||||
|
Command="{Binding RetryCommand}"
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
168
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs
Normal file
168
VoiceHubLanDesktop/Views/VoiceHubScheduleControl.axaml.cs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using VoiceHubLanDesktop.Models;
|
||||||
|
using VoiceHubLanDesktop.Services;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 广播站排期显示组件
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class VoiceHubScheduleControl : UserControl
|
||||||
|
{
|
||||||
|
private readonly VoiceHubScheduleService _scheduleService;
|
||||||
|
private readonly VoiceHubSettingsService _settingsService;
|
||||||
|
private readonly DispatcherTimer? _refreshTimer;
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
|
public ObservableCollection<SongItem> Songs { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty] private string _titleText = "广播站排期";
|
||||||
|
[ObservableProperty] private string _dateText = "";
|
||||||
|
[ObservableProperty] private string _emptyMessage = "暂无排期数据";
|
||||||
|
[ObservableProperty] private string _errorMessage = "";
|
||||||
|
[ObservableProperty] private bool _isLoading = true;
|
||||||
|
[ObservableProperty] private bool _isNormal = false;
|
||||||
|
[ObservableProperty] private bool _isEmpty = false;
|
||||||
|
[ObservableProperty] private bool _isError = false;
|
||||||
|
|
||||||
|
public VoiceHubScheduleControl(
|
||||||
|
VoiceHubScheduleService scheduleService,
|
||||||
|
VoiceHubSettingsService settingsService,
|
||||||
|
IPluginRuntimeContext runtimeContext)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = this;
|
||||||
|
|
||||||
|
_scheduleService = scheduleService;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
|
// 设置刷新定时器
|
||||||
|
var settings = _settingsService.GetSettings();
|
||||||
|
_refreshTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMinutes(settings.RefreshIntervalMinutes)
|
||||||
|
};
|
||||||
|
_refreshTimer.Tick += async (_, _) => await RefreshAsync();
|
||||||
|
_refreshTimer.Start();
|
||||||
|
|
||||||
|
// 监听设置变化
|
||||||
|
_settingsService.SettingsChanged += OnSettingsChanged;
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
_ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, PluginSettings settings)
|
||||||
|
{
|
||||||
|
if (_refreshTimer != null)
|
||||||
|
{
|
||||||
|
_refreshTimer.Interval = TimeSpan.FromMinutes(settings.RefreshIntervalMinutes);
|
||||||
|
}
|
||||||
|
_scheduleService.ClearCache();
|
||||||
|
_ = RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
SetState(ComponentState.Loading);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var displayData = await _scheduleService.GetTodayScheduleAsync(_loadCts.Token);
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ApplyDisplayData(displayData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 忽略取消
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
SetState(ComponentState.NetworkError, $"加载失败: {ex.Message}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDisplayData(DisplayData data)
|
||||||
|
{
|
||||||
|
switch (data.State)
|
||||||
|
{
|
||||||
|
case ComponentState.Normal:
|
||||||
|
Songs.Clear();
|
||||||
|
foreach (var song in data.Songs)
|
||||||
|
{
|
||||||
|
Songs.Add(song);
|
||||||
|
}
|
||||||
|
DateText = data.DisplayDate?.ToString("MM月dd日") ?? "";
|
||||||
|
SetState(ComponentState.Normal);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ComponentState.NoSchedule:
|
||||||
|
EmptyMessage = data.ErrorMessage ?? "暂无排期数据";
|
||||||
|
SetState(ComponentState.NoSchedule);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ComponentState.NetworkError:
|
||||||
|
SetState(ComponentState.NetworkError, data.ErrorMessage ?? "网络错误");
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
SetState(ComponentState.Loading);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetState(ComponentState state, string? message = null)
|
||||||
|
{
|
||||||
|
IsLoading = state == ComponentState.Loading;
|
||||||
|
IsNormal = state == ComponentState.Normal;
|
||||||
|
IsEmpty = state == ComponentState.NoSchedule;
|
||||||
|
IsError = state == ComponentState.NetworkError;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
if (state == ComponentState.NetworkError)
|
||||||
|
{
|
||||||
|
ErrorMessage = message;
|
||||||
|
}
|
||||||
|
else if (state == ComponentState.NoSchedule)
|
||||||
|
{
|
||||||
|
EmptyMessage = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RetryAsync()
|
||||||
|
{
|
||||||
|
_scheduleService.ClearCache();
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDetachedFromVisualTree(e);
|
||||||
|
|
||||||
|
_refreshTimer?.Stop();
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_settingsService.SettingsChanged -= OnSettingsChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
VoiceHubLanDesktop/VoiceHubApiService.cs
Normal file
102
VoiceHubLanDesktop/VoiceHubApiService.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VoiceHub API 服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubApiService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
|
||||||
|
private const string DefaultApiUrl = "https://voicehub.lao-shui.top/api/songs/public";
|
||||||
|
private const int MaxRetryCount = 3;
|
||||||
|
private readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
public VoiceHubApiService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResult<IReadOnlyList<SongItem>>> GetPublicScheduleAsync(
|
||||||
|
string? apiUrl = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var url = string.IsNullOrWhiteSpace(apiUrl) ? DefaultApiUrl : apiUrl.Trim();
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < MaxRetryCount; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(_requestTimeout);
|
||||||
|
|
||||||
|
var jsonResponse = await _httpClient.GetStringAsync(url, cts.Token);
|
||||||
|
var items = JsonSerializer.Deserialize<List<SongItem>>(jsonResponse, _jsonOptions);
|
||||||
|
|
||||||
|
if (items is null)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("数据解析失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Success(items);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
if (attempt == MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"网络错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
if (attempt == MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("请求超时");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"数据格式错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure($"未知错误: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResult<IReadOnlyList<SongItem>>.Failure("获取数据失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _httpClient.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApiResult<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public T? Data { get; }
|
||||||
|
public string? ErrorMessage { get; }
|
||||||
|
|
||||||
|
private ApiResult(bool isSuccess, T? data, string? errorMessage)
|
||||||
|
{
|
||||||
|
IsSuccess = isSuccess;
|
||||||
|
Data = data;
|
||||||
|
ErrorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApiResult<T> Success(T data) => new(true, data, null);
|
||||||
|
public static ApiResult<T> Failure(string errorMessage) => new(false, default, errorMessage);
|
||||||
|
}
|
||||||
26
VoiceHubLanDesktop/VoiceHubLanDesktop.csproj
Normal file
26
VoiceHubLanDesktop/VoiceHubLanDesktop.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
|
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||||
|
<LanMountainPluginBuildOutputDirectory>$(OutputPath)</LanMountainPluginBuildOutputDirectory>
|
||||||
|
<LanMountainPluginPackageVersion>$(Version)</LanMountainPluginPackageVersion>
|
||||||
|
<LanMountainPluginPackageOutputDirectory>$(MSBuildThisFileDirectory)</LanMountainPluginPackageOutputDirectory>
|
||||||
|
<LanMountainPluginPackageExtension>.laapp</LanMountainPluginPackageExtension>
|
||||||
|
<LanMountainPluginPackageFileName>$(AssemblyName).$(LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)</LanMountainPluginPackageFileName>
|
||||||
|
<LanMountainPluginPackagePath>$(LanMountainPluginPackageOutputDirectory)$(LanMountainPluginPackageFileName)</LanMountainPluginPackagePath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" ExcludeAssets="runtime" PrivateAssets="all" />
|
||||||
|
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
103
VoiceHubLanDesktop/VoiceHubPlugin.cs
Normal file
103
VoiceHubLanDesktop/VoiceHubPlugin.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VoiceHub 广播站排期插件入口
|
||||||
|
/// </summary>
|
||||||
|
[PluginEntrance]
|
||||||
|
public sealed class VoiceHubPlugin : PluginBase
|
||||||
|
{
|
||||||
|
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
var localizer = CreateLocalizer(context);
|
||||||
|
|
||||||
|
// 注册服务
|
||||||
|
services.AddSingleton<VoiceHubApiService>();
|
||||||
|
services.AddSingleton<VoiceHubScheduleService>();
|
||||||
|
|
||||||
|
// 注册桌面组件 - 最小 3x4 网格,允许等比例缩放
|
||||||
|
services.AddPluginDesktopComponent<VoiceHubScheduleWidget>(
|
||||||
|
CreateScheduleComponentOptions(localizer));
|
||||||
|
|
||||||
|
// 注册设置页面
|
||||||
|
services.AddPluginSettingsSection(
|
||||||
|
id: "voicehub-settings",
|
||||||
|
titleLocalizationKey: "settings.title",
|
||||||
|
configure: builder =>
|
||||||
|
{
|
||||||
|
builder.AddText(
|
||||||
|
key: "apiUrl",
|
||||||
|
titleLocalizationKey: "settings.apiUrl.title",
|
||||||
|
descriptionLocalizationKey: "settings.apiUrl.description",
|
||||||
|
defaultValue: "https://voicehub.lao-shui.top/api/songs/public");
|
||||||
|
|
||||||
|
builder.AddBoolean(
|
||||||
|
key: "showRequester",
|
||||||
|
titleLocalizationKey: "settings.showRequester.title",
|
||||||
|
descriptionLocalizationKey: "settings.showRequester.description",
|
||||||
|
defaultValue: true);
|
||||||
|
|
||||||
|
builder.AddBoolean(
|
||||||
|
key: "showVoteCount",
|
||||||
|
titleLocalizationKey: "settings.showVoteCount.title",
|
||||||
|
descriptionLocalizationKey: "settings.showVoteCount.description",
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
builder.AddSelection(
|
||||||
|
key: "refreshInterval",
|
||||||
|
titleLocalizationKey: "settings.refreshInterval.title",
|
||||||
|
descriptionLocalizationKey: "settings.refreshInterval.description",
|
||||||
|
defaultValue: "60",
|
||||||
|
choices:
|
||||||
|
[
|
||||||
|
new SettingsOptionChoice("5分钟", "5"),
|
||||||
|
new SettingsOptionChoice("15分钟", "15"),
|
||||||
|
new SettingsOptionChoice("30分钟", "30"),
|
||||||
|
new SettingsOptionChoice("1小时", "60"),
|
||||||
|
new SettingsOptionChoice("2小时", "120")
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
descriptionLocalizationKey: "settings.description",
|
||||||
|
iconKey: "Settings",
|
||||||
|
sortOrder: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginLocalizer CreateLocalizer(HostBuilderContext context)
|
||||||
|
{
|
||||||
|
var pluginDirectory = context.Properties.TryGetValue("LanMountainDesktop.PluginDirectory", out var directoryValue) &&
|
||||||
|
directoryValue is string resolvedPluginDirectory &&
|
||||||
|
!string.IsNullOrWhiteSpace(resolvedPluginDirectory)
|
||||||
|
? resolvedPluginDirectory
|
||||||
|
: AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
var properties = context.Properties
|
||||||
|
.Where(pair => pair.Key is string)
|
||||||
|
.ToDictionary(pair => (string)pair.Key, pair => (object?)pair.Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return new PluginLocalizer(pluginDirectory, PluginLocalizer.ResolveLanguageCode(properties));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginDesktopComponentOptions CreateScheduleComponentOptions(PluginLocalizer localizer)
|
||||||
|
{
|
||||||
|
return new PluginDesktopComponentOptions
|
||||||
|
{
|
||||||
|
ComponentId = "com.voicehub.schedule",
|
||||||
|
DisplayName = localizer.GetString("widget.display_name", "广播站排期"),
|
||||||
|
DisplayNameLocalizationKey = "widget.display_name",
|
||||||
|
IconKey = "Radio",
|
||||||
|
Category = localizer.GetString("widget.category", "信息"),
|
||||||
|
MinWidthCells = 3,
|
||||||
|
MinHeightCells = 4,
|
||||||
|
AllowDesktopPlacement = true,
|
||||||
|
AllowStatusBarPlacement = false,
|
||||||
|
ResizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||||
|
CornerRadiusPreset = PluginCornerRadiusPreset.Default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
154
VoiceHubLanDesktop/VoiceHubScheduleService.cs
Normal file
154
VoiceHubLanDesktop/VoiceHubScheduleService.cs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace VoiceHubLanDesktop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排期管理服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceHubScheduleService
|
||||||
|
{
|
||||||
|
private readonly VoiceHubApiService _apiService;
|
||||||
|
private readonly IPluginSettingsService _settingsService;
|
||||||
|
private IReadOnlyList<SongItem> _cachedSchedule = [];
|
||||||
|
private DateTime _cacheTime = DateTime.MinValue;
|
||||||
|
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private const string SettingsSectionId = "voicehub-settings";
|
||||||
|
|
||||||
|
public VoiceHubScheduleService(VoiceHubApiService apiService, IPluginSettingsService settingsService)
|
||||||
|
{
|
||||||
|
_apiService = apiService;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DisplayData> GetTodayScheduleAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var apiUrl = GetApiUrl();
|
||||||
|
|
||||||
|
if (_cachedSchedule.Count > 0 && DateTime.Now - _cacheTime < _cacheExpiry)
|
||||||
|
{
|
||||||
|
return BuildDisplayData(_cachedSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _apiService.GetPublicScheduleAsync(apiUrl, cancellationToken);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NetworkError,
|
||||||
|
ErrorMessage = result.ErrorMessage ?? "获取排期失败"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = result.Data ?? [];
|
||||||
|
_cachedSchedule = items;
|
||||||
|
_cacheTime = DateTime.Now;
|
||||||
|
|
||||||
|
return BuildDisplayData(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearCache()
|
||||||
|
{
|
||||||
|
_cachedSchedule = [];
|
||||||
|
_cacheTime = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetApiUrl()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var apiUrl = _settingsService.GetValue<string>(SettingsScope.Plugin, "apiUrl", sectionId: SettingsSectionId);
|
||||||
|
return string.IsNullOrWhiteSpace(apiUrl)
|
||||||
|
? "https://voicehub.lao-shui.top/api/songs/public"
|
||||||
|
: apiUrl;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "https://voicehub.lao-shui.top/api/songs/public";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayData BuildDisplayData(IReadOnlyList<SongItem> items)
|
||||||
|
{
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var validItems = items.Where(s => s.GetPlayDate() != DateTime.MinValue).ToList();
|
||||||
|
|
||||||
|
if (validItems.Count == 0)
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无有效排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var todaySchedule = validItems
|
||||||
|
.Where(s => s.GetPlayDate() == today)
|
||||||
|
.OrderBy(s => s.Sequence)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
List<SongItem> displayItems;
|
||||||
|
DateTime actualDate;
|
||||||
|
|
||||||
|
if (todaySchedule.Count > 0)
|
||||||
|
{
|
||||||
|
displayItems = todaySchedule;
|
||||||
|
actualDate = today;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var futureSchedule = validItems
|
||||||
|
.Where(s => s.GetPlayDate() > today)
|
||||||
|
.GroupBy(s => s.GetPlayDate())
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (futureSchedule != null)
|
||||||
|
{
|
||||||
|
displayItems = futureSchedule.OrderBy(s => s.Sequence).ToList();
|
||||||
|
actualDate = futureSchedule.Key;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.NoSchedule,
|
||||||
|
ErrorMessage = "暂无排期数据"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DisplayData
|
||||||
|
{
|
||||||
|
State = ComponentState.Normal,
|
||||||
|
Songs = displayItems,
|
||||||
|
DisplayDate = actualDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ComponentState
|
||||||
|
{
|
||||||
|
Loading,
|
||||||
|
Normal,
|
||||||
|
NetworkError,
|
||||||
|
NoSchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DisplayData
|
||||||
|
{
|
||||||
|
public ComponentState State { get; set; }
|
||||||
|
public IReadOnlyList<SongItem> Songs { get; set; } = [];
|
||||||
|
public DateTime? DisplayDate { get; set; }
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user