mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2e7b4f2f | ||
|
|
798124e500 | ||
|
|
95ecb06668 | ||
|
|
ac7e8db516 | ||
|
|
8ded721f46 | ||
|
|
a559325f5a | ||
|
|
b60368527f | ||
|
|
c8c3f51bff | ||
|
|
685323e057 | ||
|
|
def21c79b1 |
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>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentLibraryCollapseStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateExpanded_InitializesExpandedStateAndHidesChip()
|
||||
{
|
||||
var margin = new Thickness(24, 24, 24, 100);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState);
|
||||
Assert.Equal(margin, state.ExpandedMargin);
|
||||
Assert.Equal(0.75, state.ExpandedOpacity, 3);
|
||||
Assert.False(state.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions()
|
||||
{
|
||||
var margin = new Thickness(20, 18, 20, 96);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1);
|
||||
|
||||
var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||
var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||
var restoring = collapsed.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsing, collapsing.VisualState);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsed, collapsed.VisualState);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Restoring, restoring.VisualState);
|
||||
|
||||
Assert.Equal(margin, collapsing.ExpandedMargin);
|
||||
Assert.Equal(margin, collapsed.ExpandedMargin);
|
||||
Assert.Equal(margin, restoring.ExpandedMargin);
|
||||
|
||||
Assert.Equal(1, collapsing.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, collapsed.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, restoring.ExpandedOpacity, 3);
|
||||
|
||||
Assert.True(collapsing.IsChipVisible);
|
||||
Assert.True(collapsed.IsChipVisible);
|
||||
Assert.False(restoring.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExpanded_ProducesRestorableSnapshotEvenWhenOriginalOpacityIsLow()
|
||||
{
|
||||
var margin = new Thickness(18, 22, 18, 88);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15);
|
||||
var restored = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||
|
||||
Assert.Equal(margin, restored.ExpandedMargin);
|
||||
Assert.Equal(0.15, restored.ExpandedOpacity, 3);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, restored.VisualState);
|
||||
Assert.False(restored.IsChipVisible);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs
Normal file
15
LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopEditCommitMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPendingCommitValid_ReturnsTrueOnlyForMatchingActiveVersion()
|
||||
{
|
||||
Assert.True(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 4));
|
||||
Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: false, scheduledVersion: 4, currentVersion: 4));
|
||||
Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 5));
|
||||
}
|
||||
}
|
||||
173
LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
Normal file
173
LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopPlacementMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeDragStartThreshold_UsesFloorAndCellScale()
|
||||
{
|
||||
Assert.Equal(10d, DesktopPlacementMath.ComputeDragStartThreshold(24));
|
||||
Assert.Equal(14.4d, DesktopPlacementMath.ComputeDragStartThreshold(80), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasExceededThreshold_OnlyReturnsTrueAfterEnoughMovement()
|
||||
{
|
||||
var start = new Point(20, 20);
|
||||
|
||||
Assert.False(DesktopPlacementMath.HasExceededThreshold(start, new Point(27, 25), 10));
|
||||
Assert.True(DesktopPlacementMath.HasExceededThreshold(start, new Point(31, 20), 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcclusionHelpers_DetectPointAndRectOverlap()
|
||||
{
|
||||
var libraryBounds = new Rect(100, 100, 200, 160);
|
||||
|
||||
Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(120, 150), libraryBounds));
|
||||
Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(80, 90), libraryBounds));
|
||||
Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(250, 120, 120, 80), libraryBounds));
|
||||
Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(10, 10, 40, 40), libraryBounds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetSnappedCell_ClampsInsideGridBounds()
|
||||
{
|
||||
var grid = new DesktopGridGeometry(
|
||||
Origin: default,
|
||||
CellSize: 80,
|
||||
CellGap: 8,
|
||||
ColumnCount: 4,
|
||||
RowCount: 5);
|
||||
|
||||
var result = DesktopPlacementMath.TryGetSnappedCell(
|
||||
grid,
|
||||
pointerInViewport: new Point(490, 520),
|
||||
pointerOffset: new Point(10, 10),
|
||||
widthCells: 2,
|
||||
heightCells: 3,
|
||||
out var column,
|
||||
out var row);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, column);
|
||||
Assert.Equal(2, row);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCellRect_MapsCellsToPixelRect()
|
||||
{
|
||||
var grid = new DesktopGridGeometry(
|
||||
Origin: new Point(12, 24),
|
||||
CellSize: 80,
|
||||
CellGap: 8,
|
||||
ColumnCount: 6,
|
||||
RowCount: 8);
|
||||
|
||||
var rect = DesktopPlacementMath.GetCellRect(grid, column: 2, row: 3, widthCells: 2, heightCells: 3);
|
||||
|
||||
Assert.Equal(188, rect.X, 3);
|
||||
Assert.Equal(288, rect.Y, 3);
|
||||
Assert.Equal(168, rect.Width, 3);
|
||||
Assert.Equal(256, rect.Height, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_DoesNotCommitWhilePointerIsStillInsideLibrary()
|
||||
{
|
||||
var session = DesktopEditSession.CreatePendingNew(
|
||||
componentId: "demo",
|
||||
pageIndex: 0,
|
||||
widthCells: 2,
|
||||
heightCells: 2,
|
||||
startPointerInViewport: new Point(80, 80),
|
||||
pointerOffsetInViewport: new Point(60, 60),
|
||||
componentLibraryBounds: new Rect(0, 0, 220, 300));
|
||||
|
||||
session = session.WithCurrentPointer(new Point(130, 150));
|
||||
|
||||
Assert.True(session.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80)));
|
||||
Assert.True(session.IsPointerInsideComponentLibrary());
|
||||
Assert.False(session.CanCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_ResizePreviewStillBlocksWhenPointerRemainsInsideLibrary()
|
||||
{
|
||||
var session = DesktopEditSession.CreateResizingExisting(
|
||||
componentId: "demo",
|
||||
placementId: "placement-1",
|
||||
pageIndex: 0,
|
||||
widthCells: 2,
|
||||
heightCells: 2,
|
||||
startPointerInViewport: new Point(80, 80),
|
||||
componentLibraryBounds: new Rect(0, 0, 220, 300))
|
||||
.WithCurrentPointer(new Point(130, 150));
|
||||
|
||||
Assert.True(session.IsPointerInsideComponentLibrary());
|
||||
Assert.False(session.CanCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCellPositionChanged_DetectsNoOpAndRealMoves()
|
||||
{
|
||||
Assert.False(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 3));
|
||||
Assert.True(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasCellSpanChanged_DetectsNoOpAndRealResizes()
|
||||
{
|
||||
Assert.False(DesktopPlacementMath.HasCellSpanChanged(2, 3, 2, 3));
|
||||
Assert.True(DesktopPlacementMath.HasCellSpanChanged(2, 3, 3, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanCommitPlacement_BlocksWhenPlacementIsOccludedByLibrary()
|
||||
{
|
||||
var placementRect = new Rect(160, 110, 180, 140);
|
||||
var occludingLibraryBounds = new Rect(120, 80, 240, 220);
|
||||
var distantLibraryBounds = new Rect(420, 420, 80, 80);
|
||||
|
||||
Assert.False(DesktopPlacementMath.CanCommitPlacement(placementRect, occludingLibraryBounds));
|
||||
Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, distantLibraryBounds));
|
||||
Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, componentLibraryBounds: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_AllowsCommitWhenComponentLibraryBoundsAreCleared()
|
||||
{
|
||||
var pendingSession = DesktopEditSession.CreatePendingNew(
|
||||
componentId: "demo",
|
||||
pageIndex: 0,
|
||||
widthCells: 2,
|
||||
heightCells: 2,
|
||||
startPointerInViewport: new Point(80, 80),
|
||||
pointerOffsetInViewport: new Point(60, 60),
|
||||
componentLibraryBounds: null)
|
||||
.WithCurrentPointer(new Point(200, 180));
|
||||
|
||||
Assert.True(pendingSession.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80)));
|
||||
Assert.False(pendingSession.IsPointerInsideComponentLibrary());
|
||||
Assert.False(pendingSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
|
||||
Assert.False(pendingSession.CanCommit);
|
||||
|
||||
var resizeSession = DesktopEditSession.CreateResizingExisting(
|
||||
componentId: "demo",
|
||||
placementId: "placement-1",
|
||||
pageIndex: 0,
|
||||
widthCells: 2,
|
||||
heightCells: 2,
|
||||
startPointerInViewport: new Point(80, 80),
|
||||
componentLibraryBounds: null)
|
||||
.WithCurrentPointer(new Point(200, 180))
|
||||
.WithTargetCell(row: 2, column: 3);
|
||||
|
||||
Assert.False(resizeSession.IsPointerInsideComponentLibrary());
|
||||
Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
|
||||
Assert.True(resizeSession.CanCommit);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
<Application.Resources>
|
||||
<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.DataTemplates>
|
||||
@@ -23,6 +25,7 @@
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/NavigationStyles.axaml" />
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
|
||||
@@ -47,6 +47,7 @@ public partial class App : Application
|
||||
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
||||
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly FontFamilyService _fontFamilyService = new();
|
||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||
@@ -448,6 +449,21 @@ public partial class App : Application
|
||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||
Thread.CurrentThread.CurrentCulture = 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()
|
||||
|
||||
@@ -1,54 +1,229 @@
|
||||
# 隐私与遥测说明
|
||||
# LanMountainDesktop 遥测隐私政策
|
||||
|
||||
LanMountainDesktop 提供两类可选遥测能力:
|
||||
**生效日期**:2026年3月22日\
|
||||
**最后更新**:2026年3月22日
|
||||
|
||||
- 崩溃数据上传
|
||||
- 行为数据分析
|
||||
***
|
||||
|
||||
这两个开关默认关闭。即使两项都关闭,应用仍会在首次启动时向 PostHog 发送一次最小化的启动基线事件,用于统计用户量。
|
||||
## 引言
|
||||
|
||||
## 默认行为
|
||||
LanMountainDesktop(以下简称"本应用")由 灵方软件Lincube(以下简称"我们")开发和维护。我们深知用户隐私的重要性,并致力于保护您的个人信息安全。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
|
||||
|
||||
当“崩溃数据上传”和“行为数据分析”都关闭时:
|
||||
使用本应用即表示您同意本隐私政策的条款。如果您不同意本政策的任何部分,请停止使用本应用。
|
||||
|
||||
- 仅首次启动会发送一次 `app_first_launch` 事件
|
||||
- 该事件只用于统计用户量
|
||||
- 事件时间由 PostHog 接入侧记录的请求时间和启动时间决定
|
||||
- 不会主动上传设备型号、操作系统细节、组件操作轨迹等详细信息
|
||||
***
|
||||
|
||||
## 崩溃数据上传
|
||||
## 1. 数据收集范围
|
||||
|
||||
当开启“崩溃数据上传”时,应用会把崩溃与未处理异常发送到 Sentry,用于分析稳定性问题。
|
||||
### 1.1 我们收集的数据
|
||||
|
||||
上报内容可能包括:
|
||||
本应用提供两类可选的数据收集功能:
|
||||
|
||||
- 异常堆栈和错误上下文
|
||||
- 应用版本与运行环境
|
||||
- 操作系统信息
|
||||
- 设备基础信息
|
||||
- 最近的日志尾部内容
|
||||
| 数据类型 | 收集方式 | 默认状态 | 用途 |
|
||||
| ------ | ---- | ---- | -------- |
|
||||
| 启动基线事件 | 自动收集 | 开启 | 统计用户量 |
|
||||
| 崩溃数据 | 用户授权 | 关闭 | 分析稳定性问题 |
|
||||
| 行为数据 | 用户授权 | 关闭 | 分析功能使用情况 |
|
||||
|
||||
应用退出或崩溃时,会尽量补充最后一次会话和日志信息,方便定位问题。
|
||||
### 1.2 启动基线事件
|
||||
|
||||
## 行为数据分析
|
||||
无论您是否开启其他遥测选项,本应用会在首次启动时发送一次最小化的启动基线事件(`app_first_launch`),用于统计活跃用户量。该事件仅包含:
|
||||
|
||||
当开启“行为数据分析”时,应用会把关键行为事件发送到 PostHog,用于分析功能使用情况和会话路径。
|
||||
- 匿名安装标识符(Install ID)
|
||||
- 应用版本号
|
||||
- 启动时间戳
|
||||
|
||||
上报内容可能包括:
|
||||
### 1.3 崩溃数据
|
||||
|
||||
- 应用启动和退出时间
|
||||
- 会话开始与结束时间
|
||||
- 设置页打开、关闭和导航
|
||||
- 抽屉打开和关闭
|
||||
- 桌面组件的放置、移动、缩放、删除和编辑入口
|
||||
当您开启"崩溃数据上传"功能时,我们可能收集以下信息:
|
||||
|
||||
这些事件会被转换成 PostHog 可以直接接收和分析的事件格式,方便在 PostHog 中按事件流查看用户行为。桌面端的“回放”能力通过事件时间线重建,而不是浏览器式 Session Replay。
|
||||
- **异常信息**:异常类型、错误消息、堆栈跟踪
|
||||
- **应用信息**:应用版本、构建号、运行时环境
|
||||
- **系统信息**:操作系统版本、系统架构、可用内存
|
||||
- **设备信息**:设备型号、屏幕分辨率
|
||||
- **日志信息**:应用崩溃前的最近日志记录(可能包含您在使用过程中产生的操作记录)
|
||||
|
||||
## 身份与隐私控制
|
||||
### 1.4 行为数据
|
||||
|
||||
应用会使用随机生成的匿名 install ID 和可刷新 telemetry ID 来区分安装与运行会话。
|
||||
当您开启"行为数据分析"功能时,我们可能收集以下信息:
|
||||
|
||||
- 刷新 telemetry ID 只会影响后续详细遥测
|
||||
- 关闭开关后,不会继续发送对应类别的详细遥测
|
||||
- IP 只会通过 Sentry / PostHog 的服务端接入侧自然记录,不会作为自定义字段重复上报
|
||||
- **会话信息**:应用启动/退出时间、会话持续时间
|
||||
- **功能使用**:设置页面访问、抽屉操作、组件库操作
|
||||
- **组件操作**:桌面组件的放置、移动、调整大小、删除操作
|
||||
- **界面交互**:页面切换、编辑模式进入/退出
|
||||
|
||||
***
|
||||
|
||||
## 2. 数据使用目的
|
||||
|
||||
我们收集的数据将用于以下目的:
|
||||
|
||||
### 2.1 启动基线事件
|
||||
|
||||
- 统计应用的用户数量和活跃度
|
||||
- 了解应用的安装分布情况
|
||||
|
||||
### 2.2 崩溃数据
|
||||
|
||||
- 诊断和修复应用崩溃问题
|
||||
- 提高应用的稳定性和可靠性
|
||||
- 识别和解决性能瓶颈
|
||||
|
||||
### 2.3 行为数据
|
||||
|
||||
- 了解用户如何使用本应用的功能
|
||||
- 改进用户体验和界面设计
|
||||
- 指导功能开发和优先级决策
|
||||
- 分析用户行为模式和趋势
|
||||
|
||||
***
|
||||
|
||||
## 3. 数据存储与传输
|
||||
|
||||
### 3.1 数据传输
|
||||
|
||||
您的数据将通过加密连接传输至以下第三方服务:
|
||||
|
||||
| 服务提供商 | 服务类型 | 数据内容 | 隐私政策 |
|
||||
| ------- | ---- | --------- | ----------------------------- |
|
||||
| PostHog | 产品分析 | 启动事件、行为数据 | <https://posthog.com/privacy> |
|
||||
| Sentry | 错误监控 | 崩溃数据、异常信息 | <https://sentry.io/privacy/> |
|
||||
|
||||
### 3.2 数据存储位置
|
||||
|
||||
数据存储于上述第三方服务的服务器,这些服务器可能位于中国境外。我们已与这些服务提供商签订数据处理协议,确保您的数据得到适当保护。
|
||||
|
||||
### 3.3 数据保留期限
|
||||
|
||||
- **启动基线事件**:保留期限由 PostHog 服务配置决定,通常为 13 个月
|
||||
- **崩溃数据**:保留期限由 Sentry 服务配置决定,通常为 90 天
|
||||
- **行为数据**:保留期限由 PostHog 服务配置决定,通常为 13 个月
|
||||
|
||||
***
|
||||
|
||||
## 4. 用户权利与控制
|
||||
|
||||
### 4.1 您的权利
|
||||
|
||||
根据适用的数据保护法律,您享有以下权利:
|
||||
|
||||
- **知情权**:了解我们收集哪些数据及其用途
|
||||
- **访问权**:请求获取我们持有的您的个人数据副本
|
||||
- **更正权**:请求更正不准确或不完整的个人数据
|
||||
- **删除权**:请求删除您的个人数据
|
||||
- **撤回同意权**:随时撤回您对数据收集的同意
|
||||
- **数据可携带权**:以结构化格式接收您的个人数据
|
||||
|
||||
### 4.2 如何行使您的权利
|
||||
|
||||
您可以通过以下方式行使上述权利:
|
||||
|
||||
1. **关闭遥测功能**:在应用设置 > 隐私设置中关闭相应开关
|
||||
2. **刷新遥测标识**:在应用设置 > 隐私设置中点击"刷新遥测 ID"
|
||||
3. **联系我们**:通过 GitHub Issues 提交数据相关请求
|
||||
|
||||
### 4.3 功能控制
|
||||
|
||||
| 功能 | 控制方式 | 效果 |
|
||||
| ------- | ---- | ----------- |
|
||||
| 崩溃数据上传 | 设置开关 | 关闭后停止发送崩溃数据 |
|
||||
| 行为数据分析 | 设置开关 | 关闭后停止发送行为数据 |
|
||||
| 刷新遥测 ID | 手动触发 | 生成新的匿名标识符 |
|
||||
|
||||
***
|
||||
|
||||
## 5. 身份标识
|
||||
|
||||
### 5.1 匿名标识符
|
||||
|
||||
我们使用以下匿名标识符来区分用户和会话:
|
||||
|
||||
- **Install ID**:在应用首次安装时随机生成的唯一标识符,用于区分不同的安装实例
|
||||
- **Telemetry ID**:匿名标识符,用于关联遥测数据
|
||||
|
||||
### 5.2 标识符特性
|
||||
|
||||
- 这些标识符不包含您的真实身份信息
|
||||
- 标识符与您的个人身份(如姓名、邮箱、电话)无关联
|
||||
|
||||
***
|
||||
|
||||
## 6. 数据安全
|
||||
|
||||
### 6.1 安全措施
|
||||
|
||||
我们采取以下安全措施保护您的数据:
|
||||
|
||||
- **传输加密**:所有数据传输均使用 TLS/HTTPS 加密
|
||||
- **访问控制**:限制对数据的访问权限,仅授权人员可访问
|
||||
- **匿名化处理**:使用匿名标识符而非个人身份信息
|
||||
|
||||
### 6.2 数据泄露响应
|
||||
|
||||
如发生数据泄露事件,我们将:
|
||||
|
||||
1. 及时评估泄露的影响范围和严重程度
|
||||
2. 采取必要措施阻止进一步泄露
|
||||
3. 根据法律要求通知相关监管机构和受影响用户
|
||||
|
||||
***
|
||||
|
||||
## 7. 第三方服务
|
||||
|
||||
### 7.1 PostHog
|
||||
|
||||
PostHog 是我们使用的产品分析平台,用于收集和分析用户行为数据。PostHog 的隐私政策请参阅:<https://posthog.com/privacy>
|
||||
|
||||
### 7.2 Sentry
|
||||
|
||||
Sentry 是我们使用的错误监控平台,用于收集和分析崩溃数据。Sentry 的隐私政策请参阅:<https://sentry.io/privacy/>
|
||||
|
||||
### 7.3 第三方责任
|
||||
|
||||
我们仅将上述第三方服务用于本政策所述目的。我们不对这些第三方的隐私实践负责,建议您阅读其隐私政策。
|
||||
|
||||
***
|
||||
|
||||
## 8. 儿童隐私
|
||||
|
||||
本应用不面向 14 周岁以下的儿童。我们不会故意收集儿童的个人信息。如果您是 14 周岁以下儿童的监护人,且发现您的孩子向我们提供了个人信息,请联系我们,我们将采取措施删除相关信息。
|
||||
|
||||
***
|
||||
|
||||
## 9. 隐私政策更新
|
||||
|
||||
我们可能会不时更新本隐私政策。更新后的政策将在本应用内发布,并在政策顶部注明"最后更新"日期。重大变更时,我们将在应用内通过显著方式通知您。
|
||||
|
||||
建议您定期查阅本政策,以了解我们如何保护您的信息。继续使用本应用即表示您接受更新后的隐私政策。
|
||||
|
||||
***
|
||||
|
||||
## 10. 适用法律
|
||||
|
||||
本隐私政策的解释和执行适用中华人民共和国法律法规,包括但不限于:
|
||||
|
||||
- 《中华人民共和国个人信息保护法》
|
||||
- 《中华人民共和国数据安全法》
|
||||
- 《中华人民共和国网络安全法》
|
||||
- 《信息安全技术 个人信息安全规范》(GB/T 35273)
|
||||
|
||||
***
|
||||
|
||||
## 11. 联系我们
|
||||
|
||||
如果您对本隐私政策有任何疑问、意见或建议,请通过以下方式联系我们:
|
||||
|
||||
- **GitHub 仓库**:<https://github.com/wwiinnddyy/LanMountainDesktop>
|
||||
- **问题反馈**:<https://github.com/wwiinnddyy/LanMountainDesktop/issues>
|
||||
|
||||
我们将在收到您的请求后 30 日内予以答复。
|
||||
|
||||
***
|
||||
|
||||
## 12. 条款可分割性
|
||||
|
||||
如果本隐私政策的任何条款被有管辖权的法院或监管机构认定为无效或不可执行,该条款应在最小必要范围内进行修改以使其有效和可执行,或如果无法修改,则予以删除。本政策的其余条款将继续有效。
|
||||
|
||||
***
|
||||
|
||||
**本隐私政策最终解释权归灵方软件Lincube所有。**
|
||||
|
||||
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 DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
||||
public const string DesktopIfengNews = "DesktopIfengNews";
|
||||
public const string DesktopJuyaNews = "DesktopJuyaNews";
|
||||
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
||||
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
|
||||
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
||||
|
||||
@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopJuyaNews,
|
||||
"橘鸦早报",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||
"Bilibili Hot Search",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal sealed class ComponentLibraryCollapsePresenter
|
||||
{
|
||||
private static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(150);
|
||||
private static readonly Easing TransitionEasing = new CubicEaseOut();
|
||||
private const double StableOpacityThreshold = 0.01;
|
||||
|
||||
private readonly Border _componentLibraryWindow;
|
||||
private readonly Border _collapsedChipHost;
|
||||
private readonly TextBlock _collapsedChipTextBlock;
|
||||
private readonly Control? _collapsedChipIcon;
|
||||
private readonly TranslateTransform _windowTranslate = new();
|
||||
private readonly TranslateTransform _chipTranslate = new();
|
||||
private readonly ScaleTransform _chipScale = new(1, 1);
|
||||
|
||||
private ComponentLibraryCollapseState _state;
|
||||
private int _transitionVersion;
|
||||
|
||||
public ComponentLibraryCollapsePresenter(
|
||||
Border componentLibraryWindow,
|
||||
Border collapsedChipHost,
|
||||
TextBlock collapsedChipTextBlock,
|
||||
Control? collapsedChipIcon = null)
|
||||
{
|
||||
_componentLibraryWindow = componentLibraryWindow ?? throw new ArgumentNullException(nameof(componentLibraryWindow));
|
||||
_collapsedChipHost = collapsedChipHost ?? throw new ArgumentNullException(nameof(collapsedChipHost));
|
||||
_collapsedChipTextBlock = collapsedChipTextBlock ?? throw new ArgumentNullException(nameof(collapsedChipTextBlock));
|
||||
_collapsedChipIcon = collapsedChipIcon;
|
||||
|
||||
EnsureTransforms();
|
||||
_state = ComponentLibraryCollapseState.CreateExpanded(
|
||||
_componentLibraryWindow.Margin,
|
||||
_componentLibraryWindow.Opacity <= 0 ? 1 : _componentLibraryWindow.Opacity);
|
||||
ApplyExpandedSnapshot();
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
_collapsedChipHost.Opacity = 0;
|
||||
}
|
||||
|
||||
public bool IsCollapsed => _state.VisualState is ComponentLibraryCollapseVisualState.Collapsing or ComponentLibraryCollapseVisualState.Collapsed;
|
||||
|
||||
public ComponentLibraryCollapseVisualState VisualState => _state.VisualState;
|
||||
|
||||
public void SyncExpandedState(Thickness margin, double opacity)
|
||||
{
|
||||
var hasStableOpacity = IsStableExpandedOpacity(opacity);
|
||||
var nextExpandedOpacity = hasStableOpacity ? Math.Clamp(opacity, 0, 1) : _state.ExpandedOpacity;
|
||||
_state = _state with
|
||||
{
|
||||
ExpandedMargin = margin,
|
||||
ExpandedOpacity = nextExpandedOpacity
|
||||
};
|
||||
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded or ComponentLibraryCollapseVisualState.Restoring)
|
||||
{
|
||||
ApplyExpandedSnapshot(applyOpacity: hasStableOpacity);
|
||||
}
|
||||
}
|
||||
|
||||
public void Collapse(string title)
|
||||
{
|
||||
_collapsedChipTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Widgets" : title;
|
||||
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Collapsing or ComponentLibraryCollapseVisualState.Collapsed)
|
||||
{
|
||||
ShowCollapsedChip(_transitionVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
var version = ++_transitionVersion;
|
||||
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||
|
||||
ApplyExpandedSnapshot();
|
||||
ShowCollapsedChip(version);
|
||||
SetCollapsedWindowTargets();
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||
_componentLibraryWindow.IsVisible = false;
|
||||
_componentLibraryWindow.IsHitTestVisible = false;
|
||||
},
|
||||
TransitionDuration);
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded)
|
||||
{
|
||||
ApplyExpandedSnapshot();
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
_collapsedChipHost.Opacity = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var version = ++_transitionVersion;
|
||||
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
|
||||
|
||||
PrepareRestoringWindow();
|
||||
HideCollapsedChip(version);
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
_windowTranslate.Y = 0;
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
},
|
||||
TransitionDuration);
|
||||
}
|
||||
|
||||
private void EnsureTransforms()
|
||||
{
|
||||
_componentLibraryWindow.RenderTransform = _windowTranslate;
|
||||
_windowTranslate.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = TranslateTransform.YProperty,
|
||||
Duration = TransitionDuration,
|
||||
Easing = TransitionEasing
|
||||
}
|
||||
};
|
||||
|
||||
_collapsedChipHost.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
|
||||
_collapsedChipHost.RenderTransform = new TransformGroup
|
||||
{
|
||||
Children =
|
||||
{
|
||||
_chipTranslate,
|
||||
_chipScale
|
||||
}
|
||||
};
|
||||
_chipTranslate.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = TranslateTransform.YProperty,
|
||||
Duration = TransitionDuration,
|
||||
Easing = TransitionEasing
|
||||
}
|
||||
};
|
||||
_chipScale.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleXProperty,
|
||||
Duration = TransitionDuration,
|
||||
Easing = TransitionEasing
|
||||
},
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleYProperty,
|
||||
Duration = TransitionDuration,
|
||||
Easing = TransitionEasing
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyExpandedSnapshot(bool applyOpacity = true)
|
||||
{
|
||||
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||
if (applyOpacity)
|
||||
{
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
_windowTranslate.Y = 0;
|
||||
}
|
||||
|
||||
private void SetCollapsedWindowTargets()
|
||||
{
|
||||
_componentLibraryWindow.Opacity = 0;
|
||||
_windowTranslate.Y = 28;
|
||||
}
|
||||
|
||||
private void ShowCollapsedChip(int version)
|
||||
{
|
||||
_collapsedChipHost.IsVisible = true;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
_collapsedChipTextBlock.IsVisible = true;
|
||||
if (_collapsedChipIcon is not null)
|
||||
{
|
||||
_collapsedChipIcon.IsVisible = true;
|
||||
}
|
||||
|
||||
_collapsedChipHost.Opacity = 0;
|
||||
_chipTranslate.Y = 8;
|
||||
_chipScale.ScaleX = 0.96;
|
||||
_chipScale.ScaleY = 0.96;
|
||||
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_collapsedChipHost.Opacity = 1;
|
||||
_chipTranslate.Y = 0;
|
||||
_chipScale.ScaleX = 1;
|
||||
_chipScale.ScaleY = 1;
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void HideCollapsedChip(int version)
|
||||
{
|
||||
_collapsedChipHost.IsVisible = true;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
_collapsedChipHost.Opacity = 0;
|
||||
_chipTranslate.Y = 8;
|
||||
_chipScale.ScaleX = 0.96;
|
||||
_chipScale.ScaleY = 0.96;
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _transitionVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
},
|
||||
TransitionDuration);
|
||||
}
|
||||
|
||||
private void PrepareRestoringWindow()
|
||||
{
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||
_componentLibraryWindow.Opacity = 0;
|
||||
_windowTranslate.Y = 28;
|
||||
}
|
||||
|
||||
private static bool IsStableExpandedOpacity(double opacity)
|
||||
{
|
||||
return !double.IsNaN(opacity) &&
|
||||
!double.IsInfinity(opacity) &&
|
||||
opacity > StableOpacityThreshold;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal enum ComponentLibraryCollapseVisualState
|
||||
{
|
||||
Expanded,
|
||||
Collapsing,
|
||||
Collapsed,
|
||||
Restoring
|
||||
}
|
||||
|
||||
internal readonly record struct ComponentLibraryCollapseState(
|
||||
ComponentLibraryCollapseVisualState VisualState,
|
||||
Thickness ExpandedMargin,
|
||||
double ExpandedOpacity,
|
||||
bool IsChipVisible)
|
||||
{
|
||||
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin, double expandedOpacity)
|
||||
{
|
||||
return new(
|
||||
ComponentLibraryCollapseVisualState.Expanded,
|
||||
expandedMargin,
|
||||
expandedOpacity,
|
||||
IsChipVisible: false);
|
||||
}
|
||||
|
||||
public ComponentLibraryCollapseState WithVisualState(ComponentLibraryCollapseVisualState visualState, bool isChipVisible)
|
||||
{
|
||||
return this with
|
||||
{
|
||||
VisualState = visualState,
|
||||
IsChipVisible = isChipVisible
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal static class DesktopEditCommitMath
|
||||
{
|
||||
public static bool IsPendingCommitValid(bool isPending, int scheduledVersion, int currentVersion)
|
||||
{
|
||||
return isPending && scheduledVersion == currentVersion;
|
||||
}
|
||||
}
|
||||
358
LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs
Normal file
358
LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal sealed class DesktopEditGhostView : Border
|
||||
{
|
||||
private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120);
|
||||
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 TextBlock _titleTextBlock;
|
||||
private readonly TextBlock _detailTextBlock;
|
||||
private readonly Border _badgeBorder;
|
||||
private readonly TextBlock _badgeTextBlock;
|
||||
private readonly ScaleTransform _scaleTransform = new(1, 1);
|
||||
|
||||
private readonly SolidColorBrush _normalBackgroundBrush = new(Color.Parse("#F11B2430"));
|
||||
private readonly SolidColorBrush _normalBorderBrush = new(Color.Parse("#4D8AA3C1"));
|
||||
private readonly SolidColorBrush _normalAccentBrush = new(Color.Parse("#FF4F8EF7"));
|
||||
private readonly SolidColorBrush _normalTextBrush = new(Color.Parse("#FFF5F7FA"));
|
||||
private readonly SolidColorBrush _normalMutedTextBrush = new(Color.Parse("#BDE2E8F0"));
|
||||
private readonly SolidColorBrush _normalBadgeBackgroundBrush = new(Color.Parse("#245E86D6"));
|
||||
private readonly SolidColorBrush _normalBadgeBorderBrush = new(Color.Parse("#557EA7E6"));
|
||||
private readonly SolidColorBrush _invalidBackgroundBrush = new(Color.Parse("#F01B1022"));
|
||||
private readonly SolidColorBrush _invalidBorderBrush = new(Color.Parse("#FFE25555"));
|
||||
private readonly SolidColorBrush _invalidAccentBrush = new(Color.Parse("#FFFF6B6B"));
|
||||
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
|
||||
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
|
||||
|
||||
private bool _hasPreviewImage;
|
||||
private bool _isInvalid;
|
||||
|
||||
public DesktopEditGhostView()
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
VerticalAlignment = VerticalAlignment.Stretch;
|
||||
Padding = new Thickness(14);
|
||||
Background = _normalBackgroundBrush;
|
||||
BorderBrush = _normalBorderBrush;
|
||||
BorderThickness = new Thickness(1);
|
||||
CornerRadius = new CornerRadius(22);
|
||||
ClipToBounds = true;
|
||||
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
|
||||
RenderTransform = _scaleTransform;
|
||||
Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(FastDuration)
|
||||
};
|
||||
_scaleTransform.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||
};
|
||||
|
||||
_accentDot = new Border
|
||||
{
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = _normalAccentBrush,
|
||||
BorderThickness = new Thickness(0),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
_titleTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = _normalTextBrush,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
MaxLines = 1
|
||||
};
|
||||
|
||||
_detailTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = _normalMutedTextBrush,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
MaxLines = 1
|
||||
};
|
||||
|
||||
_badgeTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = _normalTextBrush,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
MaxLines = 1
|
||||
};
|
||||
|
||||
_badgeBorder = new Border
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Padding = new Thickness(9, 4),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = _normalBadgeBackgroundBrush,
|
||||
BorderBrush = _normalBadgeBorderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
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
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
Children =
|
||||
{
|
||||
_accentDot,
|
||||
_titleTextBlock
|
||||
}
|
||||
};
|
||||
|
||||
var contentPanel = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Children =
|
||||
{
|
||||
headerPanel,
|
||||
_detailTextBlock
|
||||
}
|
||||
};
|
||||
|
||||
var fallbackGrid = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions
|
||||
{
|
||||
new RowDefinition(GridLength.Auto),
|
||||
new RowDefinition(GridLength.Auto)
|
||||
},
|
||||
RowSpacing = 8
|
||||
};
|
||||
fallbackGrid.Children.Add(contentPanel);
|
||||
fallbackGrid.Children.Add(_badgeBorder);
|
||||
Grid.SetRow(contentPanel, 0);
|
||||
Grid.SetRow(_badgeBorder, 1);
|
||||
_badgeBorder.Margin = new Thickness(0, 2, 0, 0);
|
||||
|
||||
_fallbackCard = new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
Child = fallbackGrid
|
||||
};
|
||||
|
||||
Child = new Grid
|
||||
{
|
||||
Children =
|
||||
{
|
||||
_previewImage,
|
||||
_previewOverlay,
|
||||
_fallbackCard
|
||||
}
|
||||
};
|
||||
|
||||
UpdatePreviewMetrics(180, 120);
|
||||
UpdateContent(null, null, null);
|
||||
ApplyShellChrome();
|
||||
}
|
||||
|
||||
public void UpdateContent(string? title, string? detail, string? badgeText)
|
||||
{
|
||||
_titleTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Component" : title;
|
||||
_detailTextBlock.Text = string.IsNullOrWhiteSpace(detail) ? string.Empty : detail;
|
||||
_detailTextBlock.IsVisible = !string.IsNullOrWhiteSpace(detail);
|
||||
_badgeTextBlock.Text = string.IsNullOrWhiteSpace(badgeText) ? string.Empty : 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)
|
||||
{
|
||||
var normalizedWidth = Math.Max(1, width);
|
||||
var normalizedHeight = Math.Max(1, height);
|
||||
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
|
||||
|
||||
CornerRadius = _hasPreviewImage
|
||||
? 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.09, 10, 16));
|
||||
|
||||
var titleFontSize = Math.Clamp(minSide * 0.12, 12, 18);
|
||||
var detailFontSize = Math.Clamp(minSide * 0.085, 10, 13);
|
||||
var badgeFontSize = Math.Clamp(minSide * 0.08, 9, 12);
|
||||
var dotSize = Math.Clamp(minSide * 0.07, 8, 12);
|
||||
var badgeHorizontalPadding = Math.Clamp(minSide * 0.07, 8, 14);
|
||||
var badgeVerticalPadding = Math.Clamp(minSide * 0.035, 3, 6);
|
||||
|
||||
_accentDot.Width = dotSize;
|
||||
_accentDot.Height = dotSize;
|
||||
_titleTextBlock.FontSize = titleFontSize;
|
||||
_detailTextBlock.FontSize = detailFontSize;
|
||||
_badgeTextBlock.FontSize = badgeFontSize;
|
||||
_badgeBorder.Padding = new Thickness(badgeHorizontalPadding, badgeVerticalPadding);
|
||||
}
|
||||
|
||||
public void SetInvalid(bool isInvalid)
|
||||
{
|
||||
_isInvalid = isInvalid;
|
||||
|
||||
if (isInvalid)
|
||||
{
|
||||
_accentDot.Background = _invalidAccentBrush;
|
||||
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
|
||||
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
|
||||
_titleTextBlock.Foreground = _invalidBorderBrush;
|
||||
_detailTextBlock.Foreground = _invalidBorderBrush;
|
||||
_badgeTextBlock.Foreground = _invalidBorderBrush;
|
||||
if (!_hasPreviewImage)
|
||||
{
|
||||
Background = _invalidBackgroundBrush;
|
||||
BorderBrush = _invalidBorderBrush;
|
||||
BorderThickness = new Thickness(1);
|
||||
Opacity = 0.9;
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyShellChrome();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_accentDot.Background = _normalAccentBrush;
|
||||
_badgeBorder.Background = _normalBadgeBackgroundBrush;
|
||||
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
|
||||
_titleTextBlock.Foreground = _normalTextBrush;
|
||||
_detailTextBlock.Foreground = _normalMutedTextBrush;
|
||||
_badgeTextBlock.Foreground = _normalTextBrush;
|
||||
if (!_hasPreviewImage)
|
||||
{
|
||||
Background = _normalBackgroundBrush;
|
||||
BorderBrush = _normalBorderBrush;
|
||||
BorderThickness = new Thickness(1);
|
||||
Opacity = 1.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyShellChrome();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetRestingScale(double scale)
|
||||
{
|
||||
var clampedScale = Math.Clamp(scale, 0.85, 1.12);
|
||||
_scaleTransform.ScaleX = clampedScale;
|
||||
_scaleTransform.ScaleY = clampedScale;
|
||||
}
|
||||
|
||||
public void AnimateToScale(double scale)
|
||||
{
|
||||
var clampedScale = Math.Clamp(scale, 0.85, 1.12);
|
||||
_scaleTransform.ScaleX = 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
|
||||
};
|
||||
}
|
||||
343
LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs
Normal file
343
LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs
Normal file
@@ -0,0 +1,343 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal enum DesktopEditGhostVisualStyle
|
||||
{
|
||||
StandardLift = 0,
|
||||
ElevatedFromLibrary
|
||||
}
|
||||
|
||||
internal sealed class DesktopEditOverlayPresenter
|
||||
{
|
||||
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 readonly Canvas _root;
|
||||
private readonly DesktopEditGhostView _ghostView;
|
||||
private readonly Border _candidateOutline;
|
||||
private readonly ScaleTransform _candidateScale = new(1, 1);
|
||||
|
||||
private Rect? _previewRect;
|
||||
private Rect? _candidateRect;
|
||||
private bool _isInvalid;
|
||||
private bool _isVisible;
|
||||
private int _dismissVersion;
|
||||
|
||||
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
|
||||
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF3B30"));
|
||||
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#140A84FF"));
|
||||
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30"));
|
||||
|
||||
public DesktopEditOverlayPresenter()
|
||||
{
|
||||
_ghostView = new DesktopEditGhostView
|
||||
{
|
||||
IsHitTestVisible = false,
|
||||
Opacity = 1
|
||||
};
|
||||
|
||||
_candidateOutline = new Border
|
||||
{
|
||||
IsHitTestVisible = false,
|
||||
Background = _candidateFillBrush,
|
||||
BorderBrush = _candidateBrush,
|
||||
BorderThickness = new Thickness(2),
|
||||
CornerRadius = new CornerRadius(22),
|
||||
Opacity = 0,
|
||||
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative),
|
||||
RenderTransform = _candidateScale,
|
||||
Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = Visual.OpacityProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
}
|
||||
};
|
||||
_candidateScale.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||
};
|
||||
|
||||
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
||||
_ghostView.SetValue(Panel.ZIndexProperty, 1);
|
||||
|
||||
_root = new Canvas
|
||||
{
|
||||
IsHitTestVisible = false,
|
||||
ClipToBounds = false,
|
||||
Opacity = 0,
|
||||
IsVisible = false,
|
||||
Children =
|
||||
{
|
||||
_candidateOutline,
|
||||
_ghostView
|
||||
}
|
||||
};
|
||||
|
||||
_root.Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(FastDuration)
|
||||
};
|
||||
}
|
||||
|
||||
public Control Root => _root;
|
||||
|
||||
public void SetViewportSize(Size size)
|
||||
{
|
||||
_root.Width = Math.Max(1, size.Width);
|
||||
_root.Height = Math.Max(1, size.Height);
|
||||
}
|
||||
|
||||
public void SetPreviewRect(Rect rect)
|
||||
{
|
||||
_previewRect = Normalize(rect);
|
||||
ApplyPreviewRect();
|
||||
}
|
||||
|
||||
public void SetCandidateRect(Rect? rect)
|
||||
{
|
||||
_candidateRect = rect is null ? null : Normalize(rect.Value);
|
||||
ApplyCandidateRect();
|
||||
}
|
||||
|
||||
public void UpdateGhostContent(string? title, string? detail = null, string? badge = null)
|
||||
{
|
||||
_ghostView.UpdateContent(title, detail, badge);
|
||||
}
|
||||
|
||||
public void SetPreviewImage(IImage? image)
|
||||
{
|
||||
_ghostView.SetPreviewImage(image);
|
||||
}
|
||||
|
||||
public void SetInvalid(bool isInvalid)
|
||||
{
|
||||
_isInvalid = isInvalid;
|
||||
_ghostView.SetInvalid(isInvalid);
|
||||
UpdateCandidateAppearance();
|
||||
}
|
||||
|
||||
public void Show(DesktopEditGhostVisualStyle visualStyle = DesktopEditGhostVisualStyle.StandardLift)
|
||||
{
|
||||
_dismissVersion++;
|
||||
_isVisible = true;
|
||||
_root.IsVisible = true;
|
||||
_root.Opacity = 0;
|
||||
_ghostView.Opacity = 0;
|
||||
var imageMode = _ghostView.HasPreviewImage;
|
||||
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);
|
||||
_candidateOutline.Opacity = 0;
|
||||
_candidateScale.ScaleX = 0.97;
|
||||
_candidateScale.ScaleY = 0.97;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!_isVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_root.Opacity = 1;
|
||||
_ghostView.Opacity = 1;
|
||||
_ghostView.SetRestingScale(targetGhostScale);
|
||||
if (_candidateRect.HasValue)
|
||||
{
|
||||
_candidateOutline.Opacity = 1;
|
||||
_candidateScale.ScaleX = 1;
|
||||
_candidateScale.ScaleY = 1;
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
_dismissVersion++;
|
||||
_isVisible = false;
|
||||
_root.Opacity = 0;
|
||||
_ghostView.Opacity = 0;
|
||||
_candidateOutline.Opacity = 0;
|
||||
_candidateScale.ScaleX = 0.96;
|
||||
_candidateScale.ScaleY = 0.96;
|
||||
_ghostView.SetRestingScale(0.96);
|
||||
_ghostView.SetPreviewImage(null);
|
||||
_root.IsVisible = false;
|
||||
}
|
||||
|
||||
public void Commit()
|
||||
{
|
||||
BeginDismiss(isCancel: false);
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
BeginDismiss(isCancel: true);
|
||||
}
|
||||
|
||||
private void BeginDismiss(bool isCancel)
|
||||
{
|
||||
if (!_isVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var version = ++_dismissVersion;
|
||||
_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;
|
||||
_ghostView.Opacity = 0;
|
||||
_root.Opacity = 0;
|
||||
_ghostView.AnimateToScale(targetScale);
|
||||
_candidateScale.ScaleX = targetScale;
|
||||
_candidateScale.ScaleY = targetScale;
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _dismissVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_root.IsVisible = false;
|
||||
},
|
||||
FastDuration);
|
||||
}
|
||||
|
||||
private void ApplyPreviewRect()
|
||||
{
|
||||
if (!_previewRect.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = _previewRect.Value;
|
||||
_ghostView.Width = Math.Max(1, rect.Width);
|
||||
_ghostView.Height = Math.Max(1, rect.Height);
|
||||
Canvas.SetLeft(_ghostView, rect.X);
|
||||
Canvas.SetTop(_ghostView, rect.Y);
|
||||
_ghostView.UpdatePreviewMetrics(rect.Width, rect.Height);
|
||||
}
|
||||
|
||||
private void ApplyCandidateRect()
|
||||
{
|
||||
if (!_candidateRect.HasValue)
|
||||
{
|
||||
_candidateOutline.IsVisible = false;
|
||||
_candidateOutline.Opacity = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = _candidateRect.Value;
|
||||
_candidateOutline.IsVisible = true;
|
||||
_candidateOutline.Width = Math.Max(1, rect.Width);
|
||||
_candidateOutline.Height = Math.Max(1, rect.Height);
|
||||
Canvas.SetLeft(_candidateOutline, rect.X);
|
||||
Canvas.SetTop(_candidateOutline, rect.Y);
|
||||
|
||||
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26);
|
||||
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
|
||||
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||
_candidateOutline.Opacity = _isVisible ? 1 : 0;
|
||||
_candidateScale.ScaleX = _isVisible ? 1 : 0.97;
|
||||
_candidateScale.ScaleY = _isVisible ? 1 : 0.97;
|
||||
UpdateCandidateAppearance();
|
||||
}
|
||||
|
||||
private void UpdateCandidateAppearance()
|
||||
{
|
||||
if (!_candidateRect.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||
}
|
||||
|
||||
private static Rect Normalize(Rect rect)
|
||||
{
|
||||
var width = Math.Max(1, rect.Width);
|
||||
var height = Math.Max(1, rect.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
|
||||
};
|
||||
}
|
||||
205
LanMountainDesktop/DesktopEditing/DesktopEditSession.cs
Normal file
205
LanMountainDesktop/DesktopEditing/DesktopEditSession.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal enum DesktopEditSessionMode
|
||||
{
|
||||
None = 0,
|
||||
PendingNew,
|
||||
DraggingNew,
|
||||
DraggingExisting,
|
||||
ResizingExisting
|
||||
}
|
||||
|
||||
internal readonly record struct DesktopEditSession
|
||||
{
|
||||
public DesktopEditSessionMode Mode { get; init; }
|
||||
public string? ComponentId { get; init; }
|
||||
public string? PlacementId { get; init; }
|
||||
public int PageIndex { get; init; }
|
||||
public int WidthCells { get; init; }
|
||||
public int HeightCells { get; init; }
|
||||
public Point StartPointerInViewport { get; init; }
|
||||
public Point CurrentPointerInViewport { get; init; }
|
||||
public Point PointerOffsetInViewport { get; init; }
|
||||
public Rect? ComponentLibraryBounds { get; init; }
|
||||
public int TargetRow { get; init; }
|
||||
public int TargetColumn { get; init; }
|
||||
|
||||
public bool IsActive => Mode != DesktopEditSessionMode.None;
|
||||
public bool IsPendingNew => Mode == DesktopEditSessionMode.PendingNew;
|
||||
public bool IsDraggingNew => Mode == DesktopEditSessionMode.DraggingNew;
|
||||
public bool IsDraggingExisting => Mode == DesktopEditSessionMode.DraggingExisting;
|
||||
public bool IsResizingExisting => Mode == DesktopEditSessionMode.ResizingExisting;
|
||||
public bool HasTargetCell => TargetRow >= 0 && TargetColumn >= 0;
|
||||
|
||||
public double PointerTravelDistance => DesktopPlacementMath.Distance(StartPointerInViewport, CurrentPointerInViewport);
|
||||
|
||||
public bool HasExceededThreshold(double threshold)
|
||||
{
|
||||
return DesktopPlacementMath.HasExceededThreshold(StartPointerInViewport, CurrentPointerInViewport, threshold);
|
||||
}
|
||||
|
||||
public bool IsPointerInsideComponentLibrary()
|
||||
{
|
||||
return DesktopPlacementMath.IsOccludedByComponentLibrary(CurrentPointerInViewport, ComponentLibraryBounds);
|
||||
}
|
||||
|
||||
public bool IsPreviewOccludedByComponentLibrary(Rect previewRect)
|
||||
{
|
||||
return DesktopPlacementMath.IsOccludedByComponentLibrary(previewRect, ComponentLibraryBounds);
|
||||
}
|
||||
|
||||
public bool CanCommit => IsActive && HasTargetCell;
|
||||
|
||||
public Rect GetPreviewRect(DesktopGridGeometry grid)
|
||||
{
|
||||
if (HasTargetCell)
|
||||
{
|
||||
return DesktopPlacementMath.GetCellRect(
|
||||
grid,
|
||||
TargetColumn,
|
||||
TargetRow,
|
||||
Math.Max(1, WidthCells),
|
||||
Math.Max(1, HeightCells));
|
||||
}
|
||||
|
||||
var freePreviewOrigin = DesktopPlacementMath.Subtract(CurrentPointerInViewport, PointerOffsetInViewport);
|
||||
return new Rect(
|
||||
freePreviewOrigin,
|
||||
new Size(
|
||||
Math.Max(1, WidthCells) * grid.CellSize + Math.Max(0, Math.Max(1, WidthCells) - 1) * grid.CellGap,
|
||||
Math.Max(1, HeightCells) * grid.CellSize + Math.Max(0, Math.Max(1, HeightCells) - 1) * grid.CellGap));
|
||||
}
|
||||
|
||||
public DesktopEditSession WithCurrentPointer(Point pointerInViewport)
|
||||
{
|
||||
return this with { CurrentPointerInViewport = pointerInViewport };
|
||||
}
|
||||
|
||||
public DesktopEditSession WithComponentLibraryBounds(Rect? componentLibraryBounds)
|
||||
{
|
||||
return this with { ComponentLibraryBounds = componentLibraryBounds };
|
||||
}
|
||||
|
||||
public DesktopEditSession WithTargetCell(int row, int column)
|
||||
{
|
||||
return this with { TargetRow = row, TargetColumn = column };
|
||||
}
|
||||
|
||||
public DesktopEditSession PromoteToDraggingNew()
|
||||
{
|
||||
return this with { Mode = DesktopEditSessionMode.DraggingNew };
|
||||
}
|
||||
|
||||
public DesktopEditSession PromoteToDraggingExisting()
|
||||
{
|
||||
return this with { Mode = DesktopEditSessionMode.DraggingExisting };
|
||||
}
|
||||
|
||||
public DesktopEditSession PromoteToResizingExisting()
|
||||
{
|
||||
return this with { Mode = DesktopEditSessionMode.ResizingExisting };
|
||||
}
|
||||
|
||||
public static DesktopEditSession CreatePendingNew(
|
||||
string componentId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
Point startPointerInViewport,
|
||||
Point pointerOffsetInViewport,
|
||||
Rect? componentLibraryBounds)
|
||||
{
|
||||
return new DesktopEditSession
|
||||
{
|
||||
Mode = DesktopEditSessionMode.PendingNew,
|
||||
ComponentId = componentId,
|
||||
PageIndex = pageIndex,
|
||||
WidthCells = Math.Max(1, widthCells),
|
||||
HeightCells = Math.Max(1, heightCells),
|
||||
StartPointerInViewport = startPointerInViewport,
|
||||
CurrentPointerInViewport = startPointerInViewport,
|
||||
PointerOffsetInViewport = pointerOffsetInViewport,
|
||||
ComponentLibraryBounds = componentLibraryBounds,
|
||||
TargetRow = -1,
|
||||
TargetColumn = -1
|
||||
};
|
||||
}
|
||||
|
||||
public static DesktopEditSession CreateDraggingNew(
|
||||
string componentId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
Point startPointerInViewport,
|
||||
Point pointerOffsetInViewport,
|
||||
Rect? componentLibraryBounds)
|
||||
{
|
||||
return CreatePendingNew(
|
||||
componentId,
|
||||
pageIndex,
|
||||
widthCells,
|
||||
heightCells,
|
||||
startPointerInViewport,
|
||||
pointerOffsetInViewport,
|
||||
componentLibraryBounds) with
|
||||
{
|
||||
Mode = DesktopEditSessionMode.DraggingNew
|
||||
};
|
||||
}
|
||||
|
||||
public static DesktopEditSession CreateDraggingExisting(
|
||||
string componentId,
|
||||
string placementId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
Point startPointerInViewport,
|
||||
Point pointerOffsetInViewport,
|
||||
Rect? componentLibraryBounds)
|
||||
{
|
||||
return new DesktopEditSession
|
||||
{
|
||||
Mode = DesktopEditSessionMode.DraggingExisting,
|
||||
ComponentId = componentId,
|
||||
PlacementId = placementId,
|
||||
PageIndex = pageIndex,
|
||||
WidthCells = Math.Max(1, widthCells),
|
||||
HeightCells = Math.Max(1, heightCells),
|
||||
StartPointerInViewport = startPointerInViewport,
|
||||
CurrentPointerInViewport = startPointerInViewport,
|
||||
PointerOffsetInViewport = pointerOffsetInViewport,
|
||||
ComponentLibraryBounds = componentLibraryBounds,
|
||||
TargetRow = -1,
|
||||
TargetColumn = -1
|
||||
};
|
||||
}
|
||||
|
||||
public static DesktopEditSession CreateResizingExisting(
|
||||
string componentId,
|
||||
string placementId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
Point startPointerInViewport,
|
||||
Rect? componentLibraryBounds)
|
||||
{
|
||||
return new DesktopEditSession
|
||||
{
|
||||
Mode = DesktopEditSessionMode.ResizingExisting,
|
||||
ComponentId = componentId,
|
||||
PlacementId = placementId,
|
||||
PageIndex = pageIndex,
|
||||
WidthCells = Math.Max(1, widthCells),
|
||||
HeightCells = Math.Max(1, heightCells),
|
||||
StartPointerInViewport = startPointerInViewport,
|
||||
CurrentPointerInViewport = startPointerInViewport,
|
||||
PointerOffsetInViewport = default,
|
||||
ComponentLibraryBounds = componentLibraryBounds,
|
||||
TargetRow = -1,
|
||||
TargetColumn = -1
|
||||
};
|
||||
}
|
||||
}
|
||||
176
LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs
Normal file
176
LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
internal readonly record struct DesktopGridGeometry(
|
||||
Point Origin,
|
||||
double CellSize,
|
||||
double CellGap,
|
||||
int ColumnCount,
|
||||
int RowCount)
|
||||
{
|
||||
public double Pitch => CellSize + CellGap;
|
||||
|
||||
public bool IsValid =>
|
||||
CellSize > 0 &&
|
||||
ColumnCount > 0 &&
|
||||
RowCount > 0 &&
|
||||
Pitch > 0;
|
||||
}
|
||||
|
||||
internal static class DesktopPlacementMath
|
||||
{
|
||||
public static double ComputeDragStartThreshold(double cellSize)
|
||||
{
|
||||
return Math.Max(10d, Math.Max(0d, cellSize) * 0.18d);
|
||||
}
|
||||
|
||||
public static double Distance(Point start, Point end)
|
||||
{
|
||||
return Math.Sqrt(DistanceSquared(start, end));
|
||||
}
|
||||
|
||||
public static double DistanceSquared(Point start, Point end)
|
||||
{
|
||||
var deltaX = end.X - start.X;
|
||||
var deltaY = end.Y - start.Y;
|
||||
return deltaX * deltaX + deltaY * deltaY;
|
||||
}
|
||||
|
||||
public static bool HasExceededThreshold(Point start, Point end, double threshold)
|
||||
{
|
||||
if (threshold <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return DistanceSquared(start, end) >= threshold * threshold;
|
||||
}
|
||||
|
||||
public static Point Add(Point left, Point right)
|
||||
{
|
||||
return new Point(left.X + right.X, left.Y + right.Y);
|
||||
}
|
||||
|
||||
public static Point Subtract(Point left, Point right)
|
||||
{
|
||||
return new Point(left.X - right.X, left.Y - right.Y);
|
||||
}
|
||||
|
||||
public static bool ContainsPoint(Rect rect, Point point)
|
||||
{
|
||||
return rect.Contains(point);
|
||||
}
|
||||
|
||||
public static bool Intersects(Rect left, Rect right)
|
||||
{
|
||||
return left.Intersects(right);
|
||||
}
|
||||
|
||||
public static bool HasCellPositionChanged(int originalRow, int originalColumn, int targetRow, int targetColumn)
|
||||
{
|
||||
return originalRow != targetRow || originalColumn != targetColumn;
|
||||
}
|
||||
|
||||
public static bool HasCellSpanChanged(int originalWidthCells, int originalHeightCells, int targetWidthCells, int targetHeightCells)
|
||||
{
|
||||
return originalWidthCells != targetWidthCells || originalHeightCells != targetHeightCells;
|
||||
}
|
||||
|
||||
public static bool IsOccludedByComponentLibrary(Point point, Rect? componentLibraryBounds)
|
||||
{
|
||||
return componentLibraryBounds.HasValue && ContainsPoint(componentLibraryBounds.Value, point);
|
||||
}
|
||||
|
||||
public static bool IsOccludedByComponentLibrary(Rect previewRect, Rect? componentLibraryBounds)
|
||||
{
|
||||
return componentLibraryBounds.HasValue && Intersects(previewRect, componentLibraryBounds.Value);
|
||||
}
|
||||
|
||||
public static bool CanCommitPlacement(Rect placementRect, Rect? componentLibraryBounds)
|
||||
{
|
||||
return !IsOccludedByComponentLibrary(placementRect, componentLibraryBounds);
|
||||
}
|
||||
|
||||
public static Rect GetGridBounds(DesktopGridGeometry grid)
|
||||
{
|
||||
if (!grid.IsValid)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var width = grid.ColumnCount * grid.CellSize + Math.Max(0, grid.ColumnCount - 1) * grid.CellGap;
|
||||
var height = grid.RowCount * grid.CellSize + Math.Max(0, grid.RowCount - 1) * grid.CellGap;
|
||||
return new Rect(grid.Origin, new Size(width, height));
|
||||
}
|
||||
|
||||
public static Rect GetCellRect(
|
||||
DesktopGridGeometry grid,
|
||||
int column,
|
||||
int row,
|
||||
int widthCells = 1,
|
||||
int heightCells = 1)
|
||||
{
|
||||
var safeWidthCells = Math.Max(1, widthCells);
|
||||
var safeHeightCells = Math.Max(1, heightCells);
|
||||
var safeColumn = Math.Max(0, column);
|
||||
var safeRow = Math.Max(0, row);
|
||||
var pitch = grid.Pitch;
|
||||
var x = grid.Origin.X + safeColumn * pitch;
|
||||
var y = grid.Origin.Y + safeRow * pitch;
|
||||
var width = safeWidthCells * grid.CellSize + Math.Max(0, safeWidthCells - 1) * grid.CellGap;
|
||||
var height = safeHeightCells * grid.CellSize + Math.Max(0, safeHeightCells - 1) * grid.CellGap;
|
||||
return new Rect(x, y, width, height);
|
||||
}
|
||||
|
||||
public static Rect GetSnappedCellRect(
|
||||
DesktopGridGeometry grid,
|
||||
Point pointerInViewport,
|
||||
Point pointerOffset,
|
||||
int widthCells,
|
||||
int heightCells)
|
||||
{
|
||||
return TryGetSnappedCell(grid, pointerInViewport, pointerOffset, widthCells, heightCells, out var column, out var row)
|
||||
? GetCellRect(grid, column, row, widthCells, heightCells)
|
||||
: default;
|
||||
}
|
||||
|
||||
public static bool TryGetSnappedCell(
|
||||
DesktopGridGeometry grid,
|
||||
Point pointerInViewport,
|
||||
Point pointerOffset,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
out int column,
|
||||
out int row)
|
||||
{
|
||||
column = 0;
|
||||
row = 0;
|
||||
|
||||
if (!grid.IsValid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var safeWidthCells = Math.Max(1, widthCells);
|
||||
var safeHeightCells = Math.Max(1, heightCells);
|
||||
var maxColumn = Math.Max(0, grid.ColumnCount - safeWidthCells);
|
||||
var maxRow = Math.Max(0, grid.RowCount - safeHeightCells);
|
||||
var pitch = grid.Pitch;
|
||||
if (pitch <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var previewOrigin = Subtract(pointerInViewport, pointerOffset);
|
||||
var relativeX = previewOrigin.X - grid.Origin.X;
|
||||
var relativeY = previewOrigin.Y - grid.Origin.Y;
|
||||
|
||||
column = (int)Math.Floor(relativeX / pitch);
|
||||
row = (int)Math.Floor(relativeY / pitch);
|
||||
column = Math.Clamp(column, 0, maxColumn);
|
||||
row = Math.Clamp(row, 0, maxRow);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,7 @@
|
||||
"settings.region.language_label": "Language",
|
||||
"settings.region.language_zh": "Chinese",
|
||||
"settings.region.language_en": "English",
|
||||
"settings.region.language_ja": "Japanese",
|
||||
"settings.region.timezone_header": "Time Zone",
|
||||
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
|
||||
"settings.region.applied_format": "Language switched to: {0}",
|
||||
@@ -958,6 +959,10 @@
|
||||
"study.interrupt_density.unavailable": "--",
|
||||
"desktop.add_page": "Add 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.fit": "Fit",
|
||||
"placement.stretch": "Stretch",
|
||||
|
||||
973
LanMountainDesktop/Localization/ja-JP.json
Normal file
973
LanMountainDesktop/Localization/ja-JP.json
Normal file
@@ -0,0 +1,973 @@
|
||||
{
|
||||
"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": "すべてのコンポーネントは少なくとも1つのセルを占有する必要があります(最小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_updated_video": "動画の壁紙が更新されました。テーマカラーが更新されました。",
|
||||
"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": "デバイスID",
|
||||
"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": "検索結果から場所を1つ選択してください。",
|
||||
"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": "これらの単語を含むアラートは表示されません。1行に1つのルール。",
|
||||
"settings.weather.alert_filter_placeholder": "1行に1つのキーワード",
|
||||
"settings.weather.icon_style_header": "天気アイコンスタイル",
|
||||
"settings.weather.icon_style_desc": "天気シンボルのFluentアイコンスタイルを選択します。",
|
||||
"settings.weather.icon_style_fluent_regular": "Fluent Regular",
|
||||
"settings.weather.icon_style_fluent_filled": "Fluent Filled",
|
||||
"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": "1行に1つの除外ルール。",
|
||||
"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": "複数週スケジュールの週ローテーションサイクルを設定します(例: 奇数週/偶数週の場合は2)。",
|
||||
"schedule.settings.semester.week_cycle_format": "{0}週ローテーション",
|
||||
"worldclock.settings.title": "世界時計の設定",
|
||||
"worldclock.settings.desc": "4つの時計それぞれのタイムゾーンを選択します。",
|
||||
"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": "言語を選択し、設定と主要なUIにすぐに適用します。",
|
||||
"settings.region.language_header": "言語",
|
||||
"settings.region.language_label": "言語",
|
||||
"settings.region.language_zh": "中国語",
|
||||
"settings.region.language_en": "英語",
|
||||
"settings.region.language_ja": "日本語",
|
||||
"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": "ターシャリ",
|
||||
"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リリースを確認中...",
|
||||
"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": "画像ファイル",
|
||||
"filepicker.video_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デスクトップエントリから発見されたインストール済みアプリ",
|
||||
"launcher.empty": "スタートメニューのエントリが見つかりません。",
|
||||
"launcher.empty_linux": "Linuxデスクトップエントリが見つかりませんでした。",
|
||||
"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": "このプラグインは共有コントラクトの依存関係を宣言していません。",
|
||||
"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相互運用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": "すべてのソースを無効にすると、少なくとも1つのソースが再度有効になるまで、このウィジェットは空のままになります。",
|
||||
"component.removable_storage": "リムーバブルストレージ",
|
||||
"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": "CNRヘッドラインを取得中",
|
||||
"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": "公式ソース",
|
||||
"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.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",
|
||||
"study.environment.settings.title": "環境ウィジェットの設定",
|
||||
"study.environment.settings.desc": "右側のリアルタイムノイズ値表示を設定します。",
|
||||
"study.environment.settings.show_display_db": "表示dBを表示",
|
||||
"study.environment.settings.show_dbfs": "dBFSを表示",
|
||||
"study.environment.settings.hint": "少なくとも1つの表示モードを有効にしておく必要があります。",
|
||||
"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.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": "OK",
|
||||
"market.status.install_success_restart_format": "✓ プラグイン「{0}」が正常にインストールされました!有効にするには、アプリケーションを再起動してください。",
|
||||
"market.dialog.restart_message_format": "プラグイン「{0}」が正常にインストールされました。\n\nこのプラグインを使用するには、今すぐアプリケーションを再起動する必要があります。\n\n再起動しますか?",
|
||||
"component.settings.color_scheme": "カラースキーム"
|
||||
}
|
||||
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지금 재시작하시겠습니까?"
|
||||
}
|
||||
@@ -271,6 +271,7 @@
|
||||
"settings.region.language_label": "语言",
|
||||
"settings.region.language_zh": "中文",
|
||||
"settings.region.language_en": "英文",
|
||||
"settings.region.language_ja": "日文",
|
||||
"settings.region.timezone_header": "时区",
|
||||
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
|
||||
"settings.region.applied_format": "语言已切换为:{0}",
|
||||
@@ -952,6 +953,10 @@
|
||||
"study.interrupt_density.unavailable": "--",
|
||||
"desktop.add_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.fit": "适应",
|
||||
"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);
|
||||
}
|
||||
@@ -36,9 +36,18 @@ public sealed class LocalizationService
|
||||
|
||||
public string NormalizeLanguageCode(string? languageCode)
|
||||
{
|
||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||
? "en-US"
|
||||
: "zh-CN";
|
||||
if (string.IsNullOrWhiteSpace(languageCode))
|
||||
{
|
||||
return "zh-CN";
|
||||
}
|
||||
|
||||
return languageCode.ToLowerInvariant() switch
|
||||
{
|
||||
"en-us" or "en" => "en-US",
|
||||
"ja-jp" or "ja" => "ja-JP",
|
||||
"ko-kr" or "ko" => "ko-KR",
|
||||
_ => "zh-CN"
|
||||
};
|
||||
}
|
||||
|
||||
public string GetString(string languageCode, string key, string fallback)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Styles.Resources>
|
||||
<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.Slow">0:0:0.20</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
|
||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</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.28</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.40</x:TimeSpan>
|
||||
|
||||
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
|
||||
</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:behaviors="using:LanMountainDesktop.Behaviors">
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="RenderTransform">
|
||||
<Setter.Value>
|
||||
<TranslateTransform Y="14" />
|
||||
<TranslateTransform Y="24" />
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
||||
<Style.Animations>
|
||||
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
|
||||
<Animation Duration="0:0:0.65"
|
||||
FillMode="Both"
|
||||
Easing="0.22,1,0.36,1">
|
||||
Easing="0.05, 0.75, 0.10, 1.00">
|
||||
<KeyFrame Cue="0%">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="TranslateTransform.Y" Value="14" />
|
||||
<Setter Property="TranslateTransform.Y" Value="24" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="100%">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
@@ -53,9 +53,9 @@
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" 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.05,0.75,0.10,1.00" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -74,8 +74,8 @@
|
||||
<Style Selector=".settings-scope ComboBox">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" 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.05,0.75,0.10,1.00" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -87,8 +87,8 @@
|
||||
<Style Selector=".settings-scope ToggleSwitch">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" 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.05,0.75,0.10,1.00" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<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">
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
||||
|
||||
<Style Selector="StackPanel.settings-page-container">
|
||||
<Setter Property="Spacing" Value="0" />
|
||||
@@ -9,6 +10,34 @@
|
||||
<Setter Property="MaxWidth" Value="{DynamicResource SettingsContainerMaxWidth}" />
|
||||
</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">
|
||||
<Setter Property="FontSize" Value="30" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
@@ -39,10 +68,10 @@
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background"
|
||||
Duration="{StaticResource FluttermotionToken.Duration.Standard}"
|
||||
Easing="0.22,1,0.36,1" />
|
||||
Easing="0.05,0.75,0.10,1.00" />
|
||||
<BoxShadowsTransition Property="BoxShadow"
|
||||
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
|
||||
Easing="0.22,1,0.36,1" />
|
||||
Easing="0.05,0.75,0.10,1.00" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
@@ -5,13 +5,15 @@ namespace LanMountainDesktop.Theme;
|
||||
public static class FluttermotionToken
|
||||
{
|
||||
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
|
||||
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160);
|
||||
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(200);
|
||||
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(240);
|
||||
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(320);
|
||||
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(200);
|
||||
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(280);
|
||||
public static readonly TimeSpan Page = 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 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.ObjectModel;
|
||||
using Avalonia.Controls;
|
||||
using System.ComponentModel;
|
||||
using LanMountainDesktop.Services;
|
||||
using FluentIcons.Common;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
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; } = [];
|
||||
|
||||
@@ -38,20 +46,134 @@ public sealed class ComponentLibraryCategoryViewModel
|
||||
}
|
||||
|
||||
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(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Control? previewControl)
|
||||
ComponentPreviewKey previewKey,
|
||||
string loadingPreviewText = "Loading preview...",
|
||||
string previewUnavailableText = "Preview unavailable",
|
||||
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||
{
|
||||
ComponentId = componentId;
|
||||
DisplayName = displayName;
|
||||
PreviewControl = previewControl;
|
||||
_displayName = displayName;
|
||||
_previewKey = previewKey;
|
||||
_loadingPreviewText = loadingPreviewText;
|
||||
_previewUnavailableText = previewUnavailableText;
|
||||
_previewStatusText = loadingPreviewText;
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
public string DisplayName
|
||||
{
|
||||
get => _displayName;
|
||||
set => SetProperty(ref _displayName, value);
|
||||
}
|
||||
|
||||
public Control? PreviewControl { get; }
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,9 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
||||
return
|
||||
[
|
||||
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("ko-KR", L("settings.region.language_ko", "한국어"))
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -99,9 +99,48 @@
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
Padding="8">
|
||||
<ContentControl HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Content="{Binding PreviewControl}" />
|
||||
<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"
|
||||
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>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
|
||||
@@ -14,6 +14,10 @@ public partial class ComponentLibraryWindow : Window
|
||||
private IComponentLibraryService? _componentLibraryService;
|
||||
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
|
||||
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();
|
||||
|
||||
public ComponentLibraryWindow()
|
||||
@@ -25,12 +29,20 @@ public partial class ComponentLibraryWindow : Window
|
||||
public ComponentLibraryWindow(
|
||||
IComponentLibraryService componentLibraryService,
|
||||
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()
|
||||
{
|
||||
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
||||
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
||||
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
||||
_previewKeyResolver = previewKeyResolver;
|
||||
_previewEntryResolver = previewEntryResolver;
|
||||
_warmPreviewRequested = warmPreviewRequested;
|
||||
_renderPreviewRequested = renderPreviewRequested;
|
||||
Reload();
|
||||
}
|
||||
|
||||
@@ -38,9 +50,7 @@ public partial class ComponentLibraryWindow : Window
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
if (_componentLibraryService is null ||
|
||||
_createContextFactory is null ||
|
||||
_localize is null)
|
||||
if (_componentLibraryService is null || _localize is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -75,32 +85,26 @@ public partial class ComponentLibraryWindow : Window
|
||||
|
||||
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
|
||||
{
|
||||
if (_componentLibraryService is null ||
|
||||
_createContextFactory is null ||
|
||||
_localize is null)
|
||||
var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
||||
? entry.DisplayName
|
||||
: _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName;
|
||||
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)
|
||||
{
|
||||
return new ComponentLibraryItemViewModel(entry.ComponentId, entry.DisplayName, previewControl: null);
|
||||
_warmPreviewRequested?.Invoke(previewKey);
|
||||
_renderPreviewRequested?.Invoke(previewKey);
|
||||
}
|
||||
|
||||
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
|
||||
: _localize(entry.DisplayNameLocalizationKey, entry.DisplayName),
|
||||
previewControl);
|
||||
return item;
|
||||
}
|
||||
|
||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
@@ -118,6 +122,8 @@ public partial class ComponentLibraryWindow : Window
|
||||
{
|
||||
_viewModel.Components.Add(component);
|
||||
}
|
||||
|
||||
RequestPreviewWarmup(selectedCategory.Components);
|
||||
}
|
||||
|
||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||
@@ -140,6 +146,51 @@ public partial class ComponentLibraryWindow : Window
|
||||
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)
|
||||
{
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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"
|
||||
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
||||
<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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -428,6 +428,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
BuiltInComponentIds.DesktopIfengNews,
|
||||
"component.ifeng_news",
|
||||
() => new IfengNewsWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopJuyaNews,
|
||||
"component.juya_news",
|
||||
() => new JuyaNewsWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||
"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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
104
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
Normal file
104
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
Normal file
@@ -0,0 +1,104 @@
|
||||
<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 Symbol="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="14"
|
||||
Foreground="#bb5649" />
|
||||
<TextBlock 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>
|
||||
756
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
Normal file
756
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
Normal file
@@ -0,0 +1,756 @@
|
||||
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;
|
||||
}
|
||||
|
||||
_cachedNews.Clear();
|
||||
_loadedDates.Clear();
|
||||
_dailyViews.Clear();
|
||||
NewsStackPanel.Children.Clear();
|
||||
_earliestLoadedDate = DateTime.Today;
|
||||
|
||||
await LoadInitialNewsAsync();
|
||||
}
|
||||
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
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:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
715
LanMountainDesktop/Views/MainWindow.DesktopEditing.cs
Normal file
715
LanMountainDesktop/Views/MainWindow.DesktopEditing.cs
Normal file
@@ -0,0 +1,715 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private static readonly TimeSpan DesktopEditCommitAnimationDuration = FluttermotionToken.Standard;
|
||||
private static readonly TimeSpan DesktopEditCancelAnimationDuration = FluttermotionToken.Fast;
|
||||
|
||||
private DesktopEditSession _desktopEditSession;
|
||||
private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter;
|
||||
private Border? _desktopEditSourceHost;
|
||||
private Rect _desktopEditOriginalRect;
|
||||
private int _desktopEditStartRow;
|
||||
private int _desktopEditStartColumn;
|
||||
private int _desktopEditStartWidthCells;
|
||||
private int _desktopEditStartHeightCells;
|
||||
private int _desktopEditMinWidthCells;
|
||||
private int _desktopEditMinHeightCells;
|
||||
private int _desktopEditMaxWidthCells;
|
||||
private int _desktopEditMaxHeightCells;
|
||||
private DesktopComponentResizeMode _desktopEditResizeMode;
|
||||
private int _desktopEditOverlayVersion;
|
||||
private int _desktopEditCommitVersion;
|
||||
private bool _isDesktopEditCommitPending;
|
||||
private ComponentLibraryCollapsePresenter? _componentLibraryCollapsePresenter;
|
||||
|
||||
private bool HasActiveDesktopEditSession => _desktopEditSession.IsActive || _isDesktopEditCommitPending;
|
||||
|
||||
private bool IsDesktopEditDragMode =>
|
||||
_desktopEditSession.Mode is DesktopEditSessionMode.PendingNew or DesktopEditSessionMode.DraggingNew or DesktopEditSessionMode.DraggingExisting;
|
||||
|
||||
private bool IsDesktopEditResizeMode =>
|
||||
_desktopEditSession.Mode == DesktopEditSessionMode.ResizingExisting;
|
||||
|
||||
private void EnsureDesktopEditOverlayPresenter()
|
||||
{
|
||||
if (DesktopEditDragLayer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopEditOverlayPresenter ??= new DesktopEditOverlayPresenter();
|
||||
if (!DesktopEditDragLayer.Children.Contains(_desktopEditOverlayPresenter.Root))
|
||||
{
|
||||
DesktopEditDragLayer.Children.Clear();
|
||||
DesktopEditDragLayer.Children.Add(_desktopEditOverlayPresenter.Root);
|
||||
}
|
||||
|
||||
UpdateDesktopEditOverlayViewportSize();
|
||||
}
|
||||
|
||||
private void UpdateDesktopEditOverlayViewportSize()
|
||||
{
|
||||
if (_desktopEditOverlayPresenter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var width = Math.Max(
|
||||
DesktopPagesViewport?.Bounds.Width ?? 0,
|
||||
DesktopEditDragLayer?.Bounds.Width ?? 0);
|
||||
var height = Math.Max(
|
||||
DesktopPagesViewport?.Bounds.Height ?? 0,
|
||||
DesktopEditDragLayer?.Bounds.Height ?? 0);
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopEditOverlayPresenter.SetViewportSize(new Size(width, height));
|
||||
}
|
||||
|
||||
private void EnsureComponentLibraryCollapsePresenter()
|
||||
{
|
||||
if (_componentLibraryCollapsePresenter is not null || ComponentLibraryWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collapsedChipHost = this.FindControl<Border>("ComponentLibraryCollapsedChipHost");
|
||||
var collapsedChipTextBlock = this.FindControl<TextBlock>("ComponentLibraryCollapsedChipTextBlock");
|
||||
var collapsedChipIcon = this.FindControl<Control>("ComponentLibraryCollapsedChipIcon");
|
||||
if (collapsedChipHost is null || collapsedChipTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryCollapsePresenter = new ComponentLibraryCollapsePresenter(
|
||||
ComponentLibraryWindow,
|
||||
collapsedChipHost,
|
||||
collapsedChipTextBlock,
|
||||
collapsedChipIcon);
|
||||
}
|
||||
|
||||
private bool IsComponentLibraryTemporarilyCollapsedForDesktopEdit()
|
||||
{
|
||||
EnsureComponentLibraryCollapsePresenter();
|
||||
return _componentLibraryCollapsePresenter is not null &&
|
||||
_componentLibraryCollapsePresenter.VisualState != ComponentLibraryCollapseVisualState.Expanded;
|
||||
}
|
||||
|
||||
private void SyncComponentLibraryCollapseExpandedState()
|
||||
{
|
||||
if (!_isComponentLibraryOpen || ComponentLibraryWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureComponentLibraryCollapsePresenter();
|
||||
if (_componentLibraryCollapsePresenter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryCollapsePresenter.SyncExpandedState(ComponentLibraryWindow.Margin, ComponentLibraryWindow.Opacity);
|
||||
}
|
||||
|
||||
private void CollapseComponentLibraryForDesktopEdit(string? title)
|
||||
{
|
||||
if (!_isComponentLibraryOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureComponentLibraryCollapsePresenter();
|
||||
if (_componentLibraryCollapsePresenter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SyncComponentLibraryCollapseExpandedState();
|
||||
_componentLibraryCollapsePresenter.Collapse(ResolveComponentLibraryCollapsedChipTitle());
|
||||
}
|
||||
|
||||
private string ResolveComponentLibraryCollapsedChipTitle()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ComponentLibraryTitleTextBlock?.Text))
|
||||
{
|
||||
return ComponentLibraryTitleTextBlock.Text;
|
||||
}
|
||||
|
||||
return L("button.component_library", "Widgets");
|
||||
}
|
||||
|
||||
private void RestoreComponentLibraryAfterDesktopEdit()
|
||||
{
|
||||
EnsureComponentLibraryCollapsePresenter();
|
||||
if (_componentLibraryCollapsePresenter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryCollapsePresenter.Restore();
|
||||
}
|
||||
|
||||
private bool TryGetCurrentDesktopGridGeometry(out DesktopGridGeometry geometry)
|
||||
{
|
||||
geometry = default;
|
||||
if (_currentDesktopCellSize <= 0 ||
|
||||
_currentDesktopSurfaceIndex < 0 ||
|
||||
_currentDesktopSurfaceIndex >= _desktopPageCount ||
|
||||
!_desktopPageComponentGrids.TryGetValue(_currentDesktopSurfaceIndex, out var pageGrid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var columnCount = pageGrid.ColumnDefinitions.Count;
|
||||
var rowCount = pageGrid.RowDefinitions.Count;
|
||||
if (columnCount <= 0 || rowCount <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
geometry = new DesktopGridGeometry(
|
||||
Origin: default,
|
||||
CellSize: _currentDesktopCellSize,
|
||||
CellGap: _currentDesktopCellGap,
|
||||
ColumnCount: columnCount,
|
||||
RowCount: rowCount);
|
||||
return geometry.IsValid;
|
||||
}
|
||||
|
||||
private Rect? GetComponentLibraryBoundsInViewport()
|
||||
{
|
||||
if (!_isComponentLibraryOpen ||
|
||||
IsComponentLibraryTemporarilyCollapsedForDesktopEdit() ||
|
||||
ComponentLibraryWindow is null ||
|
||||
DesktopPagesViewport is null ||
|
||||
!ComponentLibraryWindow.IsVisible ||
|
||||
ComponentLibraryWindow.Bounds.Width <= 0 ||
|
||||
ComponentLibraryWindow.Bounds.Height <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var origin = ComponentLibraryWindow.TranslatePoint(default, DesktopPagesViewport);
|
||||
return origin.HasValue
|
||||
? new Rect(origin.Value, ComponentLibraryWindow.Bounds.Size)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static Size GetComponentPixelSize(int widthCells, int heightCells, double cellSize, double cellGap)
|
||||
{
|
||||
var safeWidthCells = Math.Max(1, widthCells);
|
||||
var safeHeightCells = Math.Max(1, heightCells);
|
||||
return new Size(
|
||||
safeWidthCells * cellSize + Math.Max(0, safeWidthCells - 1) * cellGap,
|
||||
safeHeightCells * cellSize + Math.Max(0, safeHeightCells - 1) * cellGap);
|
||||
}
|
||||
|
||||
private string ResolveDesktopEditTitle(string componentId)
|
||||
{
|
||||
return _componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor)
|
||||
? descriptor.Definition.DisplayName
|
||||
: componentId;
|
||||
}
|
||||
|
||||
private void UpdateDesktopEditOverlayMetadata(string componentId, int widthCells, int heightCells, string? detail)
|
||||
{
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
_desktopEditOverlayPresenter?.UpdateGhostContent(
|
||||
ResolveDesktopEditTitle(componentId),
|
||||
detail,
|
||||
$"{Math.Max(1, widthCells)}x{Math.Max(1, heightCells)}");
|
||||
}
|
||||
|
||||
private bool TryGetDesktopPlacementById(string? placementId, out DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
placement = null!;
|
||||
if (string.IsNullOrWhiteSpace(placementId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var matched = _desktopComponentPlacements.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||
if (matched is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
placement = matched;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SetDesktopEditSourceHost(Border? host, double opacity)
|
||||
{
|
||||
_desktopEditSourceHost = host;
|
||||
if (_desktopEditSourceHost is not null)
|
||||
{
|
||||
_desktopEditSourceHost.Opacity = opacity;
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreDesktopEditSourceHost()
|
||||
{
|
||||
if (_desktopEditSourceHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopEditSourceHost.Opacity = 1;
|
||||
ApplyDesktopEditStateToHost(_desktopEditSourceHost, _isComponentLibraryOpen);
|
||||
_desktopEditSourceHost = null;
|
||||
}
|
||||
|
||||
private void ResetDesktopEditState()
|
||||
{
|
||||
RestoreDesktopEditSourceHost();
|
||||
_desktopEditSession = default;
|
||||
_desktopEditOriginalRect = default;
|
||||
_desktopEditStartRow = 0;
|
||||
_desktopEditStartColumn = 0;
|
||||
_desktopEditStartWidthCells = 0;
|
||||
_desktopEditStartHeightCells = 0;
|
||||
_desktopEditMinWidthCells = 0;
|
||||
_desktopEditMinHeightCells = 0;
|
||||
_desktopEditMaxWidthCells = 0;
|
||||
_desktopEditMaxHeightCells = 0;
|
||||
_desktopEditResizeMode = DesktopComponentResizeMode.Proportional;
|
||||
_isDesktopEditCommitPending = false;
|
||||
|
||||
if (_desktopEditOverlayPresenter is not null)
|
||||
{
|
||||
_desktopEditOverlayPresenter.SetCandidateRect(null);
|
||||
_desktopEditOverlayPresenter.Hide();
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelDesktopEditSession(bool animate)
|
||||
{
|
||||
RestoreComponentLibraryAfterDesktopEdit();
|
||||
|
||||
if (_isDesktopEditCommitPending)
|
||||
{
|
||||
_desktopEditCommitVersion++;
|
||||
ResetDesktopEditState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_desktopEditSession.IsActive)
|
||||
{
|
||||
ResetDesktopEditState();
|
||||
return;
|
||||
}
|
||||
|
||||
var version = ++_desktopEditOverlayVersion;
|
||||
if (animate && _desktopEditOverlayPresenter is not null)
|
||||
{
|
||||
_desktopEditOverlayPresenter.Cancel();
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (version != _desktopEditOverlayVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ResetDesktopEditState();
|
||||
},
|
||||
DesktopEditCancelAnimationDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
ResetDesktopEditState();
|
||||
}
|
||||
|
||||
private bool CanCommitDesktopEditAtRect(Rect finalRect)
|
||||
{
|
||||
return DesktopPlacementMath.CanCommitPlacement(finalRect, GetComponentLibraryBoundsInViewport());
|
||||
}
|
||||
|
||||
private void RunDesktopEditCommit(Rect finalRect, Action commitAction)
|
||||
{
|
||||
_isDesktopEditCommitPending = true;
|
||||
var overlayVersion = ++_desktopEditOverlayVersion;
|
||||
var scheduledCommitVersion = ++_desktopEditCommitVersion;
|
||||
_desktopEditOverlayPresenter?.Commit();
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (overlayVersion != _desktopEditOverlayVersion ||
|
||||
!DesktopEditCommitMath.IsPendingCommitValid(
|
||||
_isDesktopEditCommitPending,
|
||||
scheduledCommitVersion,
|
||||
_desktopEditCommitVersion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CanCommitDesktopEditAtRect(finalRect))
|
||||
{
|
||||
RestoreComponentLibraryAfterDesktopEdit();
|
||||
ResetDesktopEditState();
|
||||
return;
|
||||
}
|
||||
|
||||
commitAction();
|
||||
RestoreComponentLibraryAfterDesktopEdit();
|
||||
ResetDesktopEditState();
|
||||
},
|
||||
DesktopEditCommitAnimationDuration);
|
||||
}
|
||||
|
||||
private void UpdateDesktopEditSession(Point pointerInViewport)
|
||||
{
|
||||
if (_isDesktopEditCommitPending || !_desktopEditSession.IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopEditSession = _desktopEditSession
|
||||
.WithCurrentPointer(pointerInViewport)
|
||||
.WithComponentLibraryBounds(GetComponentLibraryBoundsInViewport());
|
||||
|
||||
switch (_desktopEditSession.Mode)
|
||||
{
|
||||
case DesktopEditSessionMode.PendingNew:
|
||||
PromotePendingNewDesktopEditIfNeeded();
|
||||
break;
|
||||
case DesktopEditSessionMode.DraggingNew:
|
||||
case DesktopEditSessionMode.DraggingExisting:
|
||||
UpdateActiveDesktopDragPreview();
|
||||
break;
|
||||
case DesktopEditSessionMode.ResizingExisting:
|
||||
UpdateActiveDesktopResizePreview();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void PromotePendingNewDesktopEditIfNeeded()
|
||||
{
|
||||
var threshold = DesktopPlacementMath.ComputeDragStartThreshold(_currentDesktopCellSize);
|
||||
if (!_desktopEditSession.HasExceededThreshold(threshold) ||
|
||||
_desktopEditSession.IsPointerInsideComponentLibrary())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopEditSession = _desktopEditSession.PromoteToDraggingNew();
|
||||
CollapseComponentLibraryForDesktopEdit(ResolveDesktopEditTitle(_desktopEditSession.ComponentId ?? string.Empty));
|
||||
_desktopEditSession = _desktopEditSession.WithComponentLibraryBounds(GetComponentLibraryBoundsInViewport());
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
_desktopEditOverlayPresenter?.Show(DesktopEditGhostVisualStyle.ElevatedFromLibrary);
|
||||
UpdateActiveDesktopDragPreview();
|
||||
}
|
||||
|
||||
private void UpdateActiveDesktopDragPreview()
|
||||
{
|
||||
if (_desktopEditSession.Mode is not (DesktopEditSessionMode.DraggingNew or DesktopEditSessionMode.DraggingExisting) ||
|
||||
!TryGetCurrentDesktopGridGeometry(out var grid) ||
|
||||
DesktopPagesViewport is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
|
||||
var previewSize = GetComponentPixelSize(
|
||||
_desktopEditSession.WidthCells,
|
||||
_desktopEditSession.HeightCells,
|
||||
_currentDesktopCellSize,
|
||||
_currentDesktopCellGap);
|
||||
var previewOrigin = DesktopPlacementMath.Subtract(
|
||||
_desktopEditSession.CurrentPointerInViewport,
|
||||
_desktopEditSession.PointerOffsetInViewport);
|
||||
var previewRect = new Rect(previewOrigin, previewSize);
|
||||
var hasSnap = DesktopPlacementMath.TryGetSnappedCell(
|
||||
grid,
|
||||
_desktopEditSession.CurrentPointerInViewport,
|
||||
_desktopEditSession.PointerOffsetInViewport,
|
||||
_desktopEditSession.WidthCells,
|
||||
_desktopEditSession.HeightCells,
|
||||
out var column,
|
||||
out var row);
|
||||
var snappedRect = hasSnap
|
||||
? DesktopPlacementMath.GetCellRect(grid, column, row, _desktopEditSession.WidthCells, _desktopEditSession.HeightCells)
|
||||
: default;
|
||||
var withinViewport =
|
||||
_desktopEditSession.CurrentPointerInViewport.X >= 0 &&
|
||||
_desktopEditSession.CurrentPointerInViewport.Y >= 0 &&
|
||||
_desktopEditSession.CurrentPointerInViewport.X <= DesktopPagesViewport.Bounds.Width &&
|
||||
_desktopEditSession.CurrentPointerInViewport.Y <= DesktopPagesViewport.Bounds.Height;
|
||||
var occludedByLibrary =
|
||||
_desktopEditSession.IsPointerInsideComponentLibrary() ||
|
||||
_desktopEditSession.IsPreviewOccludedByComponentLibrary(previewRect);
|
||||
var canDrop = withinViewport && hasSnap && !occludedByLibrary;
|
||||
|
||||
_desktopEditSession = canDrop
|
||||
? _desktopEditSession.WithTargetCell(row, column)
|
||||
: _desktopEditSession with { TargetRow = -1, TargetColumn = -1 };
|
||||
|
||||
_desktopEditOverlayPresenter?.SetPreviewRect(previewRect);
|
||||
_desktopEditOverlayPresenter?.SetCandidateRect(canDrop ? snappedRect : null);
|
||||
_desktopEditOverlayPresenter?.SetInvalid(!canDrop);
|
||||
}
|
||||
|
||||
private void UpdateActiveDesktopResizePreview()
|
||||
{
|
||||
if (_desktopEditSession.Mode != DesktopEditSessionMode.ResizingExisting ||
|
||||
!TryGetCurrentDesktopGridGeometry(out var grid) ||
|
||||
!TryGetDesktopPlacementById(_desktopEditSession.PlacementId, out var placement))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
|
||||
var deltaX = _desktopEditSession.CurrentPointerInViewport.X - _desktopEditSession.StartPointerInViewport.X;
|
||||
var deltaY = _desktopEditSession.CurrentPointerInViewport.Y - _desktopEditSession.StartPointerInViewport.Y;
|
||||
|
||||
var minSize = GetComponentPixelSize(
|
||||
_desktopEditMinWidthCells,
|
||||
_desktopEditMinHeightCells,
|
||||
_currentDesktopCellSize,
|
||||
_currentDesktopCellGap);
|
||||
var maxSize = GetComponentPixelSize(
|
||||
_desktopEditMaxWidthCells,
|
||||
_desktopEditMaxHeightCells,
|
||||
_currentDesktopCellSize,
|
||||
_currentDesktopCellGap);
|
||||
|
||||
double previewWidth;
|
||||
double previewHeight;
|
||||
int widthCells;
|
||||
int heightCells;
|
||||
|
||||
if (_desktopEditResizeMode == DesktopComponentResizeMode.Free)
|
||||
{
|
||||
previewWidth = Math.Clamp(_desktopEditOriginalRect.Width + deltaX, minSize.Width, maxSize.Width);
|
||||
previewHeight = Math.Clamp(_desktopEditOriginalRect.Height + deltaY, minSize.Height, maxSize.Height);
|
||||
widthCells = Math.Clamp(
|
||||
(int)Math.Round(_desktopEditStartWidthCells + deltaX / CurrentDesktopPitch),
|
||||
_desktopEditMinWidthCells,
|
||||
_desktopEditMaxWidthCells);
|
||||
heightCells = Math.Clamp(
|
||||
(int)Math.Round(_desktopEditStartHeightCells + deltaY / CurrentDesktopPitch),
|
||||
_desktopEditMinHeightCells,
|
||||
_desktopEditMaxHeightCells);
|
||||
}
|
||||
else
|
||||
{
|
||||
var widthScale = (_desktopEditOriginalRect.Width + deltaX) / Math.Max(1, _desktopEditOriginalRect.Width);
|
||||
var heightScale = (_desktopEditOriginalRect.Height + deltaY) / Math.Max(1, _desktopEditOriginalRect.Height);
|
||||
var proposedScale = Math.Max(widthScale, heightScale);
|
||||
var minScale = Math.Max(
|
||||
(double)_desktopEditMinWidthCells / Math.Max(1, _desktopEditStartWidthCells),
|
||||
(double)_desktopEditMinHeightCells / Math.Max(1, _desktopEditStartHeightCells));
|
||||
var maxScale = Math.Min(
|
||||
(double)_desktopEditMaxWidthCells / Math.Max(1, _desktopEditStartWidthCells),
|
||||
(double)_desktopEditMaxHeightCells / Math.Max(1, _desktopEditStartHeightCells));
|
||||
|
||||
if (double.IsNaN(proposedScale) || double.IsInfinity(proposedScale))
|
||||
{
|
||||
proposedScale = minScale;
|
||||
}
|
||||
|
||||
if (maxScale < minScale)
|
||||
{
|
||||
maxScale = minScale;
|
||||
}
|
||||
|
||||
var scale = Math.Clamp(proposedScale, minScale, maxScale);
|
||||
previewWidth = Math.Clamp(_desktopEditOriginalRect.Width * scale, minSize.Width, maxSize.Width);
|
||||
previewHeight = Math.Clamp(_desktopEditOriginalRect.Height * scale, minSize.Height, maxSize.Height);
|
||||
widthCells = Math.Clamp(
|
||||
(int)Math.Round(_desktopEditStartWidthCells * scale),
|
||||
_desktopEditMinWidthCells,
|
||||
_desktopEditMaxWidthCells);
|
||||
heightCells = Math.Clamp(
|
||||
(int)Math.Round(_desktopEditStartHeightCells * scale),
|
||||
_desktopEditMinHeightCells,
|
||||
_desktopEditMaxHeightCells);
|
||||
}
|
||||
|
||||
var normalized = NormalizeComponentCellSpan(_desktopEditSession.ComponentId ?? string.Empty, (widthCells, heightCells));
|
||||
widthCells = Math.Clamp(normalized.WidthCells, _desktopEditMinWidthCells, _desktopEditMaxWidthCells);
|
||||
heightCells = Math.Clamp(normalized.HeightCells, _desktopEditMinHeightCells, _desktopEditMaxHeightCells);
|
||||
|
||||
var previewRect = new Rect(_desktopEditOriginalRect.X, _desktopEditOriginalRect.Y, previewWidth, previewHeight);
|
||||
var snappedRect = DesktopPlacementMath.GetCellRect(grid, placement.Column, placement.Row, widthCells, heightCells);
|
||||
var occludedByLibrary =
|
||||
_desktopEditSession.IsPointerInsideComponentLibrary() ||
|
||||
DesktopPlacementMath.IsOccludedByComponentLibrary(previewRect, _desktopEditSession.ComponentLibraryBounds);
|
||||
var canCommit = !occludedByLibrary;
|
||||
|
||||
_desktopEditSession = (_desktopEditSession with
|
||||
{
|
||||
WidthCells = widthCells,
|
||||
HeightCells = heightCells,
|
||||
TargetRow = canCommit ? placement.Row : -1,
|
||||
TargetColumn = canCommit ? placement.Column : -1
|
||||
}).WithComponentLibraryBounds(GetComponentLibraryBoundsInViewport());
|
||||
|
||||
UpdateDesktopEditOverlayMetadata(
|
||||
_desktopEditSession.ComponentId ?? placement.ComponentId,
|
||||
widthCells,
|
||||
heightCells,
|
||||
L("component.resize", "Resize"));
|
||||
_desktopEditOverlayPresenter?.SetPreviewRect(previewRect);
|
||||
_desktopEditOverlayPresenter?.SetCandidateRect(canCommit ? snappedRect : null);
|
||||
_desktopEditOverlayPresenter?.SetInvalid(!canCommit);
|
||||
}
|
||||
|
||||
private bool CompleteDesktopEditSession(Point pointerInViewport)
|
||||
{
|
||||
if (_isDesktopEditCommitPending || !_desktopEditSession.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UpdateDesktopEditSession(pointerInViewport);
|
||||
|
||||
switch (_desktopEditSession.Mode)
|
||||
{
|
||||
case DesktopEditSessionMode.DraggingNew:
|
||||
return CompleteNewDesktopComponentDrag();
|
||||
case DesktopEditSessionMode.DraggingExisting:
|
||||
return CompleteExistingDesktopComponentMove();
|
||||
case DesktopEditSessionMode.ResizingExisting:
|
||||
return CompleteExistingDesktopComponentResize();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CompleteNewDesktopComponentDrag()
|
||||
{
|
||||
if (!_desktopEditSession.HasTargetCell ||
|
||||
string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) ||
|
||||
!TryGetCurrentDesktopGridGeometry(out var grid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var finalRect = _desktopEditSession.GetPreviewRect(grid);
|
||||
if (!CanCommitDesktopEditAtRect(finalRect))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_desktopEditOverlayPresenter?.SetPreviewRect(finalRect);
|
||||
_desktopEditOverlayPresenter?.SetCandidateRect(finalRect);
|
||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||
|
||||
var componentId = _desktopEditSession.ComponentId;
|
||||
var pageIndex = _desktopEditSession.PageIndex;
|
||||
var row = _desktopEditSession.TargetRow;
|
||||
var column = _desktopEditSession.TargetColumn;
|
||||
RunDesktopEditCommit(finalRect, () => PlaceDesktopComponentOnPage(componentId, pageIndex, row, column));
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompleteExistingDesktopComponentMove()
|
||||
{
|
||||
if (!_desktopEditSession.HasTargetCell ||
|
||||
string.IsNullOrWhiteSpace(_desktopEditSession.PlacementId) ||
|
||||
!TryGetCurrentDesktopGridGeometry(out var grid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var finalRect = _desktopEditSession.GetPreviewRect(grid);
|
||||
if (!CanCommitDesktopEditAtRect(finalRect))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_desktopEditOverlayPresenter?.SetPreviewRect(finalRect);
|
||||
_desktopEditOverlayPresenter?.SetCandidateRect(finalRect);
|
||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||
|
||||
var placementId = _desktopEditSession.PlacementId;
|
||||
var row = _desktopEditSession.TargetRow;
|
||||
var column = _desktopEditSession.TargetColumn;
|
||||
if (!DesktopPlacementMath.HasCellPositionChanged(_desktopEditStartRow, _desktopEditStartColumn, row, column))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RunDesktopEditCommit(finalRect, () => TryMoveExistingDesktopComponent(placementId, row, column));
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompleteExistingDesktopComponentResize()
|
||||
{
|
||||
if (!_desktopEditSession.HasTargetCell ||
|
||||
string.IsNullOrWhiteSpace(_desktopEditSession.PlacementId) ||
|
||||
!TryGetCurrentDesktopGridGeometry(out var grid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var finalRect = _desktopEditSession.GetPreviewRect(grid);
|
||||
if (!CanCommitDesktopEditAtRect(finalRect))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_desktopEditOverlayPresenter?.SetPreviewRect(finalRect);
|
||||
_desktopEditOverlayPresenter?.SetCandidateRect(finalRect);
|
||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||
|
||||
var placementId = _desktopEditSession.PlacementId;
|
||||
var widthCells = Math.Max(1, _desktopEditSession.WidthCells);
|
||||
var heightCells = Math.Max(1, _desktopEditSession.HeightCells);
|
||||
if (!DesktopPlacementMath.HasCellSpanChanged(_desktopEditStartWidthCells, _desktopEditStartHeightCells, widthCells, heightCells))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RunDesktopEditCommit(finalRect, () => ApplyExistingDesktopComponentResize(placementId, widthCells, heightCells));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ApplyExistingDesktopComponentResize(string placementId, int widthCells, int heightCells)
|
||||
{
|
||||
if (!TryGetDesktopPlacementById(placementId, out var placement))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var before = ClonePlacementSnapshot(placement);
|
||||
var changed = placement.WidthCells != widthCells || placement.HeightCells != heightCells;
|
||||
placement.WidthCells = widthCells;
|
||||
placement.HeightCells = heightCells;
|
||||
|
||||
if (_desktopEditSourceHost is not null)
|
||||
{
|
||||
Grid.SetColumnSpan(_desktopEditSourceHost, widthCells);
|
||||
Grid.SetRowSpan(_desktopEditSourceHost, heightCells);
|
||||
ApplyDesktopEditStateToHost(_desktopEditSourceHost, _isComponentLibraryOpen);
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QueuePlacementPreviewRefresh(placement);
|
||||
PersistSettings();
|
||||
TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -200,6 +200,7 @@ public partial class MainWindow
|
||||
{
|
||||
DesktopEditDragLayer.Width = pageWidth;
|
||||
DesktopEditDragLayer.Height = pageHeight;
|
||||
UpdateDesktopEditOverlayViewportSize();
|
||||
}
|
||||
|
||||
DesktopPagesHost.RowDefinitions.Clear();
|
||||
@@ -486,8 +487,7 @@ public partial class MainWindow
|
||||
{
|
||||
return !_isSettingsOpen &&
|
||||
!_isComponentLibraryOpen &&
|
||||
!_isDesktopComponentDragActive &&
|
||||
!_isDesktopComponentResizeActive &&
|
||||
!HasActiveDesktopEditSession &&
|
||||
_desktopSurfacePageWidth > 1;
|
||||
}
|
||||
|
||||
@@ -552,7 +552,6 @@ public partial class MainWindow
|
||||
{
|
||||
if (node is Control control)
|
||||
{
|
||||
// Avoid swiping pages when interacting with desktop components/widgets.
|
||||
if (control.Classes.Contains("desktop-component") ||
|
||||
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;
|
||||
}
|
||||
@@ -611,7 +634,17 @@ public partial class MainWindow
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -625,13 +658,23 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
var typeName = node.GetType().Name;
|
||||
return typeName.Contains("Button", StringComparison.OrdinalIgnoreCase) ||
|
||||
typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
|
||||
return typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
|
||||
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
|
||||
typeName.Contains("NumericUpDown", 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)
|
||||
{
|
||||
point = default;
|
||||
|
||||
@@ -243,6 +243,15 @@
|
||||
|
||||
<Canvas x:Name="DesktopEditDragLayer"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Canvas x:Name="ComponentPreviewStagingHost"
|
||||
Width="1"
|
||||
Height="1"
|
||||
Opacity="0"
|
||||
ClipToBounds="True"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
IsHitTestVisible="False" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -588,6 +597,44 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="ComponentLibraryCollapsedChipHost"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,88"
|
||||
Padding="14,10"
|
||||
CornerRadius="999"
|
||||
Classes="surface-translucent-strong"
|
||||
BorderBrush="{DynamicResource AdaptiveDockGlassBorderBrush}"
|
||||
BorderThickness="1"
|
||||
Opacity="0">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="10">
|
||||
<fi:FluentIcon x:Name="ComponentLibraryCollapsedChipIcon"
|
||||
Grid.Column="0"
|
||||
Icon="Apps"
|
||||
IconVariant="Regular"
|
||||
Width="18"
|
||||
Height="18"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock x:Name="ComponentLibraryCollapsedChipTextBlock"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="Widgets" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
</Window>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.AppearanceSettingsPage"
|
||||
x:DataType="vm:AppearanceSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<controls:IconText Icon="Color"
|
||||
Text="{Binding ThemeHeader}"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.ComponentsSettingsPage"
|
||||
x:DataType="vm:ComponentsSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<controls:IconText Icon="Apps"
|
||||
Text="{Binding ComponentsHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.GeneralSettingsPage"
|
||||
x:DataType="vm:GeneralSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<!-- 区域设置分组 -->
|
||||
<controls:IconText Icon="Globe"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.GeneratedPluginSettingsPage"
|
||||
x:DataType="vm:PluginGeneratedSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<TextBlock Classes="settings-section-title"
|
||||
Text="{Binding Title}" />
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
|
||||
x:DataType="vm:LauncherSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<Border Classes="settings-section-card">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
x:Name="Root"
|
||||
x:DataType="vm:PluginMarketSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||
Description="{Binding StatusMessage}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
x:Name="Root"
|
||||
x:DataType="vm:PluginsSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||
Description="{Binding StatusMessage}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacySettingsPage"
|
||||
x:DataType="vm:PrivacySettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<controls:IconText Icon="Info"
|
||||
Text="{Binding PrivacyHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.StatusBarSettingsPage"
|
||||
x:DataType="vm:StatusBarSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<controls:IconText Icon="Apps"
|
||||
Text="{Binding ComponentsHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</UserControl.Styles>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<TextBlock Classes="settings-section-title"
|
||||
Text="{Binding PageTitle}" />
|
||||
<TextBlock Classes="settings-section-description"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage"
|
||||
x:DataType="vm:WallpaperSettingsPageViewModel">
|
||||
<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">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage"
|
||||
x:DataType="vm:WeatherSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
|
||||
<Border Classes="settings-section-card">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsWindow"
|
||||
x:DataType="vm:SettingsWindowViewModel"
|
||||
Width="1120"
|
||||
@@ -36,7 +36,8 @@
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid Classes="settings-scope"
|
||||
<Grid x:Name="RootGrid"
|
||||
Classes="settings-scope"
|
||||
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
|
||||
RowDefinitions="Auto,*">
|
||||
<Border x:Name="WindowTitleBarHost"
|
||||
@@ -50,15 +51,14 @@
|
||||
ColumnSpacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button x:Name="TogglePaneButton"
|
||||
Width="40"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Classes="pane-toggle-button"
|
||||
Click="OnTogglePaneButtonClick">
|
||||
<fi:FluentIcon x:Name="TogglePaneButtonIcon"
|
||||
Icon="PanelLeftExpand"
|
||||
IconVariant="Regular" />
|
||||
<Grid>
|
||||
<fi:FluentIcon x:Name="TogglePaneButtonIcon"
|
||||
Icon="Navigation"
|
||||
IconVariant="Regular"
|
||||
FontSize="16" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<fi:FluentIcon x:Name="WindowBrandIcon"
|
||||
|
||||
@@ -32,7 +32,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle;
|
||||
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
||||
private readonly Dictionary<string, Control> _cachedPages = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly bool _useSystemChrome;
|
||||
private bool _useSystemChrome;
|
||||
private bool _isResponsiveRefreshPending;
|
||||
private bool _isRestartPromptVisible;
|
||||
|
||||
@@ -152,12 +152,19 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
||||
|
||||
public void ApplyChromeMode(bool useSystemChrome)
|
||||
{
|
||||
if (useSystemChrome || OperatingSystem.IsMacOS())
|
||||
_useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();
|
||||
|
||||
if (_useSystemChrome)
|
||||
{
|
||||
ExtendClientAreaToDecorationsHint = true;
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
||||
ExtendClientAreaTitleBarHeightHint = -1;
|
||||
SystemDecorations = SystemDecorations.Full;
|
||||
|
||||
if (WindowTitleBarHost is { })
|
||||
{
|
||||
WindowTitleBarHost.IsVisible = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -165,6 +172,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
||||
ExtendClientAreaToDecorationsHint = true;
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
||||
ExtendClientAreaTitleBarHeightHint = 48;
|
||||
|
||||
if (WindowTitleBarHost is { })
|
||||
{
|
||||
WindowTitleBarHost.IsVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshShellText()
|
||||
@@ -563,10 +575,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TogglePaneButtonIcon.Icon = RootNavigationView.IsPaneOpen
|
||||
? FluentIcons.Common.Icon.PanelLeftContract
|
||||
: FluentIcons.Common.Icon.PanelLeftExpand;
|
||||
}
|
||||
|
||||
private void UpdateChromeMetrics()
|
||||
|
||||
@@ -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
|
||||
- official sample plugin release source
|
||||
- independent ecosystem documentation hub
|
||||
基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
|
||||
|
||||
## 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
|
||||
- `LanMountainDesktop.PluginTemplate`: official `dotnet new` template package (`shortName`: `lmd-plugin`)
|
||||
### 🔌 插件生态
|
||||
- 通过 `.laapp` 插件扩展功能
|
||||
- 官方 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`
|
||||
|
||||
76
docs/ARCHITECTURE.md
Normal file
76
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 架构文档 / Architecture
|
||||
|
||||
## 中文
|
||||
|
||||
### 仓库结构
|
||||
|
||||
| 路径 | 角色 |
|
||||
| --- | --- |
|
||||
| `LanMountainDesktop/` | 主桌面宿主应用,包含 UI、服务、组件系统、插件运行时接入 |
|
||||
| `LanMountainDesktop.PluginSdk/` | 官方插件 SDK,定义插件可依赖的公开接口与打包行为 |
|
||||
| `LanMountainDesktop.Shared.Contracts/` | 宿主与插件共享的稳定契约类型 |
|
||||
| `LanMountainDesktop.Appearance/` | 主题、圆角、外观资源相关基础设施 |
|
||||
| `LanMountainDesktop.Settings.Core/` | 设置域、持久化和设置基础抽象 |
|
||||
| `LanMountainDesktop.DesktopHost/` | 桌面宿主流程与生命周期相关逻辑 |
|
||||
| `LanMountainDesktop.DesktopComponents.Runtime/` | 组件运行时支撑能力 |
|
||||
| `LanMountainDesktop.Host.Abstractions/` | 宿主侧抽象接口 |
|
||||
| `LanMountainDesktop.PluginsInstallHelper/` | 插件安装辅助程序与发布输出配套 |
|
||||
| `LanMountainDesktop.PluginTemplate/` | `dotnet new lmd-plugin` 官方模板 |
|
||||
| `LanMountainDesktop.Tests/` | 宿主与 SDK 的测试项目 |
|
||||
|
||||
### 宿主启动主线
|
||||
|
||||
启动入口在 `LanMountainDesktop/Program.cs`:
|
||||
|
||||
1. 初始化日志、单实例锁和启动诊断
|
||||
2. 初始化遥测身份、崩溃遥测与使用遥测
|
||||
3. 构建 Avalonia `AppBuilder`
|
||||
4. 进入 `LanMountainDesktop/App.axaml.cs`
|
||||
5. 初始化主题、语言、设置窗口服务、天气定位刷新
|
||||
6. 初始化桌面壳层、主窗口、托盘、插件运行时
|
||||
|
||||
### 运行时主数据流
|
||||
|
||||
- 设置流:`Settings.Core` 提供基础设置能力,宿主通过 facade 读取和监听设置变化
|
||||
- 外观流:`Appearance` 提供主题和圆角资源,宿主在 `App.axaml.cs` 中应用到资源字典
|
||||
- 组件流:`LanMountainDesktop/ComponentSystem/` 维护内置组件定义、注册和扩展接入
|
||||
- 插件流:宿主侧 `plugins/` 负责 `.laapp` 的发现、安装、替换、激活与共享契约装配
|
||||
- 设置页流:插件运行时可把自己的设置页注册进宿主设置窗口
|
||||
|
||||
### 关键目录落点
|
||||
|
||||
`LanMountainDesktop/` 内高频目录:
|
||||
|
||||
- `Views/`:窗口、页面、组件视图
|
||||
- `ViewModels/`:视图模型
|
||||
- `Services/`:业务服务、持久化、启动、遥测等
|
||||
- `ComponentSystem/`:组件定义、注册、扩展加载
|
||||
- `plugins/`:宿主侧插件运行时
|
||||
- `Theme/` 与 `Styles/`:主题资源、样式、外观应用
|
||||
- `DesktopEditing/`:桌面布局编辑相关逻辑
|
||||
- `Localization/`:本地化资源
|
||||
|
||||
### 插件边界
|
||||
|
||||
- 插件 SDK 权威定义在 `LanMountainDesktop.PluginSdk/`
|
||||
- 宿主与插件共享的稳定通信类型在 `LanMountainDesktop.Shared.Contracts/`
|
||||
- 插件市场和开发者生态资料不在本仓库维护
|
||||
- 本地 market 调试从兄弟仓库 `..\\LanAirApp` 读取数据
|
||||
|
||||
### 测试边界
|
||||
|
||||
`LanMountainDesktop.Tests/` 当前主要覆盖:
|
||||
|
||||
- 圆角与外观相关基线
|
||||
- 组件放置与编辑数学
|
||||
- 组件设置服务
|
||||
- UI 异常防护
|
||||
- 白板笔记持久化
|
||||
|
||||
涉及宿主行为、SDK 契约、布局计算或设置持久化的改动,应优先补对应测试。
|
||||
|
||||
## English
|
||||
|
||||
This repository is organized around a desktop host app plus a host-side plugin ecosystem. `LanMountainDesktop/` contains the application entry points, UI, services, component system, and plugin runtime integration. The surrounding projects provide the public SDK, shared contracts, appearance infrastructure, settings primitives, host abstractions, runtime support, and tests.
|
||||
|
||||
The runtime flow starts in `Program.cs`, proceeds into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
|
||||
63
docs/CONTRIBUTING.md
Normal file
63
docs/CONTRIBUTING.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 协作文档 / Contributing
|
||||
|
||||
## 中文
|
||||
|
||||
### 适用范围
|
||||
|
||||
本文件适用于本仓库内的代码、文档、规格与测试协作。
|
||||
|
||||
### 基本流程
|
||||
|
||||
1. 先阅读 `README.md`、`docs/ARCHITECTURE.md` 和 `docs/DEVELOPMENT.md`
|
||||
2. 如果是新功能、行为变更或跨模块调整,先检查是否需要补 `.trae/specs/`
|
||||
3. 实现代码改动时,尽量同时补测试和必要文档
|
||||
4. 提交 PR 前,至少确认构建、测试和相关文档链接可用
|
||||
|
||||
### 什么时候必须更新 spec
|
||||
|
||||
以下改动默认要补或更新 `.trae/specs/<feature>/`:
|
||||
|
||||
- 新增用户可见功能
|
||||
- 修改已有功能行为、交互或规则
|
||||
- 调整设置页信息架构或主要视觉结构
|
||||
- 修改插件宿主集成方式、共享契约或 SDK 使用模式
|
||||
|
||||
如果只是小范围重构、纯修复拼写、或不改变行为的内部清理,可以不新增 spec,但仍要补必要测试。
|
||||
|
||||
### 什么时候必须更新文档
|
||||
|
||||
- 产品定位、版本阶段、生态边界变化:更新 `docs/PRODUCT.md`
|
||||
- 仓库结构、模块职责、运行时边界变化:更新 `docs/ARCHITECTURE.md`
|
||||
- 构建、运行、测试、打包步骤变化:更新 `docs/DEVELOPMENT.md`
|
||||
- AI 协作入口、代码地图、执行约束变化:更新 `AGENTS.md` 或 `docs/ai/`
|
||||
- 视觉或圆角规则变化:更新对应专题文档
|
||||
|
||||
### PR 预期
|
||||
|
||||
PR 说明至少要覆盖:
|
||||
|
||||
- 改了什么
|
||||
- 为什么要改
|
||||
- 如何验证
|
||||
- 是否影响文档、spec 或迁移说明
|
||||
|
||||
如果改动涉及 UI、插件、设置页、打包或共享契约,建议明确列出受影响区域。
|
||||
|
||||
### 测试预期
|
||||
|
||||
默认至少执行与改动相关的验证:
|
||||
|
||||
- `dotnet build LanMountainDesktop.slnx -c Debug`
|
||||
- `dotnet test LanMountainDesktop.slnx -c Debug`
|
||||
|
||||
无法运行的检查要在 PR 里说明原因。
|
||||
|
||||
### 文档原则
|
||||
|
||||
- 每类事实只保留一个权威来源
|
||||
- 根目录 `README.md` 面向人类入口,`AGENTS.md` 面向 AI 入口
|
||||
- 不要在多个文件里复制同一段说明,只保留索引和跳转
|
||||
|
||||
## English
|
||||
|
||||
Keep the documentation model simple: `README.md` is the human entry point, `AGENTS.md` is the AI entry point, `docs/` stores durable project docs, and `.trae/specs/` stores feature-level specs. If a change affects behavior, boundaries, or workflows, update the corresponding source-of-truth document in the same PR.
|
||||
81
docs/DEVELOPMENT.md
Normal file
81
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 开发文档 / Development
|
||||
|
||||
## 中文
|
||||
|
||||
### 环境准备
|
||||
|
||||
- 安装 `.NET SDK 10`
|
||||
- 桌面端建议优先在 Windows 上开发和验证
|
||||
- 仓库主入口解决方案文件为 `LanMountainDesktop.slnx`
|
||||
- SDK 版本由仓库根目录 `global.json` 锁定
|
||||
|
||||
### 常用命令
|
||||
|
||||
#### 还原与构建
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
#### 运行桌面宿主
|
||||
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
#### 运行测试
|
||||
|
||||
```bash
|
||||
dotnet test LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
### 常见工作区域
|
||||
|
||||
- 宿主应用:`LanMountainDesktop/`
|
||||
- Plugin SDK:`LanMountainDesktop.PluginSdk/`
|
||||
- 共享契约:`LanMountainDesktop.Shared.Contracts/`
|
||||
- 测试:`LanMountainDesktop.Tests/`
|
||||
- 插件打包脚本:`scripts/Pack-PluginPackages.ps1`
|
||||
|
||||
### 调试建议
|
||||
|
||||
- 启动问题优先看 `LanMountainDesktop/Program.cs` 和 `LanMountainDesktop/App.axaml.cs`
|
||||
- 设置窗口和设置页问题优先看 `LanMountainDesktop/Views/`、`ViewModels/` 与相关 `Services/`
|
||||
- 插件加载与安装问题优先看 `LanMountainDesktop/plugins/`
|
||||
- 组件元数据或可放置规则问题优先看 `LanMountainDesktop/ComponentSystem/`
|
||||
|
||||
### 常见问题
|
||||
|
||||
- 如果提示 SDK 版本不匹配,先检查 `dotnet --info`
|
||||
- 如果视频或 WebView 能力异常,优先在 Windows 环境验证
|
||||
- 如果需要重置本地配置,可删除 `%LOCALAPPDATA%\\LanMountainDesktop\\settings.json` 后重启
|
||||
- 如果需要验证插件打包或本地 feed,使用 `scripts/Pack-PluginPackages.ps1`
|
||||
|
||||
### Linux 录音依赖
|
||||
|
||||
如果在 Linux 上使用录音机或自习监测相关能力,需要安装音频库:
|
||||
|
||||
- Debian/Ubuntu:`sudo apt install libportaudio2 libasound2`
|
||||
- Fedora/RHEL:`sudo dnf install portaudio-libs alsa-lib`
|
||||
- Arch Linux:`sudo pacman -S portaudio alsa-lib`
|
||||
- Alpine Linux:`sudo apk add portaudio alsa-lib`
|
||||
|
||||
### 打包入口
|
||||
|
||||
- 桌面宿主打包说明:`LanMountainDesktop/PACKAGING.md`
|
||||
- 插件相关本地包生成:`scripts/Pack-PluginPackages.ps1`
|
||||
- CI 和工作流说明:`.github/README.md` 与相关 workflow 文档
|
||||
|
||||
### 文档协作约定
|
||||
|
||||
- 产品信息更新到 `docs/PRODUCT.md`
|
||||
- 架构边界更新到 `docs/ARCHITECTURE.md`
|
||||
- 需求与实施拆解更新到 `.trae/specs/`
|
||||
- AI 协作入口和代码地图更新到 `AGENTS.md` 与 `docs/ai/`
|
||||
|
||||
## English
|
||||
|
||||
Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is `dotnet restore`, `dotnet build LanMountainDesktop.slnx -c Debug`, `dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj`, and `dotnet test LanMountainDesktop.slnx -c Debug`.
|
||||
|
||||
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
|
||||
556
docs/JUYA_NEWS_DESIGN.md
Normal file
556
docs/JUYA_NEWS_DESIGN.md
Normal file
@@ -0,0 +1,556 @@
|
||||
# 橘鸦新闻组件 UI 设计文档
|
||||
|
||||
## 1. 数据源分析
|
||||
|
||||
### RSS 结构
|
||||
```xml
|
||||
<item>
|
||||
<title>2026-03-23</title> <!-- 日期作为标题 -->
|
||||
<link>https://imjuya.github.io/juya-ai-daily/issue-37/</link>
|
||||
<description>AI 早报 2026-03-23 视频版...</description>
|
||||
<content:encoded>
|
||||
<![CDATA[
|
||||
<img src="封面图片URL" alt=""> <!-- 每日封面图 -->
|
||||
<h1>AI 早报 2026-03-23</h1>
|
||||
<p><strong>视频版</strong>: B站链接 | YouTube链接</p>
|
||||
<h2>要闻</h2>
|
||||
<ul>
|
||||
<li>微信正式推出ClawBot插件... #1</li>
|
||||
</ul>
|
||||
<h2>开发者</h2>
|
||||
<ul>
|
||||
<li>Claude Code 测试新功能... #2</li>
|
||||
</ul>
|
||||
...更多分类
|
||||
]]>
|
||||
</content:encoded>
|
||||
<pubDate>Mon, 23 Mar 2026 00:34:38 +0000</pubDate>
|
||||
</item>
|
||||
```
|
||||
|
||||
### 推送时间规律
|
||||
- **推送时间**: 每天凌晨 00:30 - 02:00 (UTC+0)
|
||||
- **北京时间**: 每天上午 08:30 - 10:00
|
||||
- **历史数据**: RSS包含约30天的历史数据(从2026-02-18开始)
|
||||
- **更新频率**: 每日一期,一期多条新闻
|
||||
|
||||
### 内容结构
|
||||
每期早报包含:
|
||||
1. **封面图片** - 每日独特的封面图
|
||||
2. **视频版链接** - B站和YouTube双平台
|
||||
3. **要闻** - 2-3条重要新闻
|
||||
4. **开发者** - 技术相关动态
|
||||
5. **产品发布** - 新产品/功能
|
||||
6. **模型发布** - AI模型更新
|
||||
7. **其他分类** - 投资、开源、研究等
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计理念
|
||||
|
||||
### 品牌调性
|
||||
- **橘鸦官网风格**: 柔和、温暖、阅读友好
|
||||
- **主色调**: 砖红色/陶土色 (#bb5649) - 来自官网
|
||||
- **背景色**: 米白色/奶油色 (#fefefe, #f8f5ec) - 柔和不刺眼
|
||||
- **文字色**: 深灰蓝 (#34495e) - 温和专业
|
||||
- **视觉风格**: 简洁优雅、阅读舒适、温暖亲切
|
||||
|
||||
### 设计关键词
|
||||
- 柔和温暖
|
||||
- 阅读友好
|
||||
- 优雅简洁
|
||||
- 舒适护眼
|
||||
- **垂直连续滚动** ← 核心交互
|
||||
|
||||
---
|
||||
|
||||
## 3. 色彩方案 (参考橘鸦官网)
|
||||
|
||||
### 官网色彩提取
|
||||
```
|
||||
官网主色 (砖红/陶土): #bb5649
|
||||
官网文字: #34495e
|
||||
官网背景: #fefefe
|
||||
官网次要背景: #f8f5ec (米黄/奶油)
|
||||
官网引用块背景: rgba(192,91,77,.05)
|
||||
官网引用块边框: rgba(192,91,77,.3)
|
||||
官网链接悬停: #bb5649
|
||||
官网元信息: #757575
|
||||
```
|
||||
|
||||
### 日间模式 (Light Mode) - 柔和风格
|
||||
| 元素 | 颜色 | 用途 |
|
||||
|-----|------|------|
|
||||
| 卡片背景 | #fefefe | 主卡片底色 (官网背景色) |
|
||||
| 卡片边框 | #e6e6e6 | 细微边框 |
|
||||
| 品牌标题 | #bb5649 | "橘鸦" 文字 (官网主色) |
|
||||
| 日期标题 | #bb5649 | 日期大标题 |
|
||||
| 新闻标题 | #34495e | 新闻条目文字 |
|
||||
| 分类标签 | #bb5649 | 要闻/开发者等 |
|
||||
| 时间戳 | #757575 | 发布时间 |
|
||||
| 悬停背景 | rgba(192,91,77,.05) | 条目悬停效果 |
|
||||
| 分隔线 | #e6e6e6 | 日期分隔 |
|
||||
| 加载提示 | #757575 | 加载更多提示 |
|
||||
|
||||
### 夜间模式 (Dark Mode) - 柔和暗色
|
||||
| 元素 | 颜色 | 用途 |
|
||||
|-----|------|------|
|
||||
| 卡片背景 | #2d2a2a | 深暖灰 |
|
||||
| 卡片边框 | #3d3a3a | 细微边框 |
|
||||
| 品牌标题 | #d4736a | 柔和砖红 |
|
||||
| 日期标题 | #d4736a | 日期大标题 |
|
||||
| 新闻标题 | #e8e4e0 | 新闻条目文字 |
|
||||
| 分类标签 | #d4736a | 要闻/开发者等 |
|
||||
| 时间戳 | #9a9590 | 次要信息 |
|
||||
| 悬停背景 | rgba(212,115,106,.1) | 条目悬停效果 |
|
||||
| 分隔线 | #3d3a3a | 日期分隔 |
|
||||
| 加载提示 | #9a9590 | 加载更多提示 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 布局设计
|
||||
|
||||
### 组件尺寸
|
||||
- **默认尺寸**: 4格宽 x 4格高
|
||||
- **最小尺寸**: 4格宽 x 4格高
|
||||
- **滚动方向**: 垂直滚动
|
||||
|
||||
### 垂直连续滚动布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🧱 橘鸦 · AI早报 [🔗 官网] │ ← Header (固定或随滚动)
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 📰 封面图 2026-03-23 │ │ ← 今天的新闻
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ # 2026年3月23日 星期一 │ ← 日期大标题
|
||||
│ │
|
||||
│ ## 📌 要闻 │
|
||||
│ • 微信正式推出ClawBot插件... │
|
||||
│ • OpenAI发布GPT-5.4预览版... │
|
||||
│ │
|
||||
│ ## 💻 开发者 │
|
||||
│ • Claude Code测试新功能... │
|
||||
│ • 阶跃星辰推出StepPlan... │
|
||||
│ │
|
||||
│ 📺 视频版: B站 | YouTube │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │ ← 日期分隔线
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 📰 封面图 2026-03-22 │ │ ← 昨天的新闻
|
||||
│ │ │ │ (往下滑动显示)
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ # 2026年3月22日 星期日 │
|
||||
│ │
|
||||
│ ## 📌 要闻 │
|
||||
│ • OpenAI发布GPT-5.4... │
|
||||
│ • Google推出新功能... │
|
||||
│ │
|
||||
│ ## 💻 开发者 │
|
||||
│ • Anthropic更新Claude... │
|
||||
│ │
|
||||
│ 📺 视频版: B站 | YouTube │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │ ← 前天的新闻
|
||||
│ │ 📰 封面图 2026-03-21 │ │ (继续往下滑动)
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ # 2026年3月21日 星期六 │
|
||||
│ │
|
||||
│ ... │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ 正在加载更多... ↓ │ ← 加载提示
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 日期分隔设计
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ─────────── 3月22日 星期日 ─────────── │ ← 日期分隔条
|
||||
│ │
|
||||
│ [昨天的新闻内容] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 单期新闻结构
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [封面图 - 16:9 比例] │
|
||||
│ │
|
||||
│ # 2026年3月23日 星期一 │ ← 日期大标题
|
||||
│ │
|
||||
│ ## 📌 要闻 │ ← 分类标题
|
||||
│ • 新闻条目1 │
|
||||
│ • 新闻条目2 │
|
||||
│ │
|
||||
│ ## 💻 开发者 │
|
||||
│ • 新闻条目3 │
|
||||
│ • 新闻条目4 │
|
||||
│ │
|
||||
│ ## 🚀 产品发布 │
|
||||
│ • 新闻条目5 │
|
||||
│ │
|
||||
│ 📺 视频版: [B站] [YouTube] │ ← 视频链接
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 字体规范
|
||||
|
||||
### 字体族
|
||||
```xml
|
||||
FontFamily="MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"
|
||||
```
|
||||
|
||||
### 字号规范
|
||||
|
||||
| 元素 | 字号 | 字重 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| 品牌标题 | 20px | SemiBold | 顶部固定标题 |
|
||||
| 日期大标题 | 22px | Bold | 每期日期 |
|
||||
| 分类标题 | 16px | SemiBold | 要闻/开发者等 |
|
||||
| 新闻条目 | 14px | Regular | 主要阅读内容 |
|
||||
| 视频链接 | 13px | Regular | 底部视频入口 |
|
||||
| 加载提示 | 13px | Regular | 加载更多 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心交互: 垂直连续滚动
|
||||
|
||||
### 滚动行为
|
||||
```
|
||||
用户往下滑动
|
||||
↓
|
||||
显示今天的新闻内容
|
||||
↓
|
||||
继续往下滑动
|
||||
↓
|
||||
显示日期分隔线
|
||||
↓
|
||||
显示昨天的新闻内容
|
||||
↓
|
||||
继续往下滑动
|
||||
↓
|
||||
显示前天的新闻内容
|
||||
↓
|
||||
...
|
||||
↓
|
||||
到达已加载内容的底部
|
||||
↓
|
||||
显示"正在加载更多..."
|
||||
↓
|
||||
自动加载更早的新闻
|
||||
```
|
||||
|
||||
### 加载策略
|
||||
```csharp
|
||||
// 初始加载: 最近3天的新闻
|
||||
// 滚动到底部: 自动加载接下来3天
|
||||
// 最大加载: 30天历史数据
|
||||
// 内存管理: 只保留可视区域 ±3 天的数据
|
||||
```
|
||||
|
||||
### 滚动位置记忆
|
||||
```csharp
|
||||
// 记录用户当前滚动位置
|
||||
// 切换主题/刷新时不重置位置
|
||||
// 下次打开组件时恢复到上次位置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 交互设计
|
||||
|
||||
### 悬停效果
|
||||
```
|
||||
新闻条目悬停:
|
||||
- 背景色: 透明 → rgba(192,91,77,.05)
|
||||
- 过渡时间: 200ms
|
||||
- 光标: Hand cursor
|
||||
```
|
||||
|
||||
### 点击效果
|
||||
```
|
||||
新闻条目点击:
|
||||
- 打开浏览器跳转原文链接
|
||||
- 轻微缩放: scale(0.98)
|
||||
- 过渡时间: 100ms
|
||||
```
|
||||
|
||||
### 封面图点击
|
||||
```
|
||||
封面图点击:
|
||||
- 打开当期官网页面
|
||||
- 轻微放大效果
|
||||
```
|
||||
|
||||
### 日期标题点击
|
||||
```
|
||||
日期标题点击:
|
||||
- 展开/收起该期新闻
|
||||
- 箭头图标旋转动画
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 动画效果
|
||||
|
||||
### 滚动动画
|
||||
```
|
||||
内容跟随滚动:
|
||||
- 自然滚动,无额外动画
|
||||
- 保持流畅 60fps
|
||||
```
|
||||
|
||||
### 加载动画
|
||||
```
|
||||
新内容加载:
|
||||
- 淡入: opacity 0 → 1 (300ms)
|
||||
- 缓动: ease-out
|
||||
```
|
||||
|
||||
### 日期分隔线动画
|
||||
```
|
||||
日期分隔线进入视口:
|
||||
- 轻微放大: scale(0.95) → scale(1)
|
||||
- 透明度: 0.5 → 1
|
||||
- 时长: 200ms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 响应式适配
|
||||
|
||||
### 缩放规则
|
||||
```csharp
|
||||
scale = Math.Clamp(currentCellSize / 48, 0.56, 2.0)
|
||||
|
||||
字体缩放: baseFontSize * scale
|
||||
间距缩放: baseSpacing * scale
|
||||
```
|
||||
|
||||
### 最小尺寸保障
|
||||
```
|
||||
最小字体: 11px
|
||||
最小间距: 8px
|
||||
最小触摸区域: 44px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 代码结构预览
|
||||
|
||||
### XAML 结构
|
||||
```xml
|
||||
<UserControl>
|
||||
<Border x:Name="RootBorder" CornerRadius="24" Background="#fefefe">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<!-- Header (固定) -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="16">
|
||||
<TextBlock Text="🧱 橘鸦 · AI早报"
|
||||
Foreground="#bb5649" FontSize="20"/>
|
||||
<Button x:Name="OfficialWebsiteButton" Grid.Column="1"
|
||||
Content="🔗 官网" Click="OnOfficialWebsiteClick"
|
||||
Background="Transparent" Foreground="#bb5649"/>
|
||||
</Grid>
|
||||
|
||||
<!-- 滚动内容区 -->
|
||||
<ScrollViewer Grid.Row="1" x:Name="ContentScrollViewer"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="NewsStackPanel">
|
||||
|
||||
<!-- 今天的新闻 -->
|
||||
<local:DailyNewsView Date="2026-03-23"
|
||||
CoverImageUrl="..."
|
||||
Categories="..."/>
|
||||
|
||||
<!-- 日期分隔线 -->
|
||||
<local:DateSeparator Date="2026-03-22"/>
|
||||
|
||||
<!-- 昨天的新闻 -->
|
||||
<local:DailyNewsView Date="2026-03-22"
|
||||
CoverImageUrl="..."
|
||||
Categories="..."/>
|
||||
|
||||
<!-- 更多历史新闻... -->
|
||||
|
||||
<!-- 加载提示 -->
|
||||
<TextBlock x:Name="LoadingMoreText"
|
||||
Text="正在加载更多... ↓"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,20"/>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
### DailyNewsView 组件
|
||||
```xml
|
||||
<!-- 单期新闻视图 -->
|
||||
<Border x:Class="DailyNewsView" Margin="0,0,0,24">
|
||||
<StackPanel>
|
||||
<!-- 封面图 -->
|
||||
<Border CornerRadius="12" ClipToBounds="True"
|
||||
PointerPressed="OnCoverImageClick" Cursor="Hand">
|
||||
<Image Source="{Binding CoverImageUrl}" Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
|
||||
<!-- 日期大标题 -->
|
||||
<TextBlock Text="{Binding FormattedDate}"
|
||||
FontSize="22" FontWeight="Bold"
|
||||
Foreground="#bb5649" Margin="0,16,0,12"/>
|
||||
|
||||
<!-- 分类列表 -->
|
||||
<ItemsControl ItemsSource="{Binding Categories}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="0,0,0,12">
|
||||
<TextBlock Text="{Binding IconAndName}"
|
||||
FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#bb5649"/>
|
||||
<ItemsControl ItemsSource="{Binding Items}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 视频链接 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<TextBlock Text="📺 视频版:" Foreground="#757575"/>
|
||||
<HyperlinkButton Content="B站" NavigateUri="{Binding BilibiliUrl}"/>
|
||||
<TextBlock Text="|" Foreground="#757575" Margin="4,0"/>
|
||||
<HyperlinkButton Content="YouTube" NavigateUri="{Binding YoutubeUrl}"/>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 数据模型
|
||||
|
||||
```csharp
|
||||
// 每日早报数据
|
||||
public sealed record JuyaDailyNews(
|
||||
DateTime Date,
|
||||
string Title,
|
||||
string CoverImageUrl,
|
||||
string IssueUrl,
|
||||
string BilibiliUrl,
|
||||
string YoutubeUrl,
|
||||
IReadOnlyList<JuyaNewsCategory> Categories,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
// 新闻分类
|
||||
public sealed record JuyaNewsCategory(
|
||||
string Name,
|
||||
string Icon,
|
||||
IReadOnlyList<JuyaNewsItem> Items);
|
||||
|
||||
// 单条新闻
|
||||
public sealed record JuyaNewsItem(
|
||||
string Title,
|
||||
string Url,
|
||||
int? Number);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 与现有组件对比
|
||||
|
||||
| 特性 | CnrDailyNews | IfengNews | **JuyaNews (建议)** |
|
||||
|-----|--------------|-----------|---------------------|
|
||||
| 浏览方式 | 静态展示 | 静态展示 | **垂直连续滚动** |
|
||||
| 历史查看 | 不支持 | 不支持 | **下滑自动加载** |
|
||||
| 交互方式 | 点击刷新 | 点击刷新 | **滚动浏览** |
|
||||
| 内容组织 | 平铺 | 平铺 | **按日期分组** |
|
||||
|
||||
---
|
||||
|
||||
## 13. 设计亮点
|
||||
|
||||
1. **垂直滚动**: 像社交媒体一样自然浏览
|
||||
2. **连续阅读**: 今天→昨天→前天,无缝衔接
|
||||
3. **日期分隔**: 清晰的日期标识,不会混淆
|
||||
4. **自动加载**: 滑到底部自动加载更多历史
|
||||
5. **柔和色彩**: 砖红色 + 米白色,阅读舒适
|
||||
6. **主题适配**: 日间/夜间模式都柔和护眼
|
||||
|
||||
---
|
||||
|
||||
## 14. 实现建议
|
||||
|
||||
### 滚动加载实现
|
||||
```csharp
|
||||
public partial class JuyaNewsWidget : UserControl
|
||||
{
|
||||
private readonly List<JuyaDailyNews> _loadedNews = new();
|
||||
private DateTime _earliestLoadedDate;
|
||||
private bool _isLoadingMore;
|
||||
|
||||
private void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
|
||||
{
|
||||
var scrollViewer = (ScrollViewer)sender!;
|
||||
|
||||
// 检测是否滚动到底部
|
||||
if (scrollViewer.VerticalOffset >= scrollViewer.ScrollableHeight - 100)
|
||||
{
|
||||
LoadMoreNews();
|
||||
}
|
||||
}
|
||||
|
||||
private async void LoadMoreNews()
|
||||
{
|
||||
if (_isLoadingMore) return;
|
||||
_isLoadingMore = true;
|
||||
|
||||
// 加载接下来3天的新闻
|
||||
var nextBatch = await FetchNewsBatch(_earliestLoadedDate.AddDays(-1), 3);
|
||||
|
||||
foreach (var news in nextBatch)
|
||||
{
|
||||
AddNewsToView(news);
|
||||
_loadedNews.Add(news);
|
||||
}
|
||||
|
||||
_earliestLoadedDate = nextBatch.Last().Date;
|
||||
_isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 内存优化
|
||||
```csharp
|
||||
// 只保留可视区域附近的新闻
|
||||
// 远离可视区域的新闻释放图片资源
|
||||
// 保留文字内容,图片按需加载
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*设计版本: v4.0*
|
||||
*更新日期: 2026-03-24*
|
||||
*更新内容: 改为垂直连续滚动浏览模式*
|
||||
62
docs/PRODUCT.md
Normal file
62
docs/PRODUCT.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 产品文档 / Product
|
||||
|
||||
## 中文
|
||||
|
||||
### 产品一句话
|
||||
|
||||
阑山桌面——你的桌面,不止一面。
|
||||
|
||||
### 产品定位
|
||||
|
||||
- 产品类型:跨平台桌面环境增强工具
|
||||
- 技术基线:Avalonia UI + .NET 10
|
||||
- 支持平台:Windows、Linux、macOS
|
||||
- 仓库角色:本仓库是桌面宿主、插件运行时、Plugin SDK 与共享契约的权威来源
|
||||
|
||||
### 目标用户
|
||||
|
||||
- 学生用户:课程表、自习监测、计时、天气和日常信息聚合
|
||||
- 办公用户:日历、资讯、最近文档、常用工具入口
|
||||
- 效率和美化爱好者:自由布局、主题切换、插件扩展
|
||||
- 中文用户:本地化界面、农历和节假日等本地语境支持
|
||||
|
||||
### 核心使用场景
|
||||
|
||||
- 学习辅助:课程表、自习环境监测、计时与知识卡片
|
||||
- 信息聚合:天气、新闻、日历、热搜等信息集中展示
|
||||
- 效率提升:最近文档、浏览器、工具组件与桌面快捷访问
|
||||
- 个性化桌面:自由布局、多页桌面、主题与视觉风格配置
|
||||
- 插件扩展:通过 `.laapp` 插件补充新的组件、设置页和集成功能
|
||||
|
||||
### 核心能力
|
||||
|
||||
- 桌面组件系统:内置组件与扩展组件统一注册、统一放置约束
|
||||
- 插件系统:宿主加载插件、整合设置页、组件与市场安装流
|
||||
- 外观系统:主题、玻璃层级、圆角与颜色资源统一管理
|
||||
- 设置系统:独立设置窗口、设置页注册与分域持久化
|
||||
- 跨平台运行:基于 Avalonia 的桌面宿主运行在 Windows、Linux、macOS
|
||||
|
||||
### 当前阶段
|
||||
|
||||
- 产品版本:`1.0.0`
|
||||
- Plugin SDK API 基线:`4.0.0`
|
||||
- 当前重点:持续完善宿主体验、设置页体验、组件能力与插件生态
|
||||
- 近期需求入口:以 `.trae/specs/` 中的 feature spec 为准
|
||||
|
||||
### 生态边界
|
||||
|
||||
- 本仓库负责:宿主代码、插件运行时、SDK、共享契约、主题与设置基础设施
|
||||
- `LanAirApp` 负责:插件市场元数据、开发者生态材料
|
||||
- `LanMountainDesktop.SamplePlugin` 负责:官方示例插件实现
|
||||
|
||||
### 维护原则
|
||||
|
||||
- 产品事实只在本文件沉淀,不在多个根目录文档重复维护
|
||||
- 代码结构和运行方式分别以 `docs/ARCHITECTURE.md` 与 `docs/DEVELOPMENT.md` 为准
|
||||
- 专题规范以 `docs/VISUAL_SPEC.md`、`docs/CORNER_RADIUS_SPEC.md` 等专题文档为准
|
||||
|
||||
## English
|
||||
|
||||
LanMountainDesktop is a cross-platform desktop enhancement product built with Avalonia UI and .NET 10. It targets students, office users, and customization-focused users who want a programmable desktop surface for information, tools, and plugin-driven extensions.
|
||||
|
||||
This repository is the source of truth for the desktop host, plugin runtime, Plugin SDK, shared contracts, and core appearance/settings infrastructure. The current product version is `1.0.0`, and the active Plugin SDK baseline in this repository is `4.0.0`.
|
||||
76
docs/SPECS.md
Normal file
76
docs/SPECS.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 规格文档说明 / Specs
|
||||
|
||||
## 中文
|
||||
|
||||
### 目的
|
||||
|
||||
`.trae/specs/` 用来存放“一个需求从意图到落地”的协作文档,而不是长期产品说明。它适合记录功能变更、交互改造、重要修复和跨模块调整。
|
||||
|
||||
### 目录结构
|
||||
|
||||
每个功能目录建议使用:
|
||||
|
||||
```text
|
||||
.trae/specs/<feature-name>/
|
||||
spec.md
|
||||
tasks.md
|
||||
checklist.md
|
||||
```
|
||||
|
||||
### 每个文件的职责
|
||||
|
||||
#### `spec.md`
|
||||
|
||||
用于描述这次变更的意图和行为要求,建议包含:
|
||||
|
||||
- `Why`:为什么要做
|
||||
- `What Changes`:会改什么
|
||||
- `Impact`:影响哪些规范或代码区域
|
||||
- Requirements / Scenarios:可验证的行为要求
|
||||
|
||||
#### `tasks.md`
|
||||
|
||||
用于把实现拆成可执行任务,建议包含:
|
||||
|
||||
- 分阶段任务或模块任务
|
||||
- 依赖关系
|
||||
- 可并行项
|
||||
- 完成状态
|
||||
|
||||
#### `checklist.md`
|
||||
|
||||
用于验收与回归检查,建议包含:
|
||||
|
||||
- 关键 UI 或行为检查点
|
||||
- 构建、运行、测试检查点
|
||||
- 手工验证项
|
||||
|
||||
### 什么时候新建 spec
|
||||
|
||||
- 新增功能
|
||||
- 已有功能行为发生变化
|
||||
- 设置页、主界面、组件系统出现结构性调整
|
||||
- 插件系统、共享契约、SDK 接入方式发生变化
|
||||
|
||||
### 什么时候只更新现有 spec
|
||||
|
||||
- 同一 feature 的后续迭代仍属于原目标范围
|
||||
- 原 spec 仍是当前实现的权威描述
|
||||
- 只是补充场景、任务拆解或验收项
|
||||
|
||||
### 什么时候可以不写 spec
|
||||
|
||||
- 纯拼写修复
|
||||
- 纯内部重构且不改变行为
|
||||
- 只改注释、日志、文档索引等非行为项
|
||||
|
||||
### 与其他文档的关系
|
||||
|
||||
- 长期产品说明看 `docs/PRODUCT.md`
|
||||
- 长期架构说明看 `docs/ARCHITECTURE.md`
|
||||
- 开发运行方式看 `docs/DEVELOPMENT.md`
|
||||
- feature 级变更过程看 `.trae/specs/`
|
||||
|
||||
## English
|
||||
|
||||
Use `.trae/specs/` for feature-level change tracking, not for long-lived product or architecture documentation. `spec.md` defines intent and requirements, `tasks.md` breaks implementation into actionable work, and `checklist.md` captures validation and regression checks.
|
||||
60
docs/ai/CHANGE_WORKFLOW.md
Normal file
60
docs/ai/CHANGE_WORKFLOW.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Change Workflow
|
||||
|
||||
## 目标
|
||||
|
||||
给 AI 一个稳定的执行顺序,避免直接跳到编码而漏掉规格、文档和回归验证。
|
||||
|
||||
## 推荐流程
|
||||
|
||||
1. 读取 `AGENTS.md`
|
||||
2. 读取 `docs/ai/DOC_SOURCES.md`,确认这次需求涉及哪些权威文档
|
||||
3. 按需读取 `docs/ARCHITECTURE.md`、专题规范和相关目录内 README
|
||||
4. 检查 `.trae/specs/` 是否已有对应 feature
|
||||
5. 如果是新功能或行为变化,先补或更新 `spec.md / tasks.md / checklist.md`
|
||||
6. 再改代码
|
||||
7. 补测试或复用已有测试文件
|
||||
8. 运行最小必要验证
|
||||
9. 回写文档入口和迁移说明
|
||||
|
||||
## 什么时候必须先更新 `.trae/specs/`
|
||||
|
||||
- 用户可见行为变化
|
||||
- 设置页或主界面结构变化
|
||||
- 组件系统规则变化
|
||||
- 插件宿主集成、共享契约、SDK 使用模式变化
|
||||
|
||||
## 什么时候可以直接改代码
|
||||
|
||||
- 纯文档修复
|
||||
- 不改变行为的内部重构
|
||||
- 小范围 bugfix 且现有 spec 已完整覆盖该功能意图
|
||||
|
||||
## 最小验证清单
|
||||
|
||||
默认优先:
|
||||
|
||||
```bash
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
dotnet test LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
按需增加:
|
||||
|
||||
- 运行桌面宿主验证 UI 或启动行为
|
||||
- 检查插件打包或 market 调试路径
|
||||
- 手工验证设置页、主题切换、组件布局等高风险交互
|
||||
|
||||
## 回写要求
|
||||
|
||||
出现以下变化时,AI 应同步回写文档:
|
||||
|
||||
- 命令变化:更新 `docs/DEVELOPMENT.md`
|
||||
- 模块职责变化:更新 `docs/ARCHITECTURE.md`
|
||||
- 产品定位或阶段变化:更新 `docs/PRODUCT.md`
|
||||
- AI 入口或权威来源变化:更新 `AGENTS.md` 或 `docs/ai/DOC_SOURCES.md`
|
||||
|
||||
## 不要做的事
|
||||
|
||||
- 不要把根目录 `README.md` 写成 feature 详细设计文档
|
||||
- 不要在多份文档里重复维护同一条事实
|
||||
- 不要把 `LanAirApp` 的资料误写成本仓库权威来源
|
||||
59
docs/ai/CODEBASE_MAP.md
Normal file
59
docs/ai/CODEBASE_MAP.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Codebase Map
|
||||
|
||||
## 目标
|
||||
|
||||
本文件帮助 AI 在最短时间内定位“需求应该落到哪一层”,减少把改动打到错误项目或错误目录的概率。
|
||||
|
||||
## 顶层项目地图
|
||||
|
||||
| 路径 | 主要职责 | 典型改动 |
|
||||
| --- | --- | --- |
|
||||
| `LanMountainDesktop/` | 桌面宿主应用 | UI、服务、主流程、组件系统、插件接入 |
|
||||
| `LanMountainDesktop.PluginSdk/` | 插件 SDK | 公共接口、扩展方法、默认打包行为 |
|
||||
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 | 宿主与插件共享记录、模型、边界类型 |
|
||||
| `LanMountainDesktop.Appearance/` | 外观基础设施 | 主题、圆角、外观资源相关逻辑 |
|
||||
| `LanMountainDesktop.Settings.Core/` | 设置基础设施 | 设置 scope、存储抽象、设置 facade 支撑 |
|
||||
| `LanMountainDesktop.DesktopHost/` | 桌面宿主流程 | 生命周期、宿主流程支撑 |
|
||||
| `LanMountainDesktop.DesktopComponents.Runtime/` | 组件运行时 | 组件宿主运行时支撑 |
|
||||
| `LanMountainDesktop.Host.Abstractions/` | 宿主抽象 | 宿主接口与抽象层 |
|
||||
| `LanMountainDesktop.PluginsInstallHelper/` | 插件安装辅助 | 发布输出和插件安装辅助程序 |
|
||||
| `LanMountainDesktop.PluginTemplate/` | 插件模板 | `dotnet new lmd-plugin` 模板内容 |
|
||||
| `LanMountainDesktop.Tests/` | 测试 | 行为回归、契约验证、基础能力校验 |
|
||||
|
||||
## 主宿主工程内的高频落点
|
||||
|
||||
| 路径 | 用途 | 常见需求 |
|
||||
| --- | --- | --- |
|
||||
| `LanMountainDesktop/Program.cs` | 进程启动主线 | 启动诊断、单实例、启动配置 |
|
||||
| `LanMountainDesktop/App.axaml.cs` | 应用初始化 | 主题、语言、托盘、插件运行时、主窗口 |
|
||||
| `LanMountainDesktop/Views/` | 界面视图 | 设置页、主窗口、组件 UI |
|
||||
| `LanMountainDesktop/ViewModels/` | 视图模型 | 页面状态、命令、交互行为 |
|
||||
| `LanMountainDesktop/Services/` | 服务层 | 设置、存储、遥测、业务能力 |
|
||||
| `LanMountainDesktop/ComponentSystem/` | 组件系统 | 组件定义、注册、放置规则、扩展清单 |
|
||||
| `LanMountainDesktop/plugins/` | 插件运行时 | 插件发现、安装、替换、market 集成 |
|
||||
| `LanMountainDesktop/Theme/` and `Styles/` | 主题和样式 | 视觉资源、主题行为、样式规则 |
|
||||
| `LanMountainDesktop/Localization/` | 本地化 | 语言资源、语言切换 |
|
||||
| `LanMountainDesktop/DesktopEditing/` | 布局编辑 | 组件摆放、数学计算、编辑状态 |
|
||||
|
||||
## 需求到目录的快速映射
|
||||
|
||||
- 设置页改造:优先看 `Views/`, `ViewModels/`, `Services/`, `.trae/specs/`
|
||||
- 组件注册或元数据变化:优先看 `ComponentSystem/`
|
||||
- 插件安装、market、插件加载:优先看 `plugins/`
|
||||
- 主题、颜色、圆角:优先看 `Theme/`, `Styles/`, `LanMountainDesktop.Appearance/`
|
||||
- 设置持久化:优先看 `LanMountainDesktop.Settings.Core/` 与宿主设置 facade
|
||||
- SDK 接口调整:优先看 `LanMountainDesktop.PluginSdk/` 和 `LanMountainDesktop.Shared.Contracts/`
|
||||
- 桌面壳层或生命周期:优先看 `Program.cs`, `App.axaml.cs`, `LanMountainDesktop.DesktopHost/`
|
||||
|
||||
## 测试对照
|
||||
|
||||
当前测试工程 `LanMountainDesktop.Tests/` 内的典型覆盖包括:
|
||||
|
||||
- `CornerRadiusScaleTests.cs`: 圆角和外观缩放相关
|
||||
- `DesktopPlacementMathTests.cs`: 桌面布局数学
|
||||
- `DesktopEditCommitMathTests.cs`: 桌面编辑提交计算
|
||||
- `ComponentSettingsServiceTests.cs`: 组件设置服务
|
||||
- `UiExceptionGuardTests.cs`: UI 异常保护
|
||||
- `WhiteboardNotePersistenceServiceTests.cs`: 白板笔记持久化
|
||||
|
||||
如果改动落在这些行为附近,优先扩展已有测试而不是新建无关测试入口。
|
||||
39
docs/ai/DOC_SOURCES.md
Normal file
39
docs/ai/DOC_SOURCES.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Documentation Sources
|
||||
|
||||
## 目标
|
||||
|
||||
当多个文档都提到同一主题时,AI 必须知道“到底信哪一份”。本文件定义权威来源,避免引用旧文档或重复维护的文本。
|
||||
|
||||
## 权威来源表
|
||||
|
||||
| 主题 | 权威文档 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 项目总入口 | `README.md` | 面向人类,提供索引而不展开细节 |
|
||||
| AI 协作入口 | `AGENTS.md` | 面向 AI 的首读文件 |
|
||||
| 产品定位与阶段 | `docs/PRODUCT.md` | 不再使用旧根目录产品文档 |
|
||||
| 架构与模块职责 | `docs/ARCHITECTURE.md` | 包含仓库结构和运行时主线 |
|
||||
| 构建、运行、测试、打包 | `docs/DEVELOPMENT.md` | 命令以这里为准 |
|
||||
| 贡献和文档更新规则 | `docs/CONTRIBUTING.md` | PR、spec、文档协作规则 |
|
||||
| feature 级规格 | `.trae/specs/<feature>/spec.md` | 行为意图和需求场景 |
|
||||
| feature 任务拆解 | `.trae/specs/<feature>/tasks.md` | 实施步骤与依赖 |
|
||||
| feature 验收 | `.trae/specs/<feature>/checklist.md` | 回归与验收项 |
|
||||
| 视觉规范 | `docs/VISUAL_SPEC.md` | 颜色、语义资源、玻璃层级 |
|
||||
| 圆角规范 | `docs/CORNER_RADIUS_SPEC.md` | 圆角层级与动态规则 |
|
||||
| 插件生态边界 | `docs/ECOSYSTEM_BOUNDARIES.md` | 仓库边界和 market 所属 |
|
||||
| SDK v4 迁移 | `docs/PLUGIN_SDK_V4_MIGRATION.md` | Plugin SDK breaking changes |
|
||||
|
||||
## 已废弃来源
|
||||
|
||||
以下文件内容已迁移,不应继续作为权威来源引用:
|
||||
|
||||
- `PRODUCT_BRIEF.md`
|
||||
- `PRODUCT_DOCUMENT.md`
|
||||
- `run.md`
|
||||
|
||||
## 冲突处理规则
|
||||
|
||||
如果发现多个文档内容冲突,按以下优先级处理:
|
||||
|
||||
1. 先看本表中的权威来源
|
||||
2. 再看相关项目内源码、`csproj`、目录 README
|
||||
3. 如果仍有冲突,以当前仓库源码和项目配置为准,并回写文档
|
||||
59
run.md
59
run.md
@@ -1,59 +0,0 @@
|
||||
# 运行指南
|
||||
|
||||
## 中文
|
||||
|
||||
本文档只说明如何在本地运行阑山桌面。
|
||||
|
||||
### 环境准备
|
||||
|
||||
- 安装 .NET SDK 10。
|
||||
- 桌面端建议在 Windows 上运行。
|
||||
- 仓库主入口解决方案文件为 `LanMountainDesktop.slnx`。
|
||||
- SDK 版本由仓库根目录 `global.json` 锁定。
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
### 运行桌面端
|
||||
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
|
||||
- 如果提示 SDK 版本不匹配,先检查 `dotnet --info`。
|
||||
- 如果视频能力异常,优先在 Windows 环境验证。
|
||||
- 如果要重置配置,可删除 `%LOCALAPPDATA%\LanMountainDesktop\settings.json` 后重启。
|
||||
|
||||
### Linux 录音依赖
|
||||
|
||||
如果在 Linux 上使用录音机或自习监测相关能力,需要安装音频库:
|
||||
|
||||
- Debian/Ubuntu:`sudo apt install libportaudio2 libasound2`
|
||||
- Fedora/RHEL:`sudo dnf install portaudio-libs alsa-lib`
|
||||
- Arch Linux:`sudo pacman -S portaudio alsa-lib`
|
||||
- Alpine Linux:`sudo apk add portaudio alsa-lib`
|
||||
|
||||
## English
|
||||
|
||||
This guide explains how to run LanMountainDesktop locally.
|
||||
|
||||
The repository entry solution is `LanMountainDesktop.slnx`, and the SDK version is pinned by the root `global.json`.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
Reference in New Issue
Block a user