Compare commits

...

6 Commits

Author SHA1 Message Date
lincube
26ff11b16b 0.7.8 2026-03-24 23:15:32 +08:00
lincube
b83cfb47b0 0.7.7.2
笔迹粗细大小调节
2026-03-24 20:16:44 +08:00
lincube
a0bb83c743 0.7.7.1 2026-03-24 17:47:54 +08:00
lincube
af2e7b4f2f 0.7.7
橘鸦新闻
2026-03-24 09:33:56 +08:00
lincube
798124e500 0.7.6.3 2026-03-23 22:43:54 +08:00
lincube
95ecb06668 0.7.6.2
在应用启动台上,也可以正常滑动
2026-03-23 21:13:08 +08:00
54 changed files with 6525 additions and 729 deletions

91
AGENTS.md Normal file
View 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` 列出的权威来源为准。

View File

@@ -12,6 +12,7 @@
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl> <RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType> <RepositoryType>git</RepositoryType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" /> <PackageReference Include="Avalonia" Version="11.3.12" />

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View 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

View File

@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2"; public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews"; public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews"; public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopJuyaNews = "DesktopJuyaNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch"; public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch"; public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum"; public const string DesktopStcn24Forum = "DesktopStcn24Forum";

View File

@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
MinHeightCells: 4, MinHeightCells: 4,
AllowStatusBarPlacement: false, AllowStatusBarPlacement: false,
AllowDesktopPlacement: true), AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopJuyaNews,
"橘鸦早报",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition( new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch, BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search", "Bilibili Hot Search",

View File

@@ -1,13 +1,16 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.PluginMarket;
using LanMountainDesktop.Settings.Core; using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Services.Settings; namespace LanMountainDesktop.Services.Settings
{
public enum WallpaperMediaType public enum WallpaperMediaType
{ {
@@ -66,10 +69,272 @@ public sealed record UpdateSettingsState(
long? PendingUpdatePublishedAtUtcMs, long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs); long? LastUpdateCheckUtcMs);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds); public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
public enum PluginPackageSourceKind
{
ReleaseAsset = 0,
RawFallback = 1,
WorkspaceLocal = 2
}
public sealed record PluginCatalogSourceInfo(
string Id,
string Name,
string? Description,
string? SourceUrl,
string? CachePath,
bool IsOfficial,
int Priority);
public sealed record PluginCatalogSharedContractInfo(
string Id,
string Version,
string AssemblyName);
public sealed record PluginCapabilityInfo(
string Id,
string? Version,
string? AssemblyName);
public sealed record PluginPackageSourceInfo(
PluginPackageSourceKind Kind,
string Url,
string Sha256,
long PackageSizeBytes);
public sealed record PluginCatalogManifestInfo(
string Id,
string Name,
string Description,
string Author,
string Version,
string ApiVersion,
string EntranceAssembly,
IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts);
public sealed record PluginCatalogCompatibilityInfo(
string MinHostVersion,
string ApiVersion);
public sealed record PluginCatalogRepositoryInfo(
string IconUrl,
string ProjectUrl,
string ReadmeUrl,
string HomepageUrl,
string RepositoryUrl,
IReadOnlyList<string> Tags,
string ReleaseNotes);
public sealed record PluginCatalogPublicationInfo(
string ReleaseTag,
string ReleaseAssetName,
DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt,
long PackageSizeBytes,
string Sha256,
string? Md5);
public sealed record PluginCatalogItemInfo(
PluginCatalogManifestInfo Manifest,
PluginCatalogCompatibilityInfo Compatibility,
PluginCatalogRepositoryInfo Repository,
PluginCatalogPublicationInfo Publication,
IReadOnlyList<PluginPackageSourceInfo> PackageSources,
IReadOnlyList<PluginCapabilityInfo> Capabilities)
{
public string Id => Manifest.Id;
public string Name => Manifest.Name;
public string Description => Manifest.Description;
public string Author => Manifest.Author;
public string Version => Manifest.Version;
public string ApiVersion => Manifest.ApiVersion;
public string MinHostVersion => Compatibility.MinHostVersion;
public string DownloadUrl => PackageSources.FirstOrDefault()?.Url ?? string.Empty;
public string Sha256 => Publication.Sha256;
public long PackageSizeBytes => Publication.PackageSizeBytes;
public string IconUrl => Repository.IconUrl;
public string ProjectUrl => Repository.ProjectUrl;
public string ReadmeUrl => Repository.ReadmeUrl;
public string HomepageUrl => Repository.HomepageUrl;
public string RepositoryUrl => Repository.RepositoryUrl;
public IReadOnlyList<string> Tags => Repository.Tags;
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
public IReadOnlyList<PluginCatalogDependencyInfo> Dependencies =>
Manifest.SharedContracts
.Select(contract => new PluginCatalogDependencyInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray();
public DateTimeOffset PublishedAt => Publication.PublishedAt;
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
public string ReleaseTag => Publication.ReleaseTag;
public string ReleaseAssetName => Publication.ReleaseAssetName;
public string ReleaseNotes => Repository.ReleaseNotes;
public static implicit operator PluginMarketPluginInfo(PluginCatalogItemInfo item)
{
return new PluginMarketPluginInfo(
item.Id,
item.Name,
item.Description,
item.Author,
item.Version,
item.ApiVersion,
item.MinHostVersion,
item.DownloadUrl,
item.ReleaseTag,
item.ReleaseAssetName,
item.IconUrl,
item.ReadmeUrl,
item.HomepageUrl,
item.RepositoryUrl,
item.Tags.ToArray(),
item.Dependencies.Select(dependency => new PluginMarketDependencyInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName)).ToArray(),
item.PublishedAt,
item.UpdatedAt);
}
public static implicit operator PluginCatalogItemInfo(PluginMarketPluginInfo plugin)
{
return new PluginCatalogItemInfo(
new PluginCatalogManifestInfo(
plugin.Id,
plugin.Name,
plugin.Description,
plugin.Author,
plugin.Version,
plugin.ApiVersion,
string.Empty,
plugin.Dependencies
.Select(dependency => new PluginCatalogSharedContractInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName))
.ToArray()),
new PluginCatalogCompatibilityInfo(
plugin.MinHostVersion,
plugin.ApiVersion),
new PluginCatalogRepositoryInfo(
plugin.IconUrl,
plugin.RepositoryUrl,
plugin.ReadmeUrl,
plugin.HomepageUrl,
plugin.RepositoryUrl,
plugin.Tags,
string.Empty),
new PluginCatalogPublicationInfo(
plugin.ReleaseTag,
plugin.ReleaseAssetName,
plugin.PublishedAt,
plugin.UpdatedAt,
0,
string.Empty,
null),
string.IsNullOrWhiteSpace(plugin.DownloadUrl)
? []
: [
new PluginPackageSourceInfo(
string.IsNullOrWhiteSpace(plugin.ReleaseTag)
? PluginPackageSourceKind.RawFallback
: PluginPackageSourceKind.ReleaseAsset,
plugin.DownloadUrl,
string.Empty,
0)
],
[]);
}
}
public sealed record PluginCatalogIndexResult(
bool Success,
IReadOnlyList<PluginCatalogItemInfo> Plugins,
IReadOnlyList<PluginCatalogSourceInfo> Sources,
string? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage)
{
public static implicit operator PluginMarketIndexResult(PluginCatalogIndexResult result)
{
return new PluginMarketIndexResult(
result.Success,
result.Plugins.Select(plugin => (PluginMarketPluginInfo)plugin).ToArray(),
result.Source,
result.SourceLocation,
result.WarningMessage,
result.ErrorMessage);
}
}
public sealed record PluginInstallDiagnostic(
string Code,
string Message,
string? Details = null);
public sealed record PluginCatalogInstallResult(
bool Success,
string? PluginId,
string? PluginName,
PluginManifest? InstalledManifest,
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
string? ErrorMessage)
{
public static implicit operator PluginMarketInstallResult(PluginCatalogInstallResult result)
{
return new PluginMarketInstallResult(
result.Success,
result.PluginId,
result.PluginName,
result.ErrorMessage);
}
}
public sealed record PluginCatalogDependencyInfo(
string Id,
string Version,
string AssemblyName)
{
public static implicit operator PluginMarketDependencyInfo(PluginCatalogDependencyInfo dependency)
{
return new PluginMarketDependencyInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName);
}
}
[Obsolete("Use PluginCatalogSharedContractInfo and PluginCatalogItemInfo instead.")]
public sealed record PluginMarketDependencyInfo( public sealed record PluginMarketDependencyInfo(
string Id, string Id,
string Version, string Version,
string AssemblyName); string AssemblyName);
[Obsolete("Use PluginCatalogItemInfo instead.")]
public sealed record PluginMarketPluginInfo( public sealed record PluginMarketPluginInfo(
string Id, string Id,
string Name, string Name,
@@ -89,6 +354,8 @@ public sealed record PluginMarketPluginInfo(
IReadOnlyList<PluginMarketDependencyInfo> Dependencies, IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
DateTimeOffset PublishedAt, DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
[Obsolete("Use PluginCatalogIndexResult instead.")]
public sealed record PluginMarketIndexResult( public sealed record PluginMarketIndexResult(
bool Success, bool Success,
IReadOnlyList<PluginMarketPluginInfo> Plugins, IReadOnlyList<PluginMarketPluginInfo> Plugins,
@@ -96,12 +363,39 @@ public sealed record PluginMarketIndexResult(
string? SourceLocation, string? SourceLocation,
string? WarningMessage, string? WarningMessage,
string? ErrorMessage); string? ErrorMessage);
[Obsolete("Use PluginCatalogInstallResult instead.")]
public sealed record PluginMarketInstallResult( public sealed record PluginMarketInstallResult(
bool Success, bool Success,
string? PluginId, string? PluginId,
string? PluginName, string? PluginName,
string? ErrorMessage); string? ErrorMessage);
public interface IPluginCatalogSourceProvider
{
Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
}
public interface IPluginCatalogService : IPluginCatalogSourceProvider
{
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IPackageSourceResolver
{
IReadOnlyList<PluginPackageSourceInfo> ResolveSources(PluginCatalogItemInfo item);
}
public interface IPluginCompatibilityEvaluator
{
PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion);
}
public interface IPluginInstallOrchestrator
{
Task<PluginCatalogInstallResult> InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default);
}
public interface IGridSettingsService public interface IGridSettingsService
{ {
GridSettingsState Get(); GridSettingsState Get();
@@ -223,10 +517,17 @@ public interface IPluginManagementSettingsService
bool DeleteInstalledPlugin(string pluginId); bool DeleteInstalledPlugin(string pluginId);
} }
public interface IPluginMarketSettingsService public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
{
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
[Obsolete("Use IPluginCatalogSettingsService instead.")]
public interface IPluginMarketSettingsService : IPluginCatalogSettingsService
{ {
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default); Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default); new Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
} }
public interface IApplicationInfoService public interface IApplicationInfoService
@@ -252,6 +553,20 @@ public interface ISettingsFacadeService
ILauncherCatalogService LauncherCatalog { get; } ILauncherCatalogService LauncherCatalog { get; }
ILauncherPolicyService LauncherPolicy { get; } ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; } IPluginManagementSettingsService PluginManagement { get; }
IPluginCatalogSettingsService PluginCatalog { get; }
[Obsolete("Use PluginCatalog instead.")]
IPluginMarketSettingsService PluginMarket { get; } IPluginMarketSettingsService PluginMarket { get; }
IApplicationInfoService ApplicationInfo { get; } IApplicationInfoService ApplicationInfo { get; }
} }
}
namespace LanMountainDesktop.Services.PluginMarket
{
internal enum PluginPackageSourceKind
{
ReleaseAsset = 0,
RawFallback = 1,
WorkspaceLocal = 2
}
}

View File

@@ -870,14 +870,41 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot); _installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
} }
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default) public Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default)
{ {
var result = await _indexService.LoadAsync(cancellationToken); return LoadCatalogCoreAsync(cancellationToken);
}
async Task<PluginMarketIndexResult> IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken)
{
return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
}
public Task<PluginCatalogInstallResult> InstallAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
return InstallCatalogCoreAsync(pluginId, cancellationToken);
}
async Task<PluginMarketInstallResult> IPluginMarketSettingsService.InstallAsync(
string pluginId,
CancellationToken cancellationToken)
{
return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false);
}
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
{
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
var sources = BuildCatalogSources(result.Source?.ToString(), result.SourceLocation, result.WarningMessage);
if (!result.Success || result.Document is null) if (!result.Success || result.Document is null)
{ {
return new PluginMarketIndexResult( _cachedPlugins.Clear();
return new PluginCatalogIndexResult(
false, false,
[], [],
sources,
result.Source?.ToString(), result.Source?.ToString(),
result.SourceLocation, result.SourceLocation,
result.WarningMessage, result.WarningMessage,
@@ -889,81 +916,189 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
.Select(entry => .Select(entry =>
{ {
_cachedPlugins[entry.Id] = entry; _cachedPlugins[entry.Id] = entry;
return new PluginMarketPluginInfo( return MapCatalogItem(entry);
entry.Id,
entry.Name,
entry.Description,
entry.Author,
entry.Version,
entry.ApiVersion,
entry.MinHostVersion,
entry.DownloadUrl,
entry.ReleaseTag,
entry.ReleaseAssetName,
entry.IconUrl,
entry.ReadmeUrl,
entry.HomepageUrl,
entry.RepositoryUrl,
entry.Tags,
entry.SharedContracts
.Select(contract => new PluginMarketDependencyInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray(),
entry.PublishedAt,
entry.UpdatedAt);
}) })
.ToArray(); .ToArray();
return new PluginMarketIndexResult( return new PluginCatalogIndexResult(
true, true,
plugins, plugins,
sources,
result.Source?.ToString(), result.Source?.ToString(),
result.SourceLocation, result.SourceLocation,
result.WarningMessage, result.WarningMessage,
null); null);
} }
public async Task<PluginMarketInstallResult> InstallAsync( private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
string pluginId, string pluginId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(pluginId)) if (string.IsNullOrWhiteSpace(pluginId))
{ {
return new PluginMarketInstallResult(false, null, null, "Plugin id is required."); return new PluginCatalogInstallResult(
false,
null,
null,
null,
[new PluginInstallDiagnostic("invalid_request", "Plugin id is required.")],
"Plugin id is required.");
} }
if (_installService is null || _pluginRuntimeService is null) if (_installService is null || _pluginRuntimeService is null)
{ {
return new PluginMarketInstallResult( return new PluginCatalogInstallResult(
false, false,
pluginId, pluginId,
null, null,
null,
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
"Plugin runtime is unavailable."); "Plugin runtime is unavailable.");
} }
if (!_cachedPlugins.TryGetValue(pluginId, out var entry)) if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
{ {
var load = await LoadIndexAsync(cancellationToken); var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
if (!load.Success) if (!load.Success)
{ {
return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage); return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("catalog_load_failed", load.ErrorMessage ?? "Failed to load the plugin catalog.")],
load.ErrorMessage);
} }
if (!_cachedPlugins.TryGetValue(pluginId, out entry)) if (!_cachedPlugins.TryGetValue(pluginId, out entry))
{ {
return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index."); return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("not_found", "Plugin was not found in the official catalog.")],
"Plugin was not found in the official catalog.");
} }
} }
var result = await _installService.InstallAsync(entry, cancellationToken); var result = await _installService.InstallAsync(entry, cancellationToken).ConfigureAwait(false);
if (!result.Success) if (!result.Success)
{ {
return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage); return new PluginCatalogInstallResult(
false,
entry.Id,
entry.Name,
null,
[new PluginInstallDiagnostic("install_failed", result.ErrorMessage ?? "Plugin install failed.")],
result.ErrorMessage);
} }
return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null); return new PluginCatalogInstallResult(
true,
result.Manifest?.Id ?? entry.Id,
result.Manifest?.Name ?? entry.Name,
result.Manifest,
[],
null);
}
private static PluginCatalogItemInfo MapCatalogItem(AirAppMarketPluginEntry entry)
{
var manifest = new PluginCatalogManifestInfo(
entry.Id,
entry.Name,
entry.Description,
entry.Author,
entry.Version,
entry.ApiVersion,
string.Empty,
entry.SharedContracts
.Select(contract => new PluginCatalogSharedContractInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray());
var compatibility = new PluginCatalogCompatibilityInfo(
entry.MinHostVersion,
entry.ApiVersion);
var repository = new PluginCatalogRepositoryInfo(
entry.IconUrl,
entry.ProjectUrl,
entry.ReadmeUrl,
entry.HomepageUrl,
entry.RepositoryUrl,
entry.Tags.ToArray(),
entry.ReleaseNotes);
var publication = new PluginCatalogPublicationInfo(
entry.ReleaseTag,
entry.ReleaseAssetName,
entry.PublishedAt,
entry.UpdatedAt,
entry.PackageSizeBytes,
entry.Sha256,
null);
var sources = BuildPackageSources(entry);
return new PluginCatalogItemInfo(
manifest,
compatibility,
repository,
publication,
sources,
[]);
}
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.DownloadUrl))
{
return [];
}
var sourceKind = entry.HasReleaseDownloadMetadata
? PluginPackageSourceKind.ReleaseAsset
: PluginPackageSourceKind.RawFallback;
return
[
new PluginPackageSourceInfo(
sourceKind,
entry.DownloadUrl,
entry.Sha256,
entry.PackageSizeBytes)
];
}
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
string? sourceId,
string? sourceLocation,
string? warningMessage)
{
if (string.IsNullOrWhiteSpace(sourceId) && string.IsNullOrWhiteSpace(sourceLocation))
{
return [];
}
var normalizedSourceId = string.IsNullOrWhiteSpace(sourceId)
? "plugin-catalog"
: sourceId.Trim();
return
[
new PluginCatalogSourceInfo(
normalizedSourceId,
normalizedSourceId,
string.IsNullOrWhiteSpace(warningMessage) ? null : warningMessage.Trim(),
string.IsNullOrWhiteSpace(sourceLocation) ? null : sourceLocation.Trim(),
null,
true,
0)
];
} }
public void Dispose() public void Dispose()
@@ -1054,6 +1189,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService); _pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
PluginManagement = _pluginManagementSettingsService; PluginManagement = _pluginManagementSettingsService;
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService); _pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
PluginCatalog = _pluginMarketSettingsService;
PluginMarket = _pluginMarketSettingsService; PluginMarket = _pluginMarketSettingsService;
ApplicationInfo = new ApplicationInfoService(); ApplicationInfo = new ApplicationInfoService();
} }
@@ -1086,6 +1222,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IPluginManagementSettingsService PluginManagement { get; } public IPluginManagementSettingsService PluginManagement { get; }
public IPluginCatalogSettingsService PluginCatalog { get; }
public IPluginMarketSettingsService PluginMarket { get; } public IPluginMarketSettingsService PluginMarket { get; }
public IApplicationInfoService ApplicationInfo { get; } public IApplicationInfoService ApplicationInfo { get; }

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -31,7 +31,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
private bool _isLoadingIcon; private bool _isLoadingIcon;
public PluginMarketItemViewModel( public PluginMarketItemViewModel(
PluginMarketPluginInfo plugin, PluginCatalogItemInfo plugin,
LocalizationService localizationService, LocalizationService localizationService,
string languageCode) string languageCode)
{ {
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
ActionTooltip = L("market.button.install", "Install"); ActionTooltip = L("market.button.install", "Install");
} }
public PluginMarketPluginInfo Info { get; } public PluginCatalogItemInfo Info { get; }
public string PluginId => Info.Id; public string PluginId => Info.Id;
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
public string ReadmeUrl => Info.ReadmeUrl; public string ReadmeUrl => Info.ReadmeUrl;
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies; public IReadOnlyList<PluginCatalogSharedContractInfo> Dependencies => Info.SharedContracts;
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Info.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Info.Capabilities;
public string IconFallbackText { get; } public string IconFallbackText { get; }
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
_readmeService = readmeService; _readmeService = readmeService;
_primaryActionAsync = primaryActionAsync; _primaryActionAsync = primaryActionAsync;
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies); Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
VersionLabel = L("market.detail.version", "Version"); VersionLabel = L("market.detail.version", "Version");
PublisherLabel = L("market.detail.author", "Author"); PublisherLabel = L("market.detail.author", "Author");
ApiVersionLabel = L("market.detail.api_version", "API Version"); ApiVersionLabel = L("market.detail.api_version", "API Version");
@@ -271,7 +275,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public PluginMarketItemViewModel Item { get; } public PluginMarketItemViewModel Item { get; }
public ObservableCollection<PluginMarketDependencyInfo> Dependencies { get; } public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
public string DrawerTitle => Item.Name; public string DrawerTitle => Item.Name;
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown); public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
if (_isInitialized) if (_isInitialized)
@@ -370,6 +378,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{ {
private readonly ISettingsFacadeService _settingsFacade; private readonly ISettingsFacadeService _settingsFacade;
private readonly IPluginCatalogSettingsService _pluginCatalog;
private readonly LocalizationService _localizationService; private readonly LocalizationService _localizationService;
private readonly AirAppMarketIconService _iconService; private readonly AirAppMarketIconService _iconService;
private readonly AirAppMarketReadmeService _readmeService; private readonly AirAppMarketReadmeService _readmeService;
@@ -386,6 +395,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
AirAppMarketReadmeService readmeService) AirAppMarketReadmeService readmeService)
{ {
_settingsFacade = settingsFacade; _settingsFacade = settingsFacade;
_pluginCatalog = _settingsFacade.PluginCatalog;
_localizationService = localizationService; _localizationService = localizationService;
_iconService = iconService; _iconService = iconService;
_readmeService = readmeService; _readmeService = readmeService;
@@ -468,7 +478,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
StatusMessage = L("market.status.loading", "Loading the official plugin market..."); StatusMessage = L("market.status.loading", "Loading the official plugin market...");
RefreshInstalledSnapshot(); RefreshInstalledSnapshot();
var result = await _settingsFacade.PluginMarket.LoadIndexAsync(); var result = await _pluginCatalog.LoadCatalogAsync();
if (!result.Success) if (!result.Success)
{ {
_hasLoadedMarket = false; _hasLoadedMarket = false;
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."), L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
item.Name); item.Name);
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId); var result = await _pluginCatalog.InstallAsync(item.PluginId);
if (result.Success) if (result.Success)
{ {
RefreshInstalledSnapshot(); RefreshInstalledSnapshot();

View 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>

View 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;
}
}

View File

@@ -428,6 +428,10 @@ public sealed class DesktopComponentRuntimeRegistry
BuiltInComponentIds.DesktopIfengNews, BuiltInComponentIds.DesktopIfengNews,
"component.ifeng_news", "component.ifeng_news",
() => new IfengNewsWidget()), () => new IfengNewsWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopJuyaNews,
"component.juya_news",
() => new JuyaNewsWidget()),
new DesktopComponentRuntimeRegistration( new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBilibiliHotSearch, BuiltInComponentIds.DesktopBilibiliHotSearch,
"component.bilibili_hot_search", "component.bilibili_hot_search",

View File

@@ -198,7 +198,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
foreach (var visual in _itemVisuals) foreach (var visual in _itemVisuals)
{ {
visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA")); visual.Host.Background = Brushes.Transparent;
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")); visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
} }

View File

@@ -0,0 +1,106 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="640"
x:Class="LanMountainDesktop.Views.Components.JuyaNewsWidget">
<Border x:Name="RootBorder"
CornerRadius="24"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#fefefe"
CornerRadius="24"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*">
<!-- Header -->
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="10"
Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Spacing="10">
<Border x:Name="AvatarBorder"
Width="36"
Height="36"
CornerRadius="18"
ClipToBounds="True"
Background="#f8f5ec">
<Image x:Name="AvatarImage"
Source="avares://LanMountainDesktop/Assets/juya_avatar.jpg"
Stretch="UniformToFill"/>
</Border>
<TextBlock x:Name="BrandTextBlock"
Text="橘鸦Juya"
Foreground="#bb5649"
FontSize="20"
FontWeight="Bold"
VerticalAlignment="Center" />
</StackPanel>
<Button x:Name="RefreshButton"
Grid.Column="1"
Padding="8,4"
CornerRadius="8"
Background="Transparent"
BorderBrush="#bb5649"
BorderThickness="1"
Foreground="#bb5649"
Focusable="False"
ToolTip.Tip="刷新今日新闻"
Click="OnRefreshButtonClick">
<StackPanel Orientation="Horizontal" Spacing="4">
<fi:SymbolIcon x:Name="RefreshIcon"
Symbol="ArrowSync"
IconVariant="Regular"
FontSize="14"
Foreground="#bb5649" />
<TextBlock x:Name="RefreshButtonText"
Text="刷新"
FontSize="13"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
<!-- 滚动内容区 -->
<ScrollViewer x:Name="ContentScrollViewer"
Grid.Row="1"
VerticalScrollBarVisibility="Auto"
ScrollChanged="OnScrollChanged">
<StackPanel x:Name="NewsStackPanel" Spacing="16">
<!-- 加载提示 -->
<TextBlock x:Name="LoadingTextBlock"
Text="正在加载..."
Foreground="#757575"
FontSize="14"
HorizontalAlignment="Center"
IsVisible="False" />
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#757575"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,827 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class JuyaNewsWidget : UserControl, IDesktopComponentWidget
{
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly HttpClient HttpClient = new()
{
Timeout = TimeSpan.FromSeconds(15)
};
private const string RssUrl = "https://imjuya.github.io/juya-ai-daily/rss.xml";
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 4;
private const int InitialLoadDays = 3;
private const int LoadMoreDays = 3;
private const int MaxCachedDays = 30;
private readonly Dictionary<DateTime, JuyaDailyNews> _cachedNews = new();
private readonly List<DateTime> _loadedDates = new();
private readonly List<DailyNewsView> _dailyViews = new();
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isLoading;
private bool _isNightVisual;
private DateTime _earliestLoadedDate = DateTime.Today;
public JuyaNewsWidget()
{
InitializeComponent();
BrandTextBlock.FontFamily = MiSansFontFamily;
LoadingTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
ApplyLoadingState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
_ = LoadInitialNewsAsync();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
// 卡片背景
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2d2a2a") : Color.Parse("#fefefe"));
// 品牌标题
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
// 刷新按钮
RefreshButton.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
RefreshButton.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
// 头像背景
AvatarBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3d3a3a") : Color.Parse("#f8f5ec"));
// 状态文字
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
LoadingTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
// 更新所有日期视图的样式
foreach (var view in _dailyViews)
{
view.ApplyNightMode(_isNightVisual);
}
}
private async Task LoadInitialNewsAsync()
{
if (!_isAttached || _isLoading)
{
return;
}
_isLoading = true;
LoadingTextBlock.IsVisible = true;
StatusTextBlock.IsVisible = false;
try
{
// 解析RSS获取所有新闻
var allNews = await FetchJuyaNewsAsync();
if (!_isAttached)
{
return;
}
// 缓存新闻数据
foreach (var news in allNews)
{
_cachedNews[news.Date.Date] = news;
}
// 加载最近几天的新闻
var today = DateTime.Today;
var datesToLoad = Enumerable.Range(0, InitialLoadDays)
.Select(i => today.AddDays(-i))
.Where(d => _cachedNews.ContainsKey(d))
.OrderByDescending(d => d)
.ToList();
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
NewsStackPanel.Children.Clear();
_dailyViews.Clear();
_loadedDates.Clear();
foreach (var date in datesToLoad)
{
AddDailyNewsToView(_cachedNews[date]);
_loadedDates.Add(date);
}
if (_loadedDates.Any())
{
_earliestLoadedDate = _loadedDates.Min();
}
LoadingTextBlock.IsVisible = false;
StatusTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
});
}
catch
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
StatusTextBlock.Text = "加载失败";
StatusTextBlock.IsVisible = true;
LoadingTextBlock.IsVisible = false;
});
}
finally
{
_isLoading = false;
}
}
private async Task<List<JuyaDailyNews>> FetchJuyaNewsAsync()
{
var result = new List<JuyaDailyNews>();
try
{
// 使用字节数组获取内容,确保正确解码 UTF-8
var response = await HttpClient.GetByteArrayAsync(RssUrl);
var rssContent = System.Text.Encoding.UTF8.GetString(response);
var doc = XDocument.Parse(rssContent);
var contentNs = XNamespace.Get("http://purl.org/rss/1.0/modules/content/");
var items = doc.Descendants("item");
foreach (var item in items)
{
var title = item.Element("title")?.Value ?? "";
var link = item.Element("link")?.Value ?? "";
var pubDate = item.Element("pubDate")?.Value ?? "";
var contentEncoded = item.Element(contentNs + "encoded")?.Value ?? "";
// 解析日期
if (!DateTime.TryParse(pubDate, out var date))
{
date = DateTime.Today;
}
// 提取封面图URL
var coverImageUrl = ExtractCoverImageUrl(contentEncoded);
// 提取视频链接
var (bilibiliUrl, youtubeUrl) = ExtractVideoUrls(contentEncoded);
// 解析概览(简短列表)
var overviewCategories = ParseOverview(contentEncoded);
// 解析详细内容
var detailedNews = ParseDetailedNews(contentEncoded);
var news = new JuyaDailyNews(
Date: date,
Title: title,
CoverImageUrl: coverImageUrl,
IssueUrl: link,
BilibiliUrl: bilibiliUrl,
YoutubeUrl: youtubeUrl,
OverviewCategories: overviewCategories,
DetailedNews: detailedNews,
FetchedAt: DateTimeOffset.Now
);
result.Add(news);
}
}
catch
{
// 返回空列表
}
return result.OrderByDescending(n => n.Date).ToList();
}
private static string ExtractCoverImageUrl(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return "";
}
var match = Regex.Match(content, @"<img[^>]+src=[""']([^""']+)[""']", RegexOptions.IgnoreCase);
return match.Success ? match.Groups[1].Value : "";
}
private static (string bilibili, string youtube) ExtractVideoUrls(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return ("", "");
}
string bilibiliUrl = "";
string youtubeUrl = "";
var bilibiliMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?bilibili\.com/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
if (bilibiliMatch.Success)
{
bilibiliUrl = bilibiliMatch.Groups[1].Value;
}
var youtubeMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?(?:youtube\.com|youtu\.be)/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
if (youtubeMatch.Success)
{
youtubeUrl = youtubeMatch.Groups[1].Value;
}
return (bilibiliUrl, youtubeUrl);
}
private static List<JuyaOverviewCategory> ParseOverview(string content)
{
var categories = new List<JuyaOverviewCategory>();
if (string.IsNullOrWhiteSpace(content))
{
return categories;
}
var categoryIcons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["要闻"] = "📌",
["开发生态"] = "💻",
["产品应用"] = "📱",
["产品发布"] = "🚀",
["模型发布"] = "🤖",
["行业动态"] = "📈",
["技术与洞察"] = "🔍",
["学术研究"] = "📚",
["研究"] = "🔬",
["开源"] = "🔓",
["投资"] = "💰",
["融资"] = "💵",
["商业"] = "💼",
["市场"] = "📊",
["AI绘画"] = "🎨",
["设计"] = "✏️",
["创意"] = "💡",
["前瞻与传闻"] = "🔮",
["趋势"] = "📉",
["预测"] = "🔭",
["政策"] = "📋",
["法规"] = "⚖️",
["监管"] = "🛡️",
["硬件"] = "🔧",
["芯片"] = "🖥️",
["基础设施"] = "🏗️",
["其他"] = "•",
["要点"] = "📋",
["摘要"] = "📝"
};
var overviewMatch = Regex.Match(content, @"<h2>\s*概览\s*</h2>(.*?)(?:<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!overviewMatch.Success)
{
return categories;
}
var overviewContent = overviewMatch.Groups[1].Value;
var h3Matches = Regex.Matches(overviewContent, @"<h3>([^<]+)</h3>\s*<ul>(.*?)</ul>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
foreach (Match match in h3Matches)
{
var categoryName = match.Groups[1].Value.Trim();
var listContent = match.Groups[2].Value;
var icon = categoryIcons.GetValueOrDefault(categoryName, "•");
var items = new List<JuyaOverviewItem>();
var itemMatches = Regex.Matches(listContent, @"<li>(.*?)</li>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
foreach (Match itemMatch in itemMatches)
{
var itemText = itemMatch.Groups[1].Value;
string itemTitle;
string itemUrl;
int? number = null;
var linkMatch = Regex.Match(itemText, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (linkMatch.Success)
{
itemUrl = linkMatch.Groups[1].Value;
var linkText = Regex.Replace(linkMatch.Groups[2].Value, @"<[^>]+>", "").Trim();
var beforeLink = itemText.Substring(0, itemText.IndexOf("<a", StringComparison.OrdinalIgnoreCase));
itemTitle = Regex.Replace(beforeLink, @"<[^>]+>", "").Trim();
if (string.IsNullOrWhiteSpace(itemTitle))
{
itemTitle = linkText;
}
}
else
{
itemTitle = Regex.Replace(itemText, @"<[^>]+>", "").Trim();
itemUrl = "";
}
var numberMatch = Regex.Match(itemText, @"<code>\s*#(\d+)\s*</code>|#(\d+)");
if (numberMatch.Success)
{
number = int.Parse(numberMatch.Groups[1].Success ? numberMatch.Groups[1].Value : numberMatch.Groups[2].Value);
}
itemTitle = Regex.Replace(itemTitle, @"^\s*#\d+\s*", "").Trim();
itemTitle = Regex.Replace(itemTitle, @"[→↗\s]+$", "").Trim();
if (!string.IsNullOrWhiteSpace(itemTitle) && itemTitle.Length > 1)
{
items.Add(new JuyaOverviewItem(itemTitle, itemUrl, number));
}
}
if (items.Any())
{
categories.Add(new JuyaOverviewCategory(categoryName, icon, items));
}
}
return categories;
}
private static List<JuyaDetailedNewsItem> ParseDetailedNews(string content)
{
var newsItems = new List<JuyaDetailedNewsItem>();
if (string.IsNullOrWhiteSpace(content))
{
return newsItems;
}
var detailedMatch = Regex.Match(content, @"<hr>(.*)$", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!detailedMatch.Success)
{
return newsItems;
}
var detailedContent = detailedMatch.Groups[1].Value;
var newsMatches = Regex.Matches(detailedContent, @"<h2>(.*?)</h2>(.*?)(?=<h2>|<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
foreach (Match match in newsMatches)
{
var headerContent = match.Groups[1].Value;
var bodyContent = match.Groups[2].Value;
var numberMatch = Regex.Match(headerContent, @"<code>\s*#(\d+)\s*</code>");
if (!numberMatch.Success)
{
numberMatch = Regex.Match(headerContent, @"#(\d+)");
}
int? number = numberMatch.Success ? int.Parse(numberMatch.Groups[1].Value) : null;
string title;
var linkMatch = Regex.Match(headerContent, @"<a[^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (linkMatch.Success)
{
title = Regex.Replace(linkMatch.Groups[1].Value, @"<[^>]+>", "").Trim();
}
else
{
title = Regex.Replace(headerContent, @"<code>.*?</code>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
title = Regex.Replace(title, @"<[^>]+>", "").Trim();
title = Regex.Replace(title, @"#\d+", "").Trim();
}
var bodyText = ExtractBodyText(bodyContent);
var relatedLinks = new List<string>();
var linkMatches = Regex.Matches(bodyContent, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
foreach (Match linkMatch2 in linkMatches)
{
var url = linkMatch2.Groups[1].Value;
if (!string.IsNullOrWhiteSpace(url) && !relatedLinks.Contains(url))
{
relatedLinks.Add(url);
}
}
if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(bodyText))
{
newsItems.Add(new JuyaDetailedNewsItem(title, number ?? 0, bodyText, relatedLinks));
}
}
return newsItems;
}
private static string ExtractBodyText(string htmlContent)
{
if (string.IsNullOrWhiteSpace(htmlContent))
{
return "";
}
// 提取 blockquote 内容
var blockquoteMatch = Regex.Match(htmlContent, @"<blockquote>(.*?)</blockquote>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (blockquoteMatch.Success)
{
var text = blockquoteMatch.Groups[1].Value;
// 移除 <p> 标签但保留内容
text = Regex.Replace(text, @"<p>(.*?)</p>", "$1\n\n", RegexOptions.Singleline | RegexOptions.IgnoreCase);
// 移除其他 HTML 标签
text = Regex.Replace(text, @"<[^>]+>", "");
// 清理多余空白
text = Regex.Replace(text, @"\n{3,}", "\n\n");
return text.Trim();
}
// 如果没有 blockquote提取所有 <p> 标签内容
var paragraphs = Regex.Matches(htmlContent, @"<p>(.*?)</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (paragraphs.Count > 0)
{
var text = string.Join("\n\n", paragraphs.Cast<Match>().Select(m =>
Regex.Replace(m.Groups[1].Value, @"<[^>]+>", "").Trim()));
return text.Trim();
}
// 最后尝试直接移除所有 HTML 标签
return Regex.Replace(htmlContent, @"<[^>]+>", "").Trim();
}
private void AddDailyNewsToView(JuyaDailyNews news)
{
var view = new DailyNewsView(news, _isNightVisual);
view.CoverImageClicked += (s, e) => TryOpenUrl(news.IssueUrl);
view.NewsItemClicked += (s, url) => TryOpenUrl(url);
NewsStackPanel.Children.Add(view);
_dailyViews.Add(view);
}
private async void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
{
if (_isLoading || !_isAttached)
{
return;
}
var scrollViewer = (ScrollViewer)sender!;
var offset = scrollViewer.Offset;
var extent = scrollViewer.Extent;
var viewport = scrollViewer.Viewport;
if (offset.Y >= extent.Height - viewport.Height - 200)
{
await LoadMoreNewsAsync();
}
}
private async Task LoadMoreNewsAsync()
{
if (_isLoading || !_isAttached)
{
return;
}
var nextDates = Enumerable.Range(1, LoadMoreDays)
.Select(i => _earliestLoadedDate.AddDays(-i))
.Where(d => _cachedNews.ContainsKey(d) && !_loadedDates.Contains(d))
.ToList();
if (!nextDates.Any())
{
return;
}
_isLoading = true;
LoadingTextBlock.IsVisible = true;
try
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
foreach (var date in nextDates.OrderByDescending(d => d))
{
AddDailyNewsToView(_cachedNews[date]);
_loadedDates.Add(date);
}
_earliestLoadedDate = _loadedDates.Min();
LoadingTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
});
}
finally
{
_isLoading = false;
}
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
e.Handled = true;
if (_isLoading)
{
return;
}
_isLoading = true;
RefreshButtonText.Text = "刷新中...";
RefreshIcon.IsEnabled = false;
try
{
var allNews = await FetchJuyaNewsAsync();
if (!_isAttached)
{
return;
}
var today = DateTime.Today;
var todayNews = allNews.FirstOrDefault(n => n.Date.Date == today);
if (todayNews != null)
{
_cachedNews[today] = todayNews;
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
var existingIndex = _loadedDates.IndexOf(today);
if (existingIndex >= 0 && _dailyViews.Count > existingIndex)
{
var oldView = _dailyViews[existingIndex];
var insertIndex = NewsStackPanel.Children.IndexOf(oldView);
if (insertIndex >= 0)
{
NewsStackPanel.Children.RemoveAt(insertIndex);
_dailyViews.RemoveAt(existingIndex);
var newView = new DailyNewsView(todayNews, _isNightVisual);
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
NewsStackPanel.Children.Insert(insertIndex, newView);
_dailyViews.Insert(existingIndex, newView);
}
}
else
{
var newView = new DailyNewsView(todayNews, _isNightVisual);
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
NewsStackPanel.Children.Insert(0, newView);
_dailyViews.Insert(0, newView);
_loadedDates.Insert(0, today);
}
RefreshButtonText.Text = "刷新";
RefreshIcon.IsEnabled = true;
UpdateAdaptiveLayout();
});
}
else
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
RefreshButtonText.Text = "刷新";
RefreshIcon.IsEnabled = true;
});
}
}
catch
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
RefreshButtonText.Text = "刷新";
RefreshIcon.IsEnabled = true;
});
}
finally
{
_isLoading = false;
}
}
private void TryOpenUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// 忽略错误
}
}
private void ApplyLoadingState()
{
StatusTextBlock.Text = "加载中...";
StatusTextBlock.IsVisible = true;
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var softScale = Math.Clamp(scale, 0.80, 1.32);
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
RootBorder.CornerRadius = unifiedMainRectangle;
CardBorder.CornerRadius = unifiedMainRectangle;
var horizontalPadding = Math.Clamp(16 * softScale, 10, 24);
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var headerHeight = Math.Clamp(40 * softScale, 28, 56);
HeaderGrid.Height = headerHeight;
BrandTextBlock.FontSize = Math.Clamp(20 * softScale, 14, 26);
var avatarSize = Math.Clamp(36 * softScale, 24, 48);
AvatarBorder.Width = avatarSize;
AvatarBorder.Height = avatarSize;
AvatarBorder.CornerRadius = new CornerRadius(avatarSize / 2);
var buttonFontSize = Math.Clamp(13 * softScale, 10, 16);
RefreshButton.FontSize = buttonFontSize;
RefreshButton.Padding = new Thickness(
Math.Clamp(8 * softScale, 6, 12),
Math.Clamp(4 * softScale, 2, 6)
);
StatusTextBlock.FontSize = Math.Clamp(16 * softScale, 12, 22);
LoadingTextBlock.FontSize = Math.Clamp(14 * softScale, 11, 18);
foreach (var view in _dailyViews)
{
view.UpdateLayout(softScale, totalWidth - horizontalPadding * 2);
}
ApplyNightModeVisual();
}
private double ResolveScale()
{
var expectedWidth = _currentCellSize * BaseWidthCells;
var expectedHeight = _currentCellSize * BaseHeightCells;
if (expectedWidth <= 0 || expectedHeight <= 0)
{
return 1d;
}
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
var scaleX = actualWidth / expectedWidth;
var scaleY = actualHeight / expectedHeight;
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.4);
}
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
private static double ResolveUnifiedMainRadiusValue() =>
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
}
// 数据模型
public sealed record JuyaDailyNews(
DateTime Date,
string Title,
string CoverImageUrl,
string IssueUrl,
string BilibiliUrl,
string YoutubeUrl,
IReadOnlyList<JuyaOverviewCategory> OverviewCategories,
IReadOnlyList<JuyaDetailedNewsItem> DetailedNews,
DateTimeOffset FetchedAt);
public sealed record JuyaOverviewCategory(
string Name,
string Icon,
IReadOnlyList<JuyaOverviewItem> Items);
public sealed record JuyaOverviewItem(
string Title,
string Url,
int? Number);
public sealed record JuyaDetailedNewsItem(
string Title,
int Number,
string BodyText,
IReadOnlyList<string> RelatedLinks);

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -9,86 +9,124 @@
d:DesignHeight="480" d:DesignHeight="480"
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget"> x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
<Border x:Name="RootBorder" <Grid>
Background="#F1F4F9" <Border x:Name="RootBorder"
CornerRadius="20" Background="#F1F4F9"
ClipToBounds="True" CornerRadius="20"
Padding="8"> ClipToBounds="True"
<Grid RowDefinitions="*,Auto" Padding="8">
RowSpacing="8"> <Grid RowDefinitions="*,Auto"
<Border x:Name="CanvasBorder" RowSpacing="8">
Grid.Row="0" <Border x:Name="CanvasBorder"
Background="#FFFFFF" Grid.Row="0"
BorderBrush="#24000000" Background="#FFFFFF"
BorderThickness="1" BorderBrush="#24000000"
CornerRadius="14" BorderThickness="1"
ClipToBounds="True"> CornerRadius="14"
<inking:InkCanvas x:Name="InkCanvas" /> ClipToBounds="True">
</Border> <inking:InkCanvas x:Name="InkCanvas" />
</Border>
<Border x:Name="ToolbarBorder" <Border x:Name="ToolbarBorder"
Grid.Row="1" Grid.Row="1"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Background="#E6FFFFFF" Background="#E6FFFFFF"
BorderBrush="#16000000" BorderBrush="#16000000"
BorderThickness="1"
CornerRadius="14"
Padding="8,6">
<StackPanel x:Name="ToolbarButtonsPanel"
Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<Button x:Name="PenButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Pen"
Click="OnPenButtonClick">
<fi:SymbolIcon x:Name="PenIcon"
Symbol="Pen"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="EraserButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Eraser"
Click="OnEraserButtonClick">
<fi:SymbolIcon x:Name="EraserIcon"
Symbol="EraserTool"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ClearButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Clear"
Click="OnClearButtonClick">
<fi:SymbolIcon x:Name="ClearIcon"
Symbol="Delete"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ExportButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Export SVG"
Click="OnExportButtonClick">
<fi:SymbolIcon x:Name="ExportIcon"
Symbol="ArrowExport"
IconVariant="Regular"
FontSize="14" />
</Button>
</StackPanel>
</Border>
</Grid>
</Border>
<Popup x:Name="ColorPickerPopup"
Placement="Top"
PlacementTarget="{Binding #PenButton}"
IsLightDismissEnabled="True"
WindowManagerAddShadowHint="False">
<Border Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="14" CornerRadius="8"
Padding="8,6"> Padding="12">
<StackPanel x:Name="ToolbarButtonsPanel" <StackPanel Spacing="12">
Orientation="Horizontal" <ColorView x:Name="InkColorPicker"
HorizontalAlignment="Center" IsAlphaEnabled="False"
VerticalAlignment="Center" IsColorSpectrumVisible="True"
Spacing="8"> IsColorPaletteVisible="True"
<Button x:Name="PenButton" IsHexInputVisible="True"
Width="30" ColorChanged="OnColorPickerColorChanged" />
Height="30" <Grid ColumnDefinitions="Auto,*"
Padding="0" ColumnSpacing="8">
CornerRadius="15" <TextBlock Grid.Column="0"
ToolTip.Tip="Pen" Text="粗细"
Click="OnPenButtonClick"> VerticalAlignment="Center"
<fi:SymbolIcon x:Name="PenIcon" FontSize="12" />
Symbol="Pen" <Slider x:Name="InkThicknessSlider"
IconVariant="Regular" Grid.Column="1"
FontSize="14" /> Minimum="1"
</Button> Maximum="8"
<Button x:Name="EraserButton" Value="2.5"
Width="30" SmallChange="0.5"
Height="30" LargeChange="1"
Padding="0" ValueChanged="OnInkThicknessSliderValueChanged" />
CornerRadius="15" </Grid>
ToolTip.Tip="Eraser"
Click="OnEraserButtonClick">
<fi:SymbolIcon x:Name="EraserIcon"
Symbol="EraserTool"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ClearButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Clear"
Click="OnClearButtonClick">
<fi:SymbolIcon x:Name="ClearIcon"
Symbol="Delete"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ExportButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Export SVG"
Click="OnExportButtonClick">
<fi:SymbolIcon x:Name="ExportIcon"
Symbol="ArrowExport"
IconVariant="Regular"
FontSize="14" />
</Button>
</StackPanel> </StackPanel>
</Border> </Border>
</Grid> </Popup>
</Border> </Grid>
</UserControl> </UserControl>

View File

@@ -6,6 +6,7 @@ using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
@@ -38,7 +39,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private double _currentCellSize = 48; private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen; private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied; private bool? _isNightModeApplied;
private SKColor _currentInkColor = SKColors.Black; private SKColor _selectedInkColor = SKColors.Black;
private float _selectedInkThickness = 2.5f;
private string _componentId = BuiltInComponentIds.DesktopWhiteboard; private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
private string _placementId = string.Empty; private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays; private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
@@ -66,9 +68,27 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ApplyCellSize(_currentCellSize); ApplyCellSize(_currentCellSize);
RefreshFromSettings(); RefreshFromSettings();
ApplyThemeVisual(force: true); ApplyThemeVisual(force: true);
InitializeColorPicker();
SetToolMode(WhiteboardToolMode.Pen); SetToolMode(WhiteboardToolMode.Pen);
} }
private void InitializeColorPicker()
{
if (InkColorPicker is not null)
{
InkColorPicker.Color = new Color(
_selectedInkColor.Alpha,
_selectedInkColor.Red,
_selectedInkColor.Green,
_selectedInkColor.Blue);
}
if (InkThicknessSlider is not null)
{
InkThicknessSlider.Value = _selectedInkThickness;
}
}
public int NoteRetentionDays => _noteRetentionDays; public int NoteRetentionDays => _noteRetentionDays;
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
@@ -97,7 +117,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
InkCanvas.EditingMode = InkCanvasEditingMode.Ink; InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings; var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.IgnorePressure = true; settings.IgnorePressure = true;
settings.InkThickness = 2.5f; settings.InkThickness = _selectedInkThickness;
settings.EraserSize = new Size(20, 20); settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true; settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048; settings.MaxBitmapCacheSize = 2048;
@@ -135,7 +155,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
} }
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings; var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.InkThickness = (float)Math.Clamp(_currentCellSize * 0.06, 2.0, 6.0);
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44); var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
settings.EraserSize = new Size(eraserSize, eraserSize); settings.EraserSize = new Size(eraserSize, eraserSize);
} }
@@ -149,7 +168,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
} }
_isNightModeApplied = isNightMode; _isNightModeApplied = isNightMode;
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9")); RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF")); CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
@@ -157,8 +175,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF")); ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000")); ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
RecolorAllStrokes(_currentInkColor);
RefreshToolButtonVisuals(); RefreshToolButtonVisuals();
} }
@@ -204,6 +220,30 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
} }
} }
public void ForceSaveNote()
{
if (_disposed || !HasValidPersistenceContext())
{
return;
}
if (!_noteDirty)
{
return;
}
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
try
{
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
}
catch
{
}
}
public void Dispose() public void Dispose()
{ {
if (_disposed) if (_disposed)
@@ -300,12 +340,31 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
if (mode == WhiteboardToolMode.Pen) if (mode == WhiteboardToolMode.Pen)
{ {
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor; InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
} }
RefreshToolButtonVisuals(); RefreshToolButtonVisuals();
} }
private void SetInkColor(SKColor color)
{
_selectedInkColor = color;
if (_toolMode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
}
RefreshToolButtonVisuals();
}
private void SetInkThickness(float thickness)
{
_selectedInkThickness = Math.Clamp(thickness, 1.0f, 8.0f);
if (_toolMode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkThickness = _selectedInkThickness;
}
}
private void RefreshToolButtonVisuals() private void RefreshToolButtonVisuals()
{ {
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode(); var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
@@ -350,7 +409,32 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnPenButtonClick(object? sender, RoutedEventArgs e) private void OnPenButtonClick(object? sender, RoutedEventArgs e)
{ {
SetToolMode(WhiteboardToolMode.Pen); if (_toolMode == WhiteboardToolMode.Pen && ColorPickerPopup is not null)
{
if (ColorPickerPopup.IsOpen)
{
ColorPickerPopup.Close();
}
else
{
ColorPickerPopup.Open();
}
}
else
{
SetToolMode(WhiteboardToolMode.Pen);
}
}
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
{
var color = e.NewColor;
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
}
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
SetInkThickness((float)e.NewValue);
} }
private void OnEraserButtonClick(object? sender, RoutedEventArgs e) private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
@@ -509,14 +593,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
_noteDirty = false; _noteDirty = false;
_noteSaveTimer.Stop(); _noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot(); var noteSnapshot = BuildNoteSnapshot();
var componentId = _componentId; try
var placementId = _placementId; {
var retentionDays = _noteRetentionDays; _notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
_ = Task.Run(() => _notePersistenceService.SaveNote( }
componentId, catch
placementId, {
noteSnapshot, }
retentionDays));
} }
private async void SchedulePersistedNoteLoad() private async void SchedulePersistedNoteLoad()
@@ -553,7 +636,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
{ {
ClearAllStrokes(); ClearAllStrokes();
ApplyNoteSnapshot(noteSnapshot); ApplyNoteSnapshot(noteSnapshot);
RecolorAllStrokes(_currentInkColor);
} }
finally finally
{ {

View File

@@ -3276,4 +3276,19 @@ public partial class MainWindow
_isComponentLibraryComponentGestureActive = false; _isComponentLibraryComponentGestureActive = false;
ApplyComponentLibraryComponentOffset(); ApplyComponentLibraryComponentOffset();
} }
internal void SaveAllWhiteboardNotes()
{
foreach (var pageGrid in _desktopPageComponentGrids.Values)
{
foreach (var host in pageGrid.Children.OfType<Border>())
{
var contentHost = TryGetContentHost(host);
if (contentHost?.Child is WhiteboardWidget whiteboard)
{
whiteboard.ForceSaveNote();
}
}
}
}
} }

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@@ -552,7 +552,6 @@ public partial class MainWindow
{ {
if (node is Control control) if (node is Control control)
{ {
// Avoid swiping pages when interacting with desktop components/widgets.
if (control.Classes.Contains("desktop-component") || if (control.Classes.Contains("desktop-component") ||
control.Classes.Contains("desktop-component-host")) control.Classes.Contains("desktop-component-host"))
{ {
@@ -560,7 +559,31 @@ public partial class MainWindow
} }
} }
if (node is Button or TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch) if (node is Button button && IsLauncherTileButton(button))
{
continue;
}
if (node is TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch)
{
return true;
}
}
return false;
}
private static bool IsLauncherTileButton(Button? button)
{
if (button is null)
{
return false;
}
foreach (var node in button.GetSelfAndVisualAncestors())
{
if (node is WrapPanel panel &&
(panel.Name == "LauncherRootTilePanel" || panel.Name == "LauncherFolderTilePanel"))
{ {
return true; return true;
} }
@@ -611,7 +634,17 @@ public partial class MainWindow
private static bool IsDesktopSwipeBlockingNode(object node) private static bool IsDesktopSwipeBlockingNode(object node)
{ {
if (node is Button or TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem or ScrollViewer) if (node is ScrollViewer scrollViewer && IsLauncherScrollViewer(scrollViewer))
{
return false;
}
if (node is Button button && IsLauncherTileButton(button))
{
return false;
}
if (node is TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem)
{ {
return true; return true;
} }
@@ -625,13 +658,23 @@ public partial class MainWindow
} }
var typeName = node.GetType().Name; var typeName = node.GetType().Name;
return typeName.Contains("Button", StringComparison.OrdinalIgnoreCase) || return typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) || typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) || typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase); typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase);
} }
private static bool IsLauncherScrollViewer(ScrollViewer? scrollViewer)
{
if (scrollViewer is null)
{
return false;
}
return scrollViewer.Name == "LauncherRootScrollViewer" ||
scrollViewer.Name == "LauncherFolderScrollViewer";
}
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point) private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
{ {
point = default; point = default;

View File

@@ -500,6 +500,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
var wasVisible = IsVisible; var wasVisible = IsVisible;
var windowState = WindowState.ToString(); var windowState = WindowState.ToString();
SaveAllWhiteboardNotes();
PersistSettings(); PersistSettings();
_componentEditorWindowService.Close(); _componentEditorWindowService.Close();
if (_detachedComponentLibraryWindow is not null) if (_detachedComponentLibraryWindow is not null)

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
@@ -12,6 +13,8 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketInstallService : IDisposable internal sealed class AirAppMarketInstallService : IDisposable
{ {
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
private readonly PluginRuntimeService _runtime; private readonly PluginRuntimeService _runtime;
private readonly PluginsInstallHelperClient _helperClient = new(); private readonly PluginsInstallHelperClient _helperClient = new();
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -38,107 +41,226 @@ internal sealed class AirAppMarketInstallService : IDisposable
{ {
ArgumentNullException.ThrowIfNull(plugin); ArgumentNullException.ThrowIfNull(plugin);
Directory.CreateDirectory(_downloadsDirectory); if (OperatingSystem.IsWindows())
var downloadPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
try
{ {
AppLogger.Info( var helperPath = ResolveHelperPath();
"PluginMarket", if (!File.Exists(helperPath))
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
AppLogger.Info(
"PluginMarket",
$"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'.");
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
{ {
var localCopyResult = await _downloadService.DownloadAsync(
localPackagePath,
downloadPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken);
if (!localCopyResult.Success)
{
return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage);
}
}
else
{
var downloadResult = await _downloadService.DownloadAsync(
resolvedDownloadUrl,
downloadPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken);
if (!downloadResult.Success)
{
return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage);
}
}
var actualSize = new FileInfo(downloadPath).Length;
string actualHash;
await using (var hashStream = File.OpenRead(downloadPath))
{
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
AppLogger.Error(
"PluginMarket",
$"SHA-256 verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadUrl='{resolvedDownloadUrl}'; DownloadPath='{downloadPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
File.Delete(downloadPath);
return new AirAppMarketInstallResult( return new AirAppMarketInstallResult(
false, false,
null, null,
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}. Source {resolvedDownloadUrl}."); $"Plugins install helper was not found at '{helperPath}'.");
}
}
Directory.CreateDirectory(_downloadsDirectory);
var sources = plugin.GetPackageSourcesInInstallOrder();
if (sources.Count == 0)
{
return new AirAppMarketInstallResult(
false,
null,
"Plugin does not declare any package sources.");
}
AppLogger.Info(
"PluginMarket",
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'.");
var sourceErrors = new List<string>();
foreach (var source in sources)
{
var attemptResult = await TryInstallFromSourceAsync(plugin, source, cancellationToken).ConfigureAwait(false);
if (attemptResult.Success)
{
return new AirAppMarketInstallResult(true, attemptResult.Manifest, null);
}
if (attemptResult.Fatal)
{
return new AirAppMarketInstallResult(false, null, attemptResult.ErrorMessage);
}
if (!string.IsNullOrWhiteSpace(attemptResult.ErrorMessage))
{
sourceErrors.Add($"{source.SourceKind}: {attemptResult.ErrorMessage}");
}
}
var combinedMessage = sourceErrors.Count == 0
? $"Failed to install plugin '{plugin.Id}' from all available package sources."
: $"Failed to install plugin '{plugin.Id}' from all available package sources. {string.Join(" ", sourceErrors)}";
return new AirAppMarketInstallResult(false, null, combinedMessage);
}
private async Task<AirAppMarketInstallAttemptResult> TryInstallFromSourceAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken = default)
{
var attemptPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp");
try
{
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false);
AppLogger.Warn(
"PluginMarket",
$"Resolved package source for '{plugin.Id}' to '{resolvedDownloadUrl}' using '{source.SourceKind}'.");
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, attemptPath, cancellationToken).ConfigureAwait(false);
if (!acquireResult.Success)
{
TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, acquireResult.ErrorMessage);
}
var verificationResult = await VerifyPackageAsync(plugin, attemptPath, cancellationToken).ConfigureAwait(false);
if (!verificationResult.Success)
{
TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, verificationResult.ErrorMessage);
} }
PluginManifest manifest; PluginManifest manifest;
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
{ {
var helperResult = await _helperClient.InstallPackageAsync( var helperResult = await _helperClient.InstallPackageAsync(
downloadPath, attemptPath,
_runtime.PluginsDirectory, _runtime.PluginsDirectory,
cancellationToken); cancellationToken).ConfigureAwait(false);
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath)) if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
{ {
return new AirAppMarketInstallResult( var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed.";
false, AppLogger.Error(
null, "PluginMarket",
helperResult.ErrorMessage ?? "Plugins install helper failed."); $"Windows install helper failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
} }
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath); manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
} }
else else
{ {
manifest = _runtime.InstallPluginPackage(downloadPath); manifest = _runtime.InstallPluginPackage(attemptPath);
} }
AppLogger.Info( AppLogger.Info(
"PluginMarket", "PluginMarket",
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'."); $"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{attemptPath}'; SourceKind='{source.SourceKind}'.");
return new AirAppMarketInstallResult(true, manifest, null); return new AirAppMarketInstallAttemptResult(true, true, manifest, null);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
AppLogger.Warn( AppLogger.Warn(
"PluginMarket", "PluginMarket",
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'."); $"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.");
throw; throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Error( AppLogger.Error(
"PluginMarket", "PluginMarket",
$"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.", $"Install attempt failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.",
ex); ex);
return new AirAppMarketInstallResult(false, null, ex.Message); TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, ex.Message);
}
}
private async Task<AirAppMarketAcquisitionResult> AcquirePackageAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
string resolvedDownloadUrl,
string attemptPath,
CancellationToken cancellationToken)
{
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
{
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
{
AppLogger.Info(
"PluginMarket",
$"Copying workspace package for '{plugin.Id}' from '{localPackagePath}' to '{attemptPath}'.");
}
var localCopyResult = await _downloadService.DownloadAsync(
localPackagePath,
attemptPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!localCopyResult.Success)
{
return new AirAppMarketAcquisitionResult(false, localCopyResult.ErrorMessage);
}
return new AirAppMarketAcquisitionResult(true, null);
}
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
{
return new AirAppMarketAcquisitionResult(
false,
$"Workspace package source '{source.Url}' could not be resolved to a local file.");
}
var downloadResult = await _downloadService.DownloadAsync(
resolvedDownloadUrl,
attemptPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!downloadResult.Success)
{
return new AirAppMarketAcquisitionResult(false, downloadResult.ErrorMessage);
}
return new AirAppMarketAcquisitionResult(true, null);
}
private async Task<AirAppMarketVerificationResult> VerifyPackageAsync(
AirAppMarketPluginEntry plugin,
string attemptPath,
CancellationToken cancellationToken)
{
var actualSize = new FileInfo(attemptPath).Length;
string actualHash;
await using (var hashStream = File.OpenRead(attemptPath))
{
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken).ConfigureAwait(false);
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
if (actualSize != plugin.PackageSizeBytes || !string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
AppLogger.Error(
"PluginMarket",
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
return new AirAppMarketVerificationResult(
false,
$"Package verification failed. Expected SHA-256 {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}.");
}
return new AirAppMarketVerificationResult(true, null);
}
private static string ResolveHelperPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Ignore cleanup failures for temporary install artifacts.
} }
} }
@@ -152,4 +274,18 @@ internal sealed class AirAppMarketInstallService : IDisposable
var invalidChars = Path.GetInvalidFileNameChars(); var invalidChars = Path.GetInvalidFileNameChars();
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
} }
private sealed record AirAppMarketInstallAttemptResult(
bool Success,
bool Fatal,
PluginManifest? Manifest,
string? ErrorMessage);
private sealed record AirAppMarketAcquisitionResult(
bool Success,
string? ErrorMessage);
private sealed record AirAppMarketVerificationResult(
bool Success,
string? ErrorMessage);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -22,14 +22,46 @@ internal sealed class AirAppMarketReleaseResolverService
{ {
ArgumentNullException.ThrowIfNull(plugin); ArgumentNullException.ThrowIfNull(plugin);
if (!plugin.HasReleaseDownloadMetadata) var firstSource = plugin.GetPackageSourcesInInstallOrder().FirstOrDefault();
if (firstSource is null)
{ {
return plugin.DownloadUrl; return plugin.DownloadUrl;
} }
return await ResolveDownloadUrlAsync(plugin, firstSource, cancellationToken).ConfigureAwait(false);
}
public async Task<string> ResolveDownloadUrlAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
ArgumentNullException.ThrowIfNull(source);
return source.SourceKind switch
{
PluginPackageSourceKind.ReleaseAsset => await ResolveReleaseAssetDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false),
PluginPackageSourceKind.RawFallback => source.Url,
PluginPackageSourceKind.WorkspaceLocal => source.Url,
_ => source.Url
};
}
private async Task<string> ResolveReleaseAssetDownloadUrlAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken)
{
var sourceUrl = source.Url;
if (!plugin.HasReleaseDownloadMetadata)
{
return sourceUrl;
}
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName)) if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
{ {
return plugin.DownloadUrl; return sourceUrl;
} }
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl( var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
@@ -46,15 +78,15 @@ internal sealed class AirAppMarketReleaseResolverService
try try
{ {
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient); using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken); var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken).ConfigureAwait(false);
var asset = release?.Assets.FirstOrDefault(candidate => var asset = release?.Assets.FirstOrDefault(candidate =>
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase)); string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl; return asset?.BrowserDownloadUrl ?? releaseDownloadUrl;
} }
catch catch
{ {
return plugin.DownloadUrl; return releaseDownloadUrl;
} }
} }

View File

@@ -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 收集匿名数据(具体数据需后台查看)

View File

@@ -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
View File

@@ -1,53 +1,133 @@
# LanMountainDesktop # 阑山桌面 / LanMountainDesktop
`LanMountainDesktop` is the authoritative host repository for the desktop app and the host-side Plugin SDK. > 你的桌面,不止一面
## Repository Ownership [![.NET 10](https://img.shields.io/badge/.NET-10-512BD4)](https://dotnet.microsoft.com/)
[![Avalonia UI](https://img.shields.io/badge/Avalonia%20UI-11.2-blue)](https://avaloniaui.net/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
This repository owns: > [!IMPORTANT]
> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。
>
> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。
- `LanMountainDesktop/`: desktop host app and plugin runtime ## 简介
- `LanMountainDesktop.PluginSdk/`: canonical plugin API baseline (`4.0.0`)
- `LanMountainDesktop.Shared.Contracts/`: shared host/plugin contract types
- `LanMountainDesktop.Appearance/`: host appearance and radius token generation
- `LanMountainDesktop.Settings.Core/`: host settings primitives
- `LanMountainDesktop.Tests/`: host and SDK tests
This repository does not own: **阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
- plugin market metadata or developer portal content 基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
- official sample plugin release source
- independent ecosystem documentation hub
## Ecosystem Boundaries ![Platform](https://img.shields.io/badge/Windows-✓-0078D4)
![Platform](https://img.shields.io/badge/Linux-✓-FCC624?logo=linux&logoColor=black)
![Platform](https://img.shields.io/badge/macOS-✓-000000?logo=apple)
- Host and SDK source of truth: `LanMountainDesktop` (this repo) ## 核心特性
- Plugin market and developer materials: standalone `LanAirApp` repo
- Official sample plugin source of truth: standalone `LanMountainDesktop.SamplePlugin` repo
- `ClassIsland`: reference-only project, not part of build or release flow
## Plugin SDK v4 Baseline ### 📊 信息聚合
- 课程表、日历、天气、新闻、热搜
- 所有信息一目了然,无需频繁切换窗口
- API baseline: `4.0.0` ### 🎯 效率工具
- Manifest file: `plugin.json` - 自习环境监测、计时器、知识卡片
- Package extension: `.laapp` - 最近文档、浏览器快捷入口
- Entry model: `Initialize(HostBuilderContext, IServiceCollection)` - 常用工具组件一键触达
- Appearance model: `IPluginAppearanceContext`, `PluginAppearanceSnapshot`, `PluginCornerRadiusTokens`, `PluginCornerRadiusPreset`
- Component registration model: `AddPluginDesktopComponent<TControl>(PluginDesktopComponentOptions options)`
## Plugin Package Surfaces ### 🎨 个性化桌面
- 自由布局,随心所欲摆放组件
- 多页桌面,工作学习场景分离
- 主题切换、玻璃效果、圆角风格
- `LanMountainDesktop.PluginSdk`: official plugin SDK package (includes `buildTransitive` default `.laapp` packaging targets) ### 🔌 插件生态
- `LanMountainDesktop.Shared.Contracts`: shared contract package for host/plugin boundaries - 通过 `.laapp` 插件扩展功能
- `LanMountainDesktop.PluginTemplate`: official `dotnet new` template package (`shortName`: `lmd-plugin`) - 官方 Plugin SDK 支持自定义组件
- 设置页、组件、集成功能一站式接入
Use `scripts/Pack-PluginPackages.ps1` to generate local-feed packages for CI or workspace integration tests. ## 为谁而设计
## Workspace Market Resolution | 用户类型 | 典型场景 |
|---------|---------|
| 🎓 学生用户 | 课程表、自习监测、计时、天气和日常信息聚合 |
| 💼 办公用户 | 日历、资讯、最近文档、常用工具入口 |
| 🎨 效率爱好者 | 自由布局、主题切换、插件扩展 |
| 🇨🇳 中文用户 | 本地化界面、农历和节假日等本地语境支持 |
For local market debugging, the host resolves workspace files from the sibling repository path (`..\\LanAirApp`) instead of reading the in-repo mirror folder. ## 快速开始
### 环境要求
- .NET SDK 10
### 构建与运行
```bash
# 还原依赖
dotnet restore
# 构建项目
dotnet build LanMountainDesktop.slnx -c Debug
# 运行桌面宿主
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
### 运行测试
```bash
dotnet test LanMountainDesktop.slnx -c Debug
```
## 插件开发
阑山桌面支持通过 Plugin SDK 开发自定义插件:
```bash
# 安装插件模板
dotnet new install LanMountainDesktop.PluginTemplate
# 创建新插件
dotnet new lmd-plugin -n MyPlugin
```
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
## 项目结构
```
LanMountainDesktop/
├── LanMountainDesktop/ # 桌面宿主应用
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施
├── LanMountainDesktop.Settings.Core/# 设置持久化基础设施
└── LanMountainDesktop.Tests/ # 测试项目
```
## 生态边界
| 项目 | 职责 |
|-----|------|
| **本仓库** | 桌面宿主、插件运行时、Plugin SDK、共享契约 |
| [LanAirApp](https://github.com/yourorg/LanAirApp) | 插件市场元数据、开发者生态材料 |
| [LanMountainDesktop.SamplePlugin](https://github.com/yourorg/LanMountainDesktop.SamplePlugin) | 官方示例插件 |
## 文档索引
- [产品定位](docs/PRODUCT.md) - 产品愿景与目标用户
- [架构说明](docs/ARCHITECTURE.md) - 仓库结构与运行时主线
- [开发指南](docs/DEVELOPMENT.md) - 构建、测试、调试
- [视觉规范](docs/VISUAL_SPEC.md) - 主题、颜色、玻璃层级
- [圆角规范](docs/CORNER_RADIUS_SPEC.md) - 圆角层级与动态规则
- [贡献指南](docs/CONTRIBUTING.md) - PR、spec、文档协作规则
## 技术栈
- **UI 框架**: [Avalonia UI](https://avaloniaui.net/)
- **开发平台**: [.NET 10](https://dotnet.microsoft.com/)
- **支持平台**: Windows 10+, Linux, macOS
## 许可证
[MIT](LICENSE)
See:
- `docs/ECOSYSTEM_BOUNDARIES.md`
- `docs/PLUGIN_SDK_V4_MIGRATION.md`

View File

@@ -0,0 +1,18 @@
{
"widget.display_name": "Radio Station Schedule",
"widget.category": "Info",
"widget.loading": "Loading schedule...",
"widget.retry": "Retry",
"widget.no_schedule": "No schedule data",
"widget.network_error": "Network error",
"settings.title": "VoiceHub Settings",
"settings.description": "Configure radio station schedule data source and display options",
"settings.apiUrl.title": "API URL",
"settings.apiUrl.description": "VoiceHub backend API URL for fetching schedule data",
"settings.showRequester.title": "Show Requester",
"settings.showRequester.description": "Display requester information in the schedule list",
"settings.showVoteCount.title": "Show Vote Count",
"settings.showVoteCount.description": "Display song vote count in the schedule list",
"settings.refreshInterval.title": "Refresh Interval",
"settings.refreshInterval.description": "Time interval for automatic schedule data refresh"
}

View File

@@ -0,0 +1,18 @@
{
"widget.display_name": "广播站排期",
"widget.category": "信息",
"widget.loading": "正在加载排期...",
"widget.retry": "重试",
"widget.no_schedule": "暂无排期数据",
"widget.network_error": "网络错误",
"settings.title": "VoiceHub 设置",
"settings.description": "配置广播站排期数据源和显示选项",
"settings.apiUrl.title": "API 地址",
"settings.apiUrl.description": "VoiceHub 后端 API 地址,用于获取排期数据",
"settings.showRequester.title": "显示点歌人",
"settings.showRequester.description": "在排期列表中显示点歌人信息",
"settings.showVoteCount.title": "显示投票数",
"settings.showVoteCount.description": "在排期列表中显示歌曲投票数",
"settings.refreshInterval.title": "刷新间隔",
"settings.refreshInterval.description": "自动刷新排期数据的时间间隔"
}

View File

@@ -0,0 +1,27 @@
namespace VoiceHubLanDesktop.Models;
/// <summary>
/// 插件设置
/// </summary>
public sealed class PluginSettings
{
/// <summary>
/// API 地址
/// </summary>
public string ApiUrl { get; set; } = "https://voicehub.lao-shui.top/api/songs/public";
/// <summary>
/// 是否显示点歌人
/// </summary>
public bool ShowRequester { get; set; } = true;
/// <summary>
/// 是否显示投票数
/// </summary>
public bool ShowVoteCount { get; set; } = false;
/// <summary>
/// 刷新间隔(分钟)
/// </summary>
public int RefreshIntervalMinutes { get; set; } = 60;
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json.Serialization;
namespace VoiceHubLanDesktop.Models;
/// <summary>
/// 歌曲信息
/// </summary>
public sealed class Song
{
/// <summary>
/// 歌曲标题
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// 艺术家/歌手
/// </summary>
[JsonPropertyName("artist")]
public string Artist { get; set; } = string.Empty;
/// <summary>
/// 点歌人
/// </summary>
[JsonPropertyName("requester")]
public string Requester { get; set; } = string.Empty;
/// <summary>
/// 投票数/热度
/// </summary>
[JsonPropertyName("voteCount")]
public int VoteCount { get; set; }
}
/// <summary>
/// 排期歌曲项目
/// </summary>
public sealed class SongItem
{
/// <summary>
/// 播放日期 (yyyy-MM-dd)
/// </summary>
[JsonPropertyName("playDate")]
public string PlayDate { get; set; } = string.Empty;
/// <summary>
/// 播放序号
/// </summary>
[JsonPropertyName("sequence")]
public int Sequence { get; set; }
/// <summary>
/// 歌曲信息
/// </summary>
[JsonPropertyName("song")]
public Song Song { get; set; } = new();
/// <summary>
/// 获取播放日期
/// </summary>
public DateTime GetPlayDate()
{
if (string.IsNullOrWhiteSpace(PlayDate))
{
return DateTime.MinValue;
}
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
System.Globalization.DateTimeStyles.None, out var result))
{
return result;
}
return DateTime.MinValue;
}
}
/// <summary>
/// 组件状态
/// </summary>
public enum ComponentState
{
/// <summary>
/// 加载中
/// </summary>
Loading,
/// <summary>
/// 正常显示
/// </summary>
Normal,
/// <summary>
/// 网络错误
/// </summary>
NetworkError,
/// <summary>
/// 暂无排期
/// </summary>
NoSchedule
}
/// <summary>
/// 显示数据
/// </summary>
public sealed class DisplayData
{
public ComponentState State { get; set; }
public IReadOnlyList<SongItem> Songs { get; set; } = [];
public DateTime? DisplayDate { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,62 @@
# VoiceHubLanDesktop
VoiceHub 广播站排期插件,用于 LanMountainDesktop 桌面应用。
## 功能特性
- 📻 **排期显示**:展示 VoiceHub 广播站当日排期歌曲
- 🔄 **自动刷新**支持自定义刷新间隔5分钟 ~ 2小时
- ⚙️ **灵活配置**:可自定义 API 地址、显示选项
- 🌐 **多语言支持**:支持中文和英文
## 安装
`.laapp` 包放入 LanMountainDesktop 的插件目录:
```
%LocalAppData%\LanMountainDesktop\Extensions\Plugins\
```
## 配置
在 LanMountainDesktop 设置中找到 "VoiceHub 设置"
| 选项 | 说明 | 默认值 |
|-----|------|--------|
| API 地址 | VoiceHub 后端 API 地址 | `https://voicehub.lao-shui.top/api/songs/public` |
| 显示点歌人 | 是否显示点歌人信息 | 是 |
| 显示投票数 | 是否显示歌曲投票数 | 否 |
| 刷新间隔 | 自动刷新时间间隔 | 1小时 |
## 组件规格
- **最小尺寸**3 × 4 网格
- **缩放模式**:等比例缩放
- **放置位置**:桌面
## 开发
### 构建
```bash
cd VoiceHubLanDesktop
dotnet build
```
### 打包
```bash
dotnet pack
# 或使用脚本
../scripts/Pack-PluginPackages.ps1
```
## 技术栈
- .NET 10
- Avalonia UI 11.3.12
- LanMountainDesktop.PluginSdk 4.0.0
- CommunityToolkit.Mvvm 8.2.1
## 许可证
MIT License

View File

@@ -0,0 +1,113 @@
using System.Net.Http;
using System.Text.Json;
using VoiceHubLanDesktop.Models;
namespace VoiceHubLanDesktop.Services;
/// <summary>
/// VoiceHub API 服务
/// </summary>
public sealed class VoiceHubApiService : IDisposable
{
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
private const string DefaultApiUrl = "https://voicehub.lao-shui.top/api/songs/public";
private const int MaxRetryCount = 3;
private readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(10);
public VoiceHubApiService()
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
/// <summary>
/// 获取公开排期数据
/// </summary>
public async Task<ApiResult<IReadOnlyList<SongItem>>> GetPublicScheduleAsync(
string? apiUrl = null,
CancellationToken cancellationToken = default)
{
var url = string.IsNullOrWhiteSpace(apiUrl) ? DefaultApiUrl : apiUrl.Trim();
for (var attempt = 0; attempt < MaxRetryCount; attempt++)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_requestTimeout);
var jsonResponse = await _httpClient.GetStringAsync(url, cts.Token);
var items = JsonSerializer.Deserialize<List<SongItem>>(jsonResponse, _jsonOptions);
if (items is null)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure("数据解析失败");
}
return ApiResult<IReadOnlyList<SongItem>>.Success(items);
}
catch (HttpRequestException ex)
{
if (attempt == MaxRetryCount - 1)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure($"网络错误: {ex.Message}");
}
}
catch (TaskCanceledException)
{
if (attempt == MaxRetryCount - 1)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure("请求超时");
}
}
catch (JsonException ex)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure($"数据格式错误: {ex.Message}");
}
catch (Exception ex)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure($"未知错误: {ex.Message}");
}
// 指数退避
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
}
return ApiResult<IReadOnlyList<SongItem>>.Failure("获取数据失败");
}
public void Dispose()
{
_httpClient.Dispose();
}
}
/// <summary>
/// API 结果
/// </summary>
public sealed class ApiResult<T>
{
public bool IsSuccess { get; }
public T? Data { get; }
public string? ErrorMessage { get; }
private ApiResult(bool isSuccess, T? data, string? errorMessage)
{
IsSuccess = isSuccess;
Data = data;
ErrorMessage = errorMessage;
}
public static ApiResult<T> Success(T data) => new(true, data, null);
public static ApiResult<T> Failure(string errorMessage) => new(false, default, errorMessage);
}

View File

@@ -0,0 +1,164 @@
using VoiceHubLanDesktop.Models;
namespace VoiceHubLanDesktop.Services;
/// <summary>
/// 排期管理服务
/// </summary>
public sealed class VoiceHubScheduleService
{
private readonly VoiceHubApiService _apiService;
private readonly VoiceHubSettingsService _settingsService;
private IReadOnlyList<SongItem> _cachedSchedule = [];
private DateTime _cacheTime = DateTime.MinValue;
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
public event EventHandler<ScheduleUpdatedEventArgs>? ScheduleUpdated;
public VoiceHubScheduleService(VoiceHubApiService apiService, VoiceHubSettingsService settingsService)
{
_apiService = apiService;
_settingsService = settingsService;
}
/// <summary>
/// 获取今日排期
/// </summary>
public async Task<DisplayData> GetTodayScheduleAsync(CancellationToken cancellationToken = default)
{
var settings = _settingsService.GetSettings();
// 检查缓存
if (_cachedSchedule.Count > 0 && DateTime.Now - _cacheTime < _cacheExpiry)
{
return BuildDisplayData(_cachedSchedule);
}
// 从 API 获取
var result = await _apiService.GetPublicScheduleAsync(settings.ApiUrl, cancellationToken);
if (!result.IsSuccess)
{
return new DisplayData
{
State = ComponentState.NetworkError,
ErrorMessage = result.ErrorMessage ?? "获取排期失败"
};
}
var items = result.Data ?? [];
// 更新缓存
_cachedSchedule = items;
_cacheTime = DateTime.Now;
return BuildDisplayData(items);
}
/// <summary>
/// 强制刷新
/// </summary>
public async Task<DisplayData> RefreshAsync(CancellationToken cancellationToken = default)
{
_cachedSchedule = [];
_cacheTime = DateTime.MinValue;
return await GetTodayScheduleAsync(cancellationToken);
}
/// <summary>
/// 清除缓存
/// </summary>
public void ClearCache()
{
_cachedSchedule = [];
_cacheTime = DateTime.MinValue;
}
private DisplayData BuildDisplayData(IReadOnlyList<SongItem> items)
{
if (items.Count == 0)
{
return new DisplayData
{
State = ComponentState.NoSchedule,
ErrorMessage = "暂无排期数据"
};
}
// 过滤有效日期
var validItems = items.Where(s => s.GetPlayDate() != DateTime.MinValue).ToList();
if (validItems.Count == 0)
{
return new DisplayData
{
State = ComponentState.NoSchedule,
ErrorMessage = "暂无有效排期数据"
};
}
// 找到今天或最近未来的排期
var today = DateTime.Today;
var todaySchedule = validItems
.Where(s => s.GetPlayDate() == today)
.OrderBy(s => s.Sequence)
.ToList();
List<SongItem> displayItems;
DateTime actualDate;
if (todaySchedule.Count > 0)
{
displayItems = todaySchedule;
actualDate = today;
}
else
{
// 找最近的未来排期
var futureSchedule = validItems
.Where(s => s.GetPlayDate() > today)
.GroupBy(s => s.GetPlayDate())
.OrderBy(g => g.Key)
.FirstOrDefault();
if (futureSchedule != null)
{
displayItems = futureSchedule.OrderBy(s => s.Sequence).ToList();
actualDate = futureSchedule.Key;
}
else
{
return new DisplayData
{
State = ComponentState.NoSchedule,
ErrorMessage = "暂无排期数据"
};
}
}
// 触发更新事件
ScheduleUpdated?.Invoke(this, new ScheduleUpdatedEventArgs(displayItems, actualDate));
return new DisplayData
{
State = ComponentState.Normal,
Songs = displayItems,
DisplayDate = actualDate
};
}
}
/// <summary>
/// 排期更新事件参数
/// </summary>
public sealed class ScheduleUpdatedEventArgs : EventArgs
{
public IReadOnlyList<SongItem> Songs { get; }
public DateTime DisplayDate { get; }
public ScheduleUpdatedEventArgs(IReadOnlyList<SongItem> songs, DateTime displayDate)
{
Songs = songs;
DisplayDate = displayDate;
}
}

View File

@@ -0,0 +1,97 @@
using LanMountainDesktop.PluginSdk;
using VoiceHubLanDesktop.Models;
namespace VoiceHubLanDesktop.Services;
/// <summary>
/// 插件设置服务
/// </summary>
public sealed class VoiceHubSettingsService
{
private readonly IPluginSettingsService _settingsService;
private const string SettingsSectionId = "voicehub-settings";
private PluginSettings? _cachedSettings;
public event EventHandler<PluginSettings>? SettingsChanged;
public VoiceHubSettingsService(IPluginSettingsService settingsService)
{
_settingsService = settingsService;
}
/// <summary>
/// 获取设置
/// </summary>
public PluginSettings GetSettings()
{
if (_cachedSettings != null)
{
return _cachedSettings;
}
var settings = new PluginSettings();
try
{
var apiUrl = _settingsService.GetValue<string>(SettingsScope.Plugin, "apiUrl", SettingsSectionId);
if (!string.IsNullOrWhiteSpace(apiUrl))
{
settings.ApiUrl = apiUrl;
}
var showRequester = _settingsService.GetValue<bool?>(SettingsScope.Plugin, "showRequester", SettingsSectionId);
if (showRequester.HasValue)
{
settings.ShowRequester = showRequester.Value;
}
var showVoteCount = _settingsService.GetValue<bool?>(SettingsScope.Plugin, "showVoteCount", SettingsSectionId);
if (showVoteCount.HasValue)
{
settings.ShowVoteCount = showVoteCount.Value;
}
var refreshInterval = _settingsService.GetValue<string>(SettingsScope.Plugin, "refreshInterval", SettingsSectionId);
if (!string.IsNullOrWhiteSpace(refreshInterval) && int.TryParse(refreshInterval, out var minutes))
{
settings.RefreshIntervalMinutes = minutes;
}
}
catch
{
// 使用默认值
}
_cachedSettings = settings;
return settings;
}
/// <summary>
/// 保存设置
/// </summary>
public void SaveSettings(PluginSettings settings)
{
try
{
_settingsService.SetValue(SettingsScope.Plugin, "apiUrl", settings.ApiUrl, sectionId: SettingsSectionId);
_settingsService.SetValue(SettingsScope.Plugin, "showRequester", settings.ShowRequester, sectionId: SettingsSectionId);
_settingsService.SetValue(SettingsScope.Plugin, "showVoteCount", settings.ShowVoteCount, sectionId: SettingsSectionId);
_settingsService.SetValue(SettingsScope.Plugin, "refreshInterval", settings.RefreshIntervalMinutes.ToString(), sectionId: SettingsSectionId);
_cachedSettings = settings;
SettingsChanged?.Invoke(this, settings);
}
catch
{
// 忽略保存错误
}
}
/// <summary>
/// 清除缓存
/// </summary>
public void ClearCache()
{
_cachedSettings = null;
}
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace VoiceHubLanDesktop;
/// <summary>
/// 歌曲信息
/// </summary>
public sealed class Song
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("artist")]
public string Artist { get; set; } = string.Empty;
[JsonPropertyName("requester")]
public string Requester { get; set; } = string.Empty;
[JsonPropertyName("voteCount")]
public int VoteCount { get; set; }
}
/// <summary>
/// 排期歌曲项目
/// </summary>
public sealed class SongItem
{
[JsonPropertyName("playDate")]
public string PlayDate { get; set; } = string.Empty;
[JsonPropertyName("sequence")]
public int Sequence { get; set; }
[JsonPropertyName("song")]
public Song Song { get; set; } = new();
public DateTime GetPlayDate()
{
if (string.IsNullOrWhiteSpace(PlayDate))
{
return DateTime.MinValue;
}
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
System.Globalization.DateTimeStyles.None, out var result))
{
return result;
}
return DateTime.MinValue;
}
}

View File

@@ -0,0 +1,144 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="400"
x:Class="VoiceHubLanDesktop.Views.VoiceHubScheduleControl"
x:DataType="VoiceHubLanDesktop.Views.VoiceHubScheduleControl">
<Design.DataContext>
<VoiceHubLanDesktop.Views.VoiceHubScheduleControl/>
</Design.DataContext>
<Grid RowDefinitions="Auto,*">
<!-- 标题栏 -->
<Border Grid.Row="0"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
Padding="12,8"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="&#xE7D1;"
FontFamily="Segoe MDL2 Assets"
FontSize="16"
VerticalAlignment="Center"
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}"/>
<TextBlock Text="{Binding TitleText}"
FontWeight="SemiBold"
FontSize="14"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding DateText}"
FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
Margin="8,0,0,0"/>
</StackPanel>
</Border>
<!-- 内容区域 -->
<Grid Grid.Row="1">
<!-- 加载状态 -->
<StackPanel x:Name="LoadingPanel"
Orientation="Vertical"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12"
IsVisible="{Binding IsLoading}">
<ProgressBar IsIndeterminate="True"
Width="100"
Height="4"/>
<TextBlock Text="正在加载排期..."
FontSize="13"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
</StackPanel>
<!-- 排期列表 -->
<ScrollViewer x:Name="SchedulePanel"
IsVisible="{Binding IsNormal}"
Padding="8,8,8,8">
<ItemsControl ItemsSource="{Binding Songs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
CornerRadius="8"
Padding="12,10"
Margin="0,0,0,8">
<Grid ColumnDefinitions="Auto,*">
<!-- 序号 -->
<Border Grid.Column="0"
Background="{DynamicResource SystemAccentColor}"
CornerRadius="12"
Width="24"
Height="24"
Margin="0,0,12,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding Sequence}"
FontSize="11"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 歌曲信息 -->
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock Text="{Binding Song.Title}"
FontSize="14"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
MaxLines="1"/>
<TextBlock FontSize="12"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"
TextTrimming="CharacterEllipsis"
MaxLines="1">
<Run Text="{Binding Song.Artist}"/>
</TextBlock>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- 空状态 -->
<StackPanel x:Name="EmptyPanel"
Orientation="Vertical"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8"
IsVisible="{Binding IsEmpty}">
<TextBlock Text="&#xE7E5;"
FontFamily="Segoe MDL2 Assets"
FontSize="48"
Foreground="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"/>
<TextBlock Text="{Binding EmptyMessage}"
FontSize="14"
Foreground="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
</StackPanel>
<!-- 错误状态 -->
<StackPanel x:Name="ErrorPanel"
Orientation="Vertical"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8"
IsVisible="{Binding IsError}">
<TextBlock Text="&#xE783;"
FontFamily="Segoe MDL2 Assets"
FontSize="48"
Foreground="#FFB00020"/>
<TextBlock Text="{Binding ErrorMessage}"
FontSize="14"
Foreground="#FFB00020"
TextWrapping="Wrap"
MaxWidth="200"
TextAlignment="Center"/>
<Button Content="重试"
Command="{Binding RetryCommand}"
Margin="0,8,0,0"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</Grid>
</UserControl>

View File

@@ -0,0 +1,168 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.PluginSdk;
using VoiceHubLanDesktop.Models;
using VoiceHubLanDesktop.Services;
namespace VoiceHubLanDesktop.Views;
/// <summary>
/// 广播站排期显示组件
/// </summary>
public sealed partial class VoiceHubScheduleControl : UserControl
{
private readonly VoiceHubScheduleService _scheduleService;
private readonly VoiceHubSettingsService _settingsService;
private readonly DispatcherTimer? _refreshTimer;
private CancellationTokenSource? _loadCts;
public ObservableCollection<SongItem> Songs { get; } = [];
[ObservableProperty] private string _titleText = "广播站排期";
[ObservableProperty] private string _dateText = "";
[ObservableProperty] private string _emptyMessage = "暂无排期数据";
[ObservableProperty] private string _errorMessage = "";
[ObservableProperty] private bool _isLoading = true;
[ObservableProperty] private bool _isNormal = false;
[ObservableProperty] private bool _isEmpty = false;
[ObservableProperty] private bool _isError = false;
public VoiceHubScheduleControl(
VoiceHubScheduleService scheduleService,
VoiceHubSettingsService settingsService,
IPluginRuntimeContext runtimeContext)
{
InitializeComponent();
DataContext = this;
_scheduleService = scheduleService;
_settingsService = settingsService;
// 设置刷新定时器
var settings = _settingsService.GetSettings();
_refreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMinutes(settings.RefreshIntervalMinutes)
};
_refreshTimer.Tick += async (_, _) => await RefreshAsync();
_refreshTimer.Start();
// 监听设置变化
_settingsService.SettingsChanged += OnSettingsChanged;
// 初始加载
_ = LoadAsync();
}
private void OnSettingsChanged(object? sender, PluginSettings settings)
{
if (_refreshTimer != null)
{
_refreshTimer.Interval = TimeSpan.FromMinutes(settings.RefreshIntervalMinutes);
}
_scheduleService.ClearCache();
_ = RefreshAsync();
}
private async Task LoadAsync()
{
SetState(ComponentState.Loading);
try
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var displayData = await _scheduleService.GetTodayScheduleAsync(_loadCts.Token);
await Dispatcher.UIThread.InvokeAsync(() =>
{
ApplyDisplayData(displayData);
});
}
catch (OperationCanceledException)
{
// 忽略取消
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
SetState(ComponentState.NetworkError, $"加载失败: {ex.Message}");
});
}
}
private void ApplyDisplayData(DisplayData data)
{
switch (data.State)
{
case ComponentState.Normal:
Songs.Clear();
foreach (var song in data.Songs)
{
Songs.Add(song);
}
DateText = data.DisplayDate?.ToString("MM月dd日") ?? "";
SetState(ComponentState.Normal);
break;
case ComponentState.NoSchedule:
EmptyMessage = data.ErrorMessage ?? "暂无排期数据";
SetState(ComponentState.NoSchedule);
break;
case ComponentState.NetworkError:
SetState(ComponentState.NetworkError, data.ErrorMessage ?? "网络错误");
break;
default:
SetState(ComponentState.Loading);
break;
}
}
private void SetState(ComponentState state, string? message = null)
{
IsLoading = state == ComponentState.Loading;
IsNormal = state == ComponentState.Normal;
IsEmpty = state == ComponentState.NoSchedule;
IsError = state == ComponentState.NetworkError;
if (!string.IsNullOrWhiteSpace(message))
{
if (state == ComponentState.NetworkError)
{
ErrorMessage = message;
}
else if (state == ComponentState.NoSchedule)
{
EmptyMessage = message;
}
}
}
[RelayCommand]
private async Task RetryAsync()
{
_scheduleService.ClearCache();
await LoadAsync();
}
public async Task RefreshAsync()
{
await LoadAsync();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_refreshTimer?.Stop();
_loadCts?.Cancel();
_settingsService.SettingsChanged -= OnSettingsChanged;
}
}

View File

@@ -0,0 +1,102 @@
using System.Net.Http;
using System.Text.Json;
namespace VoiceHubLanDesktop;
/// <summary>
/// VoiceHub API 服务
/// </summary>
public sealed class VoiceHubApiService : IDisposable
{
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
private const string DefaultApiUrl = "https://voicehub.lao-shui.top/api/songs/public";
private const int MaxRetryCount = 3;
private readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(10);
public VoiceHubApiService()
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
public async Task<ApiResult<IReadOnlyList<SongItem>>> GetPublicScheduleAsync(
string? apiUrl = null,
CancellationToken cancellationToken = default)
{
var url = string.IsNullOrWhiteSpace(apiUrl) ? DefaultApiUrl : apiUrl.Trim();
for (var attempt = 0; attempt < MaxRetryCount; attempt++)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_requestTimeout);
var jsonResponse = await _httpClient.GetStringAsync(url, cts.Token);
var items = JsonSerializer.Deserialize<List<SongItem>>(jsonResponse, _jsonOptions);
if (items is null)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure("数据解析失败");
}
return ApiResult<IReadOnlyList<SongItem>>.Success(items);
}
catch (HttpRequestException ex)
{
if (attempt == MaxRetryCount - 1)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure($"网络错误: {ex.Message}");
}
}
catch (TaskCanceledException)
{
if (attempt == MaxRetryCount - 1)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure("请求超时");
}
}
catch (JsonException ex)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure($"数据格式错误: {ex.Message}");
}
catch (Exception ex)
{
return ApiResult<IReadOnlyList<SongItem>>.Failure($"未知错误: {ex.Message}");
}
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
}
return ApiResult<IReadOnlyList<SongItem>>.Failure("获取数据失败");
}
public void Dispose() => _httpClient.Dispose();
}
public sealed class ApiResult<T>
{
public bool IsSuccess { get; }
public T? Data { get; }
public string? ErrorMessage { get; }
private ApiResult(bool isSuccess, T? data, string? errorMessage)
{
IsSuccess = isSuccess;
Data = data;
ErrorMessage = errorMessage;
}
public static ApiResult<T> Success(T data) => new(true, data, null);
public static ApiResult<T> Failure(string errorMessage) => new(false, default, errorMessage);
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<LanMountainPluginBuildOutputDirectory>$(OutputPath)</LanMountainPluginBuildOutputDirectory>
<LanMountainPluginPackageVersion>$(Version)</LanMountainPluginPackageVersion>
<LanMountainPluginPackageOutputDirectory>$(MSBuildThisFileDirectory)</LanMountainPluginPackageOutputDirectory>
<LanMountainPluginPackageExtension>.laapp</LanMountainPluginPackageExtension>
<LanMountainPluginPackageFileName>$(AssemblyName).$(LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)</LanMountainPluginPackageFileName>
<LanMountainPluginPackagePath>$(LanMountainPluginPackageOutputDirectory)$(LanMountainPluginPackageFileName)</LanMountainPluginPackagePath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" ExcludeAssets="runtime" PrivateAssets="all" />
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,103 @@
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace VoiceHubLanDesktop;
/// <summary>
/// VoiceHub 广播站排期插件入口
/// </summary>
[PluginEntrance]
public sealed class VoiceHubPlugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(services);
var localizer = CreateLocalizer(context);
// 注册服务
services.AddSingleton<VoiceHubApiService>();
services.AddSingleton<VoiceHubScheduleService>();
// 注册桌面组件 - 最小 3x4 网格,允许等比例缩放
services.AddPluginDesktopComponent<VoiceHubScheduleWidget>(
CreateScheduleComponentOptions(localizer));
// 注册设置页面
services.AddPluginSettingsSection(
id: "voicehub-settings",
titleLocalizationKey: "settings.title",
configure: builder =>
{
builder.AddText(
key: "apiUrl",
titleLocalizationKey: "settings.apiUrl.title",
descriptionLocalizationKey: "settings.apiUrl.description",
defaultValue: "https://voicehub.lao-shui.top/api/songs/public");
builder.AddBoolean(
key: "showRequester",
titleLocalizationKey: "settings.showRequester.title",
descriptionLocalizationKey: "settings.showRequester.description",
defaultValue: true);
builder.AddBoolean(
key: "showVoteCount",
titleLocalizationKey: "settings.showVoteCount.title",
descriptionLocalizationKey: "settings.showVoteCount.description",
defaultValue: false);
builder.AddSelection(
key: "refreshInterval",
titleLocalizationKey: "settings.refreshInterval.title",
descriptionLocalizationKey: "settings.refreshInterval.description",
defaultValue: "60",
choices:
[
new SettingsOptionChoice("5分钟", "5"),
new SettingsOptionChoice("15分钟", "15"),
new SettingsOptionChoice("30分钟", "30"),
new SettingsOptionChoice("1小时", "60"),
new SettingsOptionChoice("2小时", "120")
]);
},
descriptionLocalizationKey: "settings.description",
iconKey: "Settings",
sortOrder: 0);
}
private static PluginLocalizer CreateLocalizer(HostBuilderContext context)
{
var pluginDirectory = context.Properties.TryGetValue("LanMountainDesktop.PluginDirectory", out var directoryValue) &&
directoryValue is string resolvedPluginDirectory &&
!string.IsNullOrWhiteSpace(resolvedPluginDirectory)
? resolvedPluginDirectory
: AppContext.BaseDirectory;
var properties = context.Properties
.Where(pair => pair.Key is string)
.ToDictionary(pair => (string)pair.Key, pair => (object?)pair.Value, StringComparer.OrdinalIgnoreCase);
return new PluginLocalizer(pluginDirectory, PluginLocalizer.ResolveLanguageCode(properties));
}
private static PluginDesktopComponentOptions CreateScheduleComponentOptions(PluginLocalizer localizer)
{
return new PluginDesktopComponentOptions
{
ComponentId = "com.voicehub.schedule",
DisplayName = localizer.GetString("widget.display_name", "广播站排期"),
DisplayNameLocalizationKey = "widget.display_name",
IconKey = "Radio",
Category = localizer.GetString("widget.category", "信息"),
MinWidthCells = 3,
MinHeightCells = 4,
AllowDesktopPlacement = true,
AllowStatusBarPlacement = false,
ResizeMode = PluginDesktopComponentResizeMode.Proportional,
CornerRadiusPreset = PluginCornerRadiusPreset.Default
};
}
}

View File

@@ -0,0 +1,154 @@
using LanMountainDesktop.PluginSdk;
namespace VoiceHubLanDesktop;
/// <summary>
/// 排期管理服务
/// </summary>
public sealed class VoiceHubScheduleService
{
private readonly VoiceHubApiService _apiService;
private readonly IPluginSettingsService _settingsService;
private IReadOnlyList<SongItem> _cachedSchedule = [];
private DateTime _cacheTime = DateTime.MinValue;
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5);
private const string SettingsSectionId = "voicehub-settings";
public VoiceHubScheduleService(VoiceHubApiService apiService, IPluginSettingsService settingsService)
{
_apiService = apiService;
_settingsService = settingsService;
}
public async Task<DisplayData> GetTodayScheduleAsync(CancellationToken cancellationToken = default)
{
var apiUrl = GetApiUrl();
if (_cachedSchedule.Count > 0 && DateTime.Now - _cacheTime < _cacheExpiry)
{
return BuildDisplayData(_cachedSchedule);
}
var result = await _apiService.GetPublicScheduleAsync(apiUrl, cancellationToken);
if (!result.IsSuccess)
{
return new DisplayData
{
State = ComponentState.NetworkError,
ErrorMessage = result.ErrorMessage ?? "获取排期失败"
};
}
var items = result.Data ?? [];
_cachedSchedule = items;
_cacheTime = DateTime.Now;
return BuildDisplayData(items);
}
public void ClearCache()
{
_cachedSchedule = [];
_cacheTime = DateTime.MinValue;
}
private string GetApiUrl()
{
try
{
var apiUrl = _settingsService.GetValue<string>(SettingsScope.Plugin, "apiUrl", sectionId: SettingsSectionId);
return string.IsNullOrWhiteSpace(apiUrl)
? "https://voicehub.lao-shui.top/api/songs/public"
: apiUrl;
}
catch
{
return "https://voicehub.lao-shui.top/api/songs/public";
}
}
private DisplayData BuildDisplayData(IReadOnlyList<SongItem> items)
{
if (items.Count == 0)
{
return new DisplayData
{
State = ComponentState.NoSchedule,
ErrorMessage = "暂无排期数据"
};
}
var validItems = items.Where(s => s.GetPlayDate() != DateTime.MinValue).ToList();
if (validItems.Count == 0)
{
return new DisplayData
{
State = ComponentState.NoSchedule,
ErrorMessage = "暂无有效排期数据"
};
}
var today = DateTime.Today;
var todaySchedule = validItems
.Where(s => s.GetPlayDate() == today)
.OrderBy(s => s.Sequence)
.ToList();
List<SongItem> displayItems;
DateTime actualDate;
if (todaySchedule.Count > 0)
{
displayItems = todaySchedule;
actualDate = today;
}
else
{
var futureSchedule = validItems
.Where(s => s.GetPlayDate() > today)
.GroupBy(s => s.GetPlayDate())
.OrderBy(g => g.Key)
.FirstOrDefault();
if (futureSchedule != null)
{
displayItems = futureSchedule.OrderBy(s => s.Sequence).ToList();
actualDate = futureSchedule.Key;
}
else
{
return new DisplayData
{
State = ComponentState.NoSchedule,
ErrorMessage = "暂无排期数据"
};
}
}
return new DisplayData
{
State = ComponentState.Normal,
Songs = displayItems,
DisplayDate = actualDate
};
}
}
public enum ComponentState
{
Loading,
Normal,
NetworkError,
NoSchedule
}
public sealed class DisplayData
{
public ComponentState State { get; set; }
public IReadOnlyList<SongItem> Songs { get; set; } = [];
public DateTime? DisplayDate { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,393 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace VoiceHubLanDesktop;
/// <summary>
/// 广播站排期显示组件
/// </summary>
internal sealed class VoiceHubScheduleWidget : Border
{
private readonly PluginDesktopComponentContext _context;
private readonly PluginLocalizer _localizer;
private readonly VoiceHubScheduleService _scheduleService;
private readonly PluginAppearanceSnapshot? _appearanceSnapshot;
private readonly TextBlock _titleTextBlock;
private readonly TextBlock _dateTextBlock;
private readonly StackPanel _contentPanel;
private readonly StackPanel _loadingPanel;
private readonly StackPanel _errorPanel;
private readonly DispatcherTimer? _refreshTimer;
private CancellationTokenSource? _loadCts;
public VoiceHubScheduleWidget(PluginDesktopComponentContext context)
{
_context = context;
_localizer = PluginLocalizer.Create(context);
_scheduleService = context.GetService<VoiceHubScheduleService>()
?? throw new InvalidOperationException("VoiceHubScheduleService is not available.");
_appearanceSnapshot = context.GetAppearanceSnapshot();
// 创建 UI 元素
_titleTextBlock = new TextBlock
{
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
VerticalAlignment = VerticalAlignment.Center
};
_dateTextBlock = new TextBlock
{
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
VerticalAlignment = VerticalAlignment.Center
};
_contentPanel = new StackPanel
{
Spacing = 8
};
_loadingPanel = new StackPanel
{
Orientation = Orientation.Vertical,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Spacing = 12,
Children =
{
new ProgressBar
{
IsIndeterminate = true,
Width = 100,
Height = 4
},
new TextBlock
{
Text = T("widget.loading", "正在加载排期..."),
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF"))
}
}
};
_errorPanel = new StackPanel
{
Orientation = Orientation.Vertical,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Spacing = 8
};
// 设置背景和边框
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#FF07111F"), 0),
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
]
};
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
BorderThickness = new Thickness(1);
// 构建主布局
Child = new Grid
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 12,
Children =
{
// 标题栏
new Border
{
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(12, 8),
Child = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children =
{
new TextBlock
{
Text = "📻",
FontSize = 16,
VerticalAlignment = VerticalAlignment.Center
},
_titleTextBlock,
_dateTextBlock
}
}
},
// 内容区域
new ScrollViewer
{
Padding = new Thickness(8),
Content = _contentPanel
}
}
};
Grid.SetRow(((Grid)Child).Children[1], 1);
// 设置刷新定时器
var refreshInterval = GetRefreshInterval();
_refreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMinutes(refreshInterval)
};
_refreshTimer.Tick += async (_, _) => await RefreshAsync();
// 事件处理
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
// 初始化显示
SetTitle();
ApplyScale();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_refreshTimer?.Start();
_ = LoadAsync();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_refreshTimer?.Stop();
_loadCts?.Cancel();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyScale();
}
private async Task LoadAsync()
{
ShowLoading();
try
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var displayData = await _scheduleService.GetTodayScheduleAsync(_loadCts.Token);
await Dispatcher.UIThread.InvokeAsync(() =>
{
ApplyDisplayData(displayData);
});
}
catch (OperationCanceledException)
{
// 忽略取消
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
ShowError($"加载失败: {ex.Message}");
});
}
}
private void ApplyDisplayData(DisplayData data)
{
switch (data.State)
{
case ComponentState.Normal:
ShowContent(data);
break;
case ComponentState.NoSchedule:
ShowError(data.ErrorMessage ?? "暂无排期数据");
break;
case ComponentState.NetworkError:
ShowError(data.ErrorMessage ?? "网络错误");
break;
}
}
private void ShowLoading()
{
if (Child is not Grid mainGrid) return;
mainGrid.Children[1] = _loadingPanel;
}
private void ShowError(string message)
{
_errorPanel.Children.Clear();
_errorPanel.Children.Add(new TextBlock
{
Text = "⚠️",
FontSize = 48,
Foreground = new SolidColorBrush(Color.Parse("#FFF87171"))
});
_errorPanel.Children.Add(new TextBlock
{
Text = message,
Foreground = new SolidColorBrush(Color.Parse("#FFF87171")),
TextWrapping = TextWrapping.Wrap,
MaxWidth = 200,
TextAlignment = TextAlignment.Center
});
_errorPanel.Children.Add(new Button
{
Content = T("widget.retry", "重试"),
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 8, 0, 0)
});
var retryButton = (Button)_errorPanel.Children[2];
retryButton.Click += async (_, _) => await RefreshAsync();
if (Child is not Grid mainGrid) return;
mainGrid.Children[1] = _errorPanel;
}
private void ShowContent(DisplayData data)
{
_contentPanel.Children.Clear();
var basis = GetLayoutBasis();
var titleSize = Math.Clamp(basis * 0.055, 12, 16);
var detailSize = Math.Clamp(basis * 0.045, 10, 13);
foreach (var item in data.Songs)
{
var card = new Border
{
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
BorderThickness = new Thickness(1),
CornerRadius = _appearanceSnapshot.ResolveCornerRadius(
PluginCornerRadiusPreset.Md,
new CornerRadius(8)),
Padding = new Thickness(12, 10),
Child = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 12,
Children =
{
// 序号
new Border
{
Width = 24,
Height = 24,
CornerRadius = new CornerRadius(12),
Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = item.Sequence.ToString(),
FontSize = 11,
FontWeight = FontWeight.Bold,
Foreground = Brushes.White,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
},
// 歌曲信息
new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = item.Song.Title,
FontSize = titleSize,
FontWeight = FontWeight.Medium,
Foreground = Brushes.White,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 1
},
new TextBlock
{
Text = $"{item.Song.Artist}",
FontSize = detailSize,
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 1
}
}
}
}
}
};
Grid.SetColumn(((Grid)card.Child!).Children[1], 1);
_contentPanel.Children.Add(card);
}
// 更新日期显示
_dateTextBlock.Text = data.DisplayDate?.ToString("MM月dd日") ?? "";
if (Child is not Grid mainGrid) return;
mainGrid.Children[1] = new ScrollViewer
{
Padding = new Thickness(8),
Content = _contentPanel
};
}
private void SetTitle()
{
_titleTextBlock.Text = T("widget.display_name", "广播站排期");
}
private void ApplyScale()
{
var basis = GetLayoutBasis();
Padding = new Thickness(Math.Clamp(basis * 0.06, 10, 18));
CornerRadius = _appearanceSnapshot.ResolveCornerRadius(
PluginCornerRadiusPreset.Island,
new CornerRadius(Math.Clamp(basis * 0.12, 16, 28)));
_titleTextBlock.FontSize = Math.Clamp(basis * 0.065, 12, 16);
_dateTextBlock.FontSize = Math.Clamp(basis * 0.05, 10, 13);
}
private double GetLayoutBasis()
{
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 3;
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
return Math.Max(_context.CellSize * 3, Math.Min(width, height));
}
private int GetRefreshInterval()
{
try
{
var interval = _context.GetService<IPluginSettingsService>()
?.GetValue<string>(SettingsScope.Plugin, "refreshInterval", sectionId: "voicehub-settings");
if (!string.IsNullOrWhiteSpace(interval) && int.TryParse(interval, out var minutes))
{
return minutes;
}
}
catch { }
return 60;
}
public async Task RefreshAsync()
{
_scheduleService.ClearCache();
await LoadAsync();
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
}

View File

@@ -0,0 +1,10 @@
{
"id": "com.voicehub.landesktop",
"name": "VoiceHub 广播站排期",
"description": "展示 VoiceHub 广播站当日排期歌曲,按播放顺序显示歌曲信息",
"author": "VoiceHub",
"version": "1.0.0",
"apiVersion": "4.0.0",
"entranceAssembly": "VoiceHubLanDesktop.dll",
"sharedContracts": []
}

76
docs/ARCHITECTURE.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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.

View 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
View 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
View 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
View File

@@ -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
```