添加项目文件。

This commit is contained in:
lq1405 2026-05-21 20:34:06 +08:00
parent 4cf262c126
commit 9ddaf31f28
22 changed files with 1953 additions and 0 deletions

4
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,4 @@
# Copilot Instructions
## 项目指南
- 用户偏好:仅修改明确要求的内容,不要做额外改动(如未请求的 ViewModel DI 注册)。

15
App.axaml Normal file
View File

@ -0,0 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Avalonia_PC.App"
xmlns:local="using:Avalonia_PC"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

37
App.axaml.cs Normal file
View File

@ -0,0 +1,37 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia_PC.ViewModels;
using Avalonia_PC.Views;
using Microsoft.Extensions.DependencyInjection;
namespace Avalonia_PC
{
/// <summary>
/// Avalonia 应用程序入口类,负责初始化 XAML 资源和设置主窗口。
/// </summary>
public partial class App : Application
{
/// <summary>
/// 加载 Avalonia XAML 资源。
/// </summary>
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
/// <summary>
/// 框架初始化完成后设置主窗口和数据上下文。
/// </summary>
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = Program.Services.GetRequiredService<MainWindow>();
desktop.MainWindow.DataContext = new MainWindowViewModel();
}
base.OnFrameworkInitializationCompleted();
}
}
}

BIN
Assets/avalonia-logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@ -0,0 +1,50 @@
using Avalonia_Services.Services.AuthService;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Avalonia_PC.Authentication
{
/// <summary>
/// 第三方授权客户端占位实现。接入真实第三方接口时替换此服务即可。
/// </summary>
public sealed class DefaultPcThirdPartyAuthorizationClient : IPcThirdPartyAuthorizationClient
{
/// <summary>
/// 验证第三方授权码是否有效。默认实现将 "invalid" 视为授权丢失,其余视为有效。
/// </summary>
/// <param name="authorizationCode">第三方授权码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>授权检查结果。</returns>
public Task<ThirdPartyAuthCheckResult> ValidateAuthorizationCodeAsync(
string authorizationCode,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(authorizationCode) ||
string.Equals(authorizationCode, "invalid", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost);
}
return Task.FromResult(ThirdPartyAuthCheckResult.Valid);
}
/// <summary>
/// 刷新第三方授权。默认实现总是返回 TemporaryFailure表示暂时无法刷新。
/// </summary>
/// <param name="authorizationReference">授权引用标识。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>授权检查结果。</returns>
public Task<ThirdPartyAuthCheckResult> RefreshAuthorizationAsync(
string authorizationReference,
CancellationToken cancellationToken = default)
{
if (string.Equals(authorizationReference, "invalid", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost);
}
return Task.FromResult(ThirdPartyAuthCheckResult.TemporaryFailure);
}
}
}

View File

@ -0,0 +1,94 @@
using Authentication;
using Avalonia_Common.Core;
using Avalonia_Services.Core;
using Avalonia_Services.Services.AuthService;
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace Avalonia_PC.Authentication
{
/// <summary>
/// PC 端鉴权端点服务,实现 <see cref="IPcAuthEndpointService"/>
/// 处理授权码登录、Token 刷新和登出操作。
/// </summary>
public sealed class PcAuthEndpointService(PcGlobalTokenService tokenService) : IPcAuthEndpointService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <inheritdoc />
public async Task<object?> AuthorizeAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<PcAuthorizeRequest>(ctx.Body);
var token = await tokenService.AuthorizeAsync(request?.AuthorizationCode);
if (token is null)
{
ctx.StatusCode = 401;
return ResponseHelper.Failure(401, "授权失败");
}
return ResponseHelper.Ok(token, "授权成功");
}
/// <inheritdoc />
public async Task<object?> RefreshAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<PcRefreshRequest>(ctx.Body);
var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization"));
var refreshed = await tokenService.RefreshAsync(token);
if (refreshed is null)
{
ctx.StatusCode = 401;
return ResponseHelper.Failure(401, "授权已失效");
}
return ResponseHelper.Ok(refreshed, "刷新成功");
}
/// <inheritdoc />
public Task<object?> LogoutAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<PcLogoutRequest>(ctx.Body);
var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization"));
tokenService.Logout(token);
return Task.FromResult<object?>(ResponseHelper.Succeed("退出成功"));
}
/// <summary>
/// 将 JSON 请求体反序列化为指定类型。
/// </summary>
/// <typeparam name="T">目标类型。</typeparam>
/// <param name="body">JSON 请求体字符串,可为空。</param>
/// <returns>反序列化后的对象;若 body 为空则返回默认值。</returns>
private static T? Deserialize<T>(string? body)
{
return string.IsNullOrWhiteSpace(body)
? default
: JsonSerializer.Deserialize<T>(body, JsonOptions);
}
/// <summary>
/// 从 Authorization 头中提取 Bearer Token。
/// </summary>
/// <param name="authorization">Authorization 头的值。</param>
/// <returns>提取的 Token 字符串;若无法提取则返回 null。</returns>
private static string? ExtractBearerToken(string? authorization)
{
if (string.IsNullOrWhiteSpace(authorization))
{
return null;
}
/// <summary>
/// Bearer Token 的前缀常量。
/// </summary>
const string prefix = "Bearer ";
return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? authorization[prefix.Length..].Trim()
: authorization.Trim();
}
}
}

View File

@ -0,0 +1,60 @@
using Authentication;
using Avalonia_Services.Core;
using System;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Avalonia_PC.Authentication
{
/// <summary>
/// PC 端鉴权服务,基于全局 Token 验证用户身份,实现 <see cref="IAuthService"/>。
/// </summary>
public sealed class PcAuthService(PcGlobalTokenService tokenService) : IAuthService
{
/// <inheritdoc />
public async Task<ClaimsPrincipal?> AuthenticateAsync(ServiceEndpointContext context)
{
var token = ExtractBearerToken(context.GetHeader("Authorization"));
if (!await tokenService.ValidateAsync(token))
{
return null;
}
var identity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, "pc-local"),
new Claim(ClaimTypes.Name, "PC授权用户"),
new Claim(ClaimTypes.Role, "SuperAdmin"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim("auth_type", "pc-global-token"),
],
"pc-global-token");
return new ClaimsPrincipal(identity);
}
/// <inheritdoc />
public Task<bool> AuthorizeAsync(ClaimsPrincipal user, string policy)
{
return Task.FromResult(user.Identity?.IsAuthenticated == true);
}
/// <summary>
/// 从 Authorization 头中提取 Bearer Token。
/// </summary>
/// <param name="authorization">Authorization 头的值。</param>
/// <returns>提取的 Token 字符串;若无法提取则返回 null。</returns>
private static string? ExtractBearerToken(string? authorization)
{
if (string.IsNullOrWhiteSpace(authorization))
{
return null;
}
string prefix = "Bearer ";
return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? authorization[prefix.Length..].Trim()
: authorization.Trim();
}
}
}

View File

@ -0,0 +1,227 @@
using Avalonia_Services.Services.AuthService;
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Authentication
{
/// <summary>
/// PC 端全局 Token 服务,管理全局唯一的访问 Token。
/// 支持授权码登录、Token 刷新、有效性验证和登出,
/// 在第三方授权暂时失败时使用缩短有效期的临时 Token。
/// </summary>
public sealed class PcGlobalTokenService(IPcThirdPartyAuthorizationClient thirdPartyClient)
{
/// <summary>
/// 超级管理员角色集合。
/// </summary>
private static readonly string[] SuperRoles = ["SuperAdmin", "Admin"];
/// <summary>
/// 线程同步锁。
/// </summary>
private readonly object _syncRoot = new();
/// <summary>
/// 当前 Token 状态。
/// </summary>
private PcTokenState? _current;
/// <summary>
/// 正常 Token 有效期8 小时)。
/// </summary>
private static readonly TimeSpan NormalLifetime = TimeSpan.FromHours(8);
/// <summary>
/// 第三方暂时失败时的 Token 有效期20 分钟)。
/// </summary>
private static readonly TimeSpan TemporaryFailureLifetime = TimeSpan.FromMinutes(20);
/// <summary>
/// 第三方暂时失败的最长容忍窗口24 小时),超出后清除 Token。
/// </summary>
private static readonly TimeSpan MaxTemporaryFailureWindow = TimeSpan.FromHours(24);
/// <summary>
/// 使用授权码进行登录授权,验证成功后颁发全局 Token。
/// </summary>
/// <param name="authorizationCode">第三方授权码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>Token 响应;若授权码无效则返回 null。</returns>
public async Task<PcTokenResponse?> AuthorizeAsync(string? authorizationCode, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(authorizationCode))
{
return null;
}
var result = await thirdPartyClient.ValidateAuthorizationCodeAsync(authorizationCode, cancellationToken);
if (result != ThirdPartyAuthCheckResult.Valid)
{
return null;
}
return IssueToken(authorizationCode, NormalLifetime, resetTemporaryFailureWindow: true);
}
/// <summary>
/// 刷新当前 Token向第三方验证授权引用是否仍然有效。
/// 根据第三方返回结果决定是续期、降级为临时 Token 还是清除。
/// </summary>
/// <param name="token">当前 Token。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>新的 Token 响应;若授权丢失则返回 null。</returns>
public async Task<PcTokenResponse?> RefreshAsync(string? token, CancellationToken cancellationToken = default)
{
PcTokenState? current;
lock (_syncRoot)
{
current = IsCurrentToken(token) ? _current : null;
}
if (current is null)
{
return null;
}
var result = await thirdPartyClient.RefreshAuthorizationAsync(current.AuthorizationReference, cancellationToken);
return result switch
{
ThirdPartyAuthCheckResult.Valid => IssueToken(current.AuthorizationReference, NormalLifetime, resetTemporaryFailureWindow: true),
ThirdPartyAuthCheckResult.AuthorizationLost => ClearAndReturnNull(),
ThirdPartyAuthCheckResult.TemporaryFailure => RefreshAfterTemporaryFailure(current),
_ => null,
};
}
/// <summary>
/// 验证 Token 是否有效,若已过期则尝试自动刷新。
/// </summary>
/// <param name="token">要验证的 Token。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>Token 是否有效。</returns>
public async Task<bool> ValidateAsync(string? token, CancellationToken cancellationToken = default)
{
PcTokenState? current;
lock (_syncRoot)
{
if (!IsCurrentToken(token))
{
return false;
}
current = _current;
if (current is not null && current.ExpiresAt > DateTime.UtcNow)
{
return true;
}
}
return await RefreshAsync(token, cancellationToken) is not null;
}
/// <summary>
/// 登出并清除当前 Token。
/// </summary>
/// <param name="token">要清除的 Token。</param>
public void Logout(string? token)
{
lock (_syncRoot)
{
if (IsCurrentToken(token))
{
_current = null;
}
}
}
/// <summary>
/// 颁发新的全局 Token。
/// </summary>
/// <param name="authorizationReference">授权引用标识。</param>
/// <param name="lifetime">Token 有效期。</param>
/// <param name="resetTemporaryFailureWindow">是否重置暂时失败窗口。</param>
/// <returns>包含 Token 和过期时间的响应。</returns>
private PcTokenResponse IssueToken(string authorizationReference, TimeSpan lifetime, bool resetTemporaryFailureWindow)
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
var now = DateTime.UtcNow;
var state = new PcTokenState(
HashToken(token),
authorizationReference,
now.Add(lifetime),
resetTemporaryFailureWindow ? null : _current?.TemporaryFailureStartedAt ?? now);
lock (_syncRoot)
{
_current = state;
}
return new PcTokenResponse(token, state.ExpiresAt, SuperRoles);
}
/// <summary>
/// 在第三方暂时失败时刷新 Token。若超出最大容忍窗口则清除 Token。
/// </summary>
/// <param name="current">当前 Token 状态。</param>
/// <returns>新的临时 Token 响应;若超出容忍窗口则返回 null。</returns>
private PcTokenResponse? RefreshAfterTemporaryFailure(PcTokenState current)
{
var startedAt = current.TemporaryFailureStartedAt ?? DateTime.UtcNow;
if (DateTime.UtcNow - startedAt > MaxTemporaryFailureWindow)
{
return ClearAndReturnNull();
}
return IssueToken(current.AuthorizationReference, TemporaryFailureLifetime, resetTemporaryFailureWindow: false);
}
/// <summary>
/// 清除当前 Token 并返回 null。
/// </summary>
/// <returns>始终返回 null。</returns>
private PcTokenResponse? ClearAndReturnNull()
{
lock (_syncRoot)
{
_current = null;
}
return null;
}
/// <summary>
/// 检查给定 Token 是否与当前持有的 Token 匹配。
/// </summary>
/// <param name="token">要检查的 Token。</param>
/// <returns>是否匹配。</returns>
private bool IsCurrentToken(string? token)
{
return !string.IsNullOrWhiteSpace(token) &&
_current is not null &&
string.Equals(_current.TokenHash, HashToken(token), StringComparison.Ordinal);
}
/// <summary>
/// 对 Token 原文进行 SHA256 哈希,返回十六进制字符串。
/// </summary>
/// <param name="token">Token 原文。</param>
/// <returns>SHA256 哈希后的十六进制字符串。</returns>
private static string HashToken(string token)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes);
}
/// <summary>
/// 保存当前全局 Token 的内部状态。
/// </summary>
/// <param name="TokenHash">Token 的 SHA256 哈希值。</param>
/// <param name="AuthorizationReference">授权引用标识。</param>
/// <param name="ExpiresAt">过期时间。</param>
/// <param name="TemporaryFailureStartedAt">第三方暂时失败的起始时间。</param>
private sealed record PcTokenState(
string TokenHash,
string AuthorizationReference,
DateTime ExpiresAt,
DateTime? TemporaryFailureStartedAt);
}
}

48
Avalonia-PC.csproj Normal file
View File

@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\avalonia-logo.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<Content Include="www\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<AvaloniaXaml Remove="Models\**" />
<Compile Remove="Models\**" />
<EmbeddedResource Remove="Models\**" />
<None Remove="Models\**" />
</ItemGroup>
<ItemGroup>
<None Include=".github\copilot-instructions.md" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.1" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.1" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Avalonia-Services\Avalonia-Services.csproj" />
<ProjectReference Include="..\Avalonia-Common\Avalonia-Common.csproj" />
<ProjectReference Include="..\Avalonia-EFCore\Avalonia-EFCore.csproj" />
</ItemGroup>
</Project>

11
Avalonia-PC.slnx Normal file
View File

@ -0,0 +1,11 @@
<Solution>
<Project Path="../Avalonia-API/Avalonia-API.csproj" Id="e33aba9a-a56b-4f6b-8eaa-3acbed65ebad" />
<Project Path="../Avalonia-Common/Avalonia-Common.csproj" Id="caed4118-2161-4382-90b8-35fb4efe3b5f" />
<Project Path="../Avalonia-EFCore/Avalonia-EFCore.csproj" Id="64557501-62a7-4863-b2bf-1570b8c6fecb" />
<Project Path="../Avalonia-Services/Avalonia-Services.csproj" Id="b8757cf9-5422-4c67-acae-3c967c95f866" />
<Project Path="../Avalonia-Web-VUE/avalonia-web-vue.esproj">
<Build />
<Deploy />
</Project>
<Project Path="Avalonia-PC.csproj" />
</Solution>

97
Program.cs Normal file
View File

@ -0,0 +1,97 @@
using Authentication;
using Avalonia;
using Avalonia_Common.Infrastructure;
using Avalonia_EFCore.Database;
using Avalonia_PC.Authentication;
using Avalonia_PC.Views;
using Avalonia_Services.Core;
using Avalonia_Services.Endpoints;
using Avalonia_Services.Services;
using Avalonia_Services.Services.AuthService;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System;
namespace Avalonia_PC
{
/// <summary>
/// 桌面应用程序入口类,负责配置 DI 容器、初始化数据库和启动 Avalonia 框架。
/// </summary>
internal sealed class Program
{
/// <summary>
/// 获取全局 DI 服务提供程序。
/// </summary>
public static IServiceProvider Services { get; private set; } = null!;
/// <summary>
/// 应用程序主入口点。
/// </summary>
/// <param name="args">命令行参数。</param>
[STAThread]
public static void Main(string[] args)
{
// 初始化日志系统
AppLog.Initialize(LoggingConfiguration.CreateDefaultLogger(logDir: "logs"));
AppLog.Information("Avalonia-PC 正在启动...");
ConfigureServices();
// 初始化数据库(自动迁移 + 种子数据)
Services.InitializeDatabase<AppDataContext>();
#if DEBUG
// 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页
Environment.SetEnvironmentVariable(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--remote-debugging-port=9222 --auto-open-devtools-for-tabs");
#endif
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
/// <summary>
/// 配置 DI 容器,注册数据库、业务服务、鉴权服务和统一端点。
/// </summary>
private static void ConfigureServices()
{
var services = new ServiceCollection();
// ---- 数据库 ----
// 注册默认数据库提供程序SQLite / MySQL / PostgreSQL / SqlServer
DatabaseProviderRegistry.RegisterDefaults();
// 桌面端固定使用 SQLite 本地数据库
services.AddAppDatabase<AppDataContext>(DatabaseConfiguration.ForSQLite("app.db"));
// ---- 业务服务 ----
services.AddSingleton<WeatherForecastService>();
services.AddSingleton<IPcThirdPartyAuthorizationClient, DefaultPcThirdPartyAuthorizationClient>();
services.AddSingleton<PcGlobalTokenService>();
services.AddSingleton<IAuthService, PcAuthService>();
services.AddSingleton<IPcAuthEndpointService, PcAuthEndpointService>();
// ---- 端点注册 ----
var endpointBuilder = new ServiceEndpointBuilder();
AppEndpoints.Configure(endpointBuilder);
AuthEndpoints.ConfigurePc(endpointBuilder);
var endpoints = endpointBuilder.Build();
services.AddSingleton(endpoints);
// 注册 Window
services.AddTransient<MainWindow>(sp => new MainWindow(sp));
Services = services.BuildServiceProvider();
}
/// <summary>
/// 构建 Avalonia 应用程序(供可视化设计器使用,请勿删除)。
/// </summary>
/// <returns>Avalonia 应用构建器。</returns>
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@ -0,0 +1,11 @@
{
"profiles": {
"Avalonia-PC": {
"commandName": "Project"
},
"WSL": {
"commandName": "WSL2",
"distributionName": ""
}
}
}

53
ViewLocator.cs Normal file
View File

@ -0,0 +1,53 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia_PC.ViewModels;
using System;
using System.Diagnostics.CodeAnalysis;
namespace Avalonia_PC
{
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
/// <summary>
/// 视图定位器,根据 ViewModel 类型自动查找对应的 View
/// 实现 IDataTemplate 以支持 Avalonia 的数据模板机制。
/// </summary>
public class ViewLocator : IDataTemplate
{
/// <summary>
/// 根据 ViewModel 实例构建对应的 View 控件。
/// 约定:将 ViewModels 命名空间中的 ViewModel 替换为 Views 命名空间中的同名 View。
/// </summary>
/// <param name="param">ViewModel 实例。</param>
/// <returns>对应的 View 控件;若未找到则返回 TextBlock 显示错误信息。</returns>
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
/// <summary>
/// 判断数据对象是否为 ViewModel 类型(以 "ViewModel" 结尾)。
/// </summary>
/// <param name="data">要判断的数据对象。</param>
/// <returns>是否为 ViewModel。</returns>
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
}

View File

@ -0,0 +1,13 @@
namespace Avalonia_PC.ViewModels
{
/// <summary>
/// 主窗口的 ViewModel提供问候语等绑定属性。
/// </summary>
public partial class MainWindowViewModel : ViewModelBase
{
/// <summary>
/// 获取问候语文本。
/// </summary>
public string Greeting { get; } = "Welcome to Avalonia!";
}
}

View File

@ -0,0 +1,12 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Avalonia_PC.ViewModels
{
/// <summary>
/// ViewModel 基类,继承自 CommunityToolkit.Mvvm 的 ObservableObject
/// 提供属性变更通知功能。
/// </summary>
public abstract class ViewModelBase : ObservableObject
{
}
}

View File

@ -0,0 +1,345 @@
namespace Avalonia_PC.Views
{
/// <summary>
/// MainWindow 的分部类,定义注入 WebView2 的 JavaScript Bridge 脚本。
/// </summary>
public partial class MainWindow
{
private const string BridgeScript = """
if (!window.__appBridgeInstalled) {
window.__appBridgeInstalled = true;
window.isWebView2 = true;
const pending = new Map();
const tryOpenDevTools = () => {
window.invokeCSharpAction(JSON.stringify({ kind: 'app-open-devtools' }));
};
window.__dispatchAppResponse = function(jsonStr) {
const payload = JSON.parse(jsonStr);
const responseId = payload.id ?? payload.Id;
const entry = pending.get(responseId);
if (!entry) return;
pending.delete(responseId);
entry.resolve(new Response(payload.body ?? payload.Body ?? '', {
status: payload.statusCode ?? payload.StatusCode ?? 200,
statusText: payload.statusMessage ?? payload.StatusMessage ?? 'OK',
headers: payload.headers ?? payload.Headers ?? { 'Content-Type': 'application/json' }
}));
};
const nativeFetch = window.fetch ? window.fetch.bind(window) : null;
const NativeXMLHttpRequest = window.XMLHttpRequest;
const sendAppBridgeRequest = ({ requestUrl, method, headers, body, timeoutMs = 30000 }) => {
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
const responsePromise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
pending.delete(id);
reject(new Error(`Timed out waiting for ${requestUrl}`));
}, timeoutMs);
pending.set(id, {
resolve: response => { clearTimeout(timeoutId); resolve(response); },
reject: error => { clearTimeout(timeoutId); reject(error); }
});
});
window.invokeCSharpAction(JSON.stringify({
kind: 'app-request',
id,
url: requestUrl,
method,
headers,
body
}));
return responsePromise;
};
document.addEventListener('keydown', event => {
if (event.key === 'F12' || (event.ctrlKey && event.shiftKey && (event.key === 'I' || event.key === 'i'))) {
event.preventDefault();
tryOpenDevTools();
}
}, true);
document.addEventListener('contextmenu', event => {
if (event.shiftKey) {
event.preventDefault();
tryOpenDevTools();
}
}, true);
window.fetch = async (input, init) => {
const request = input instanceof Request ? input : null;
const requestUrl = typeof input === 'string' || input instanceof URL
? input.toString()
: request?.url;
if (!requestUrl || !requestUrl.startsWith('app://')) {
if (!nativeFetch) throw new Error('window.fetch is not available.');
return nativeFetch(input, init);
}
const combinedHeaders = new Headers(request?.headers);
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => combinedHeaders.set(key, value));
}
const headers = {};
combinedHeaders.forEach((value, key) => headers[key] = value);
let body = init?.body;
if (body === undefined && request) {
body = await request.clone().text();
}
if (body && typeof body !== 'string') {
body = await new Response(body).text();
}
return sendAppBridgeRequest({
requestUrl,
method: init?.method ?? request?.method ?? 'GET',
headers,
body: body ?? null,
timeoutMs: 30000
});
};
/// <summary>
/// WebView2 Bridge 中的 XMLHttpRequest 替代实现,将 app:// 请求拦截并转为 C# 调用。
/// </summary>
class BridgeXMLHttpRequest {
constructor() {
this._native = new NativeXMLHttpRequest();
this._isAppRequest = false;
this._requestUrl = '';
this._method = 'GET';
this._headers = {};
this._responseHeaders = {};
this._responseHeadersRaw = '';
this._aborted = false;
this.readyState = 0;
this.status = 0;
this.statusText = '';
this.response = null;
this.responseText = '';
this.responseType = '';
this.responseURL = '';
this.timeout = 0;
this.withCredentials = false;
this.onreadystatechange = null;
this.onload = null;
this.onerror = null;
this.ontimeout = null;
this.onabort = null;
this.onloadend = null;
this.upload = {
addEventListener: () => {},
removeEventListener: () => {}
};
this._native.onreadystatechange = () => {
if (this._isAppRequest) {
return;
}
this.readyState = this._native.readyState;
this.status = this._native.status;
this.statusText = this._native.statusText;
this.responseURL = this._native.responseURL ?? '';
this.response = this._native.response;
this.responseText = this._native.responseText ?? '';
this._raiseReadyStateChange();
};
this._native.onload = event => {
if (!this._isAppRequest && typeof this.onload === 'function') {
this.onload(event);
}
};
this._native.onerror = event => {
if (!this._isAppRequest && typeof this.onerror === 'function') {
this.onerror(event);
}
};
this._native.ontimeout = event => {
if (!this._isAppRequest && typeof this.ontimeout === 'function') {
this.ontimeout(event);
}
};
this._native.onabort = event => {
if (!this._isAppRequest && typeof this.onabort === 'function') {
this.onabort(event);
}
};
this._native.onloadend = event => {
if (!this._isAppRequest && typeof this.onloadend === 'function') {
this.onloadend(event);
}
};
}
open(method, url, async = true, user, password) {
const requestUrl = typeof url === 'string' || url instanceof URL
? url.toString()
: `${url ?? ''}`;
this._requestUrl = requestUrl;
this._method = method ?? 'GET';
this._isAppRequest = requestUrl.startsWith('app://');
this._headers = {};
this._responseHeaders = {};
this._responseHeadersRaw = '';
this._aborted = false;
if (!this._isAppRequest) {
this._native.open(method, url, async, user, password);
return;
}
this.readyState = 1;
this._raiseReadyStateChange();
}
setRequestHeader(name, value) {
if (!this._isAppRequest) {
this._native.setRequestHeader(name, value);
return;
}
this._headers[name] = value;
}
getAllResponseHeaders() {
if (!this._isAppRequest) {
return this._native.getAllResponseHeaders();
}
return this._responseHeadersRaw;
}
getResponseHeader(name) {
if (!this._isAppRequest) {
return this._native.getResponseHeader(name);
}
return this._responseHeaders[name.toLowerCase()] ?? null;
}
overrideMimeType(mimeType) {
if (!this._isAppRequest && typeof this._native.overrideMimeType === 'function') {
this._native.overrideMimeType(mimeType);
}
}
abort() {
if (!this._isAppRequest) {
this._native.abort();
return;
}
this._aborted = true;
if (typeof this.onabort === 'function') {
this.onabort();
}
if (typeof this.onloadend === 'function') {
this.onloadend();
}
}
async send(body = null) {
if (!this._isAppRequest) {
this._native.send(body);
return;
}
let requestBody = body;
if (requestBody && typeof requestBody !== 'string') {
requestBody = await new Response(requestBody).text();
}
try {
const response = await sendAppBridgeRequest({
requestUrl: this._requestUrl,
method: this._method,
headers: this._headers,
body: requestBody ?? null,
timeoutMs: this.timeout > 0 ? this.timeout : 30000
});
if (this._aborted) {
return;
}
this.status = response.status;
this.statusText = response.statusText;
this.responseURL = this._requestUrl;
this._responseHeaders = {};
this._responseHeadersRaw = '';
response.headers.forEach((value, key) => {
this._responseHeaders[key.toLowerCase()] = value;
this._responseHeadersRaw += `${key}: ${value}\r\n`;
});
const text = await response.text();
this.responseText = text;
this.response = this.responseType === 'json'
? (text ? JSON.parse(text) : null)
: text;
this.readyState = 4;
this._raiseReadyStateChange();
if (typeof this.onload === 'function') {
this.onload();
}
if (typeof this.onloadend === 'function') {
this.onloadend();
}
} catch (error) {
if (this._aborted) {
return;
}
this.status = 0;
this.statusText = '';
this.readyState = 4;
this._raiseReadyStateChange();
const errorMessage = error?.message ?? '';
if (errorMessage.includes('Timed out waiting') && typeof this.ontimeout === 'function') {
this.ontimeout(error);
} else if (typeof this.onerror === 'function') {
this.onerror(error);
}
if (typeof this.onloadend === 'function') {
this.onloadend();
}
}
}
_raiseReadyStateChange() {
if (typeof this.onreadystatechange === 'function') {
this.onreadystatechange();
}
}
}
window.XMLHttpRequest = BridgeXMLHttpRequest;
}
""";
}
}

View File

@ -0,0 +1,38 @@
using Avalonia_Services.Core;
using Avalonia_Services.Endpoints;
using Avalonia_Services.Extensions;
using Avalonia_Services.Services;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Avalonia_PC.Views
{
/// <summary>
/// MainWindow 的分部类,负责路由注册和统一端点适配。
/// </summary>
public partial class MainWindow
{
/// <summary>
/// 统一端点适配器(替代原来的 _routes 字典)。
/// 所有端点在 Avalonia-Services/AppEndpoints.cs 中统一定义。
/// </summary>
private DesktopEndpointAdapter _endpointAdapter = null!;
/// <summary>
/// 服务容器,通过构造函数注入。
/// </summary>
private IServiceProvider _services = null!;
/// <summary>
/// 从 DI 获取统一端点集合并构建桌面适配器。
/// </summary>
private void RegisterRoutes()
{
// 从 DI 获取已构建的端点集合
var endpointCollection = _services.GetRequiredService<ServiceEndpointCollection>();
_endpointAdapter = endpointCollection.CreateAdapter(_services);
}
}
}

23
Views/MainWindow.axaml Normal file
View File

@ -0,0 +1,23 @@
<Window 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:vm="using:Avalonia_PC.ViewModels"
xmlns:webview="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.WebView"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Avalonia_PC.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Title="Avalonia_PC">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid>
<webview:NativeWebView x:Name="WebView" />
</Grid>
</Window>

684
Views/MainWindow.axaml.cs Normal file
View File

@ -0,0 +1,684 @@
using Avalonia.Controls;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Avalonia_PC.Views
{
/// <summary>
/// 主窗口,承载 WebView2 控件并管理前后端 Bridge 通信。
/// </summary>
public partial class MainWindow : Window
{
/// <summary>
/// 自定义协议方案名称。
/// </summary>
private const string AppScheme = "app";
/// <summary>
/// 在线模式下的前端启动 URL。
/// </summary>
private const string? OnlineStartupUrl = "http://localhost:51240";
//private const string? OnlineStartupUrl = null;
/// <summary>
/// 离线模式下的前端本地文件路径,为空则使用在线模式。
/// </summary>
private const string? LocalStartupPath = null;
private static readonly JsonSerializerOptions BridgeJsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// WebView2 原生控件实例。
/// </summary>
private NativeWebView? _webView;
/// <summary>
/// 标记 WebView 事件是否已绑定。
/// </summary>
private bool _eventsAttached;
/// <summary>
/// WebView 适配器对象。
/// </summary>
private object? _webViewAdapter;
/// <summary>
/// 本地 HTTP 服务器实例(离线模式)。
/// </summary>
private HttpListener? _localHttpServer;
/// <summary>
/// 本地 HTTP 服务器的取消令牌源。
/// </summary>
private CancellationTokenSource? _localHttpServerCts;
/// <summary>
/// 本地 HTTP 服务器的基础 URL。
/// </summary>
private string? _localHttpBaseUrl;
/// <summary>
/// 本地 HTTP 服务器的根目录路径。
/// </summary>
private string? _localHttpRoot;
#region WebView
/// <summary>
/// 初始化窗口并注册生命周期事件。
/// </summary>
public MainWindow(IServiceProvider services)
{
_services = services;
InitializeComponent();
Opened += OnOpened;
Closed += OnClosed;
RegisterRoutes();
}
/// <summary>
/// 窗口打开后初始化 WebView、挂载事件并加载入口页面。
/// </summary>
private async void OnOpened(object? sender, EventArgs e)
{
if (_eventsAttached)
{
return;
}
_webView = this.FindControl<NativeWebView>("WebView");
if (_webView is null)
{
return;
}
_eventsAttached = true;
_webView.NavigationCompleted += OnNavigationCompleted;
_webView.WebMessageReceived += OnWebMessageReceived;
_webView.AdapterCreated += OnAdapterCreated;
await LoadInitialContentAsync();
}
/// <summary>
/// WebView 适配器创建后缓存实例,用于后续打开开发者工具。
/// </summary>
private void OnAdapterCreated(object? sender, WebViewAdapterEventArgs e)
{
_webViewAdapter = e.GetType().GetProperty("Adapter")?.GetValue(e);
}
/// <summary>
/// 窗口关闭时解绑事件并释放本地资源。
/// </summary>
private void OnClosed(object? sender, EventArgs e)
{
if (_webView is not null)
{
_webView.NavigationCompleted -= OnNavigationCompleted;
_webView.WebMessageReceived -= OnWebMessageReceived;
_webView.AdapterCreated -= OnAdapterCreated;
}
_webViewAdapter = null;
StopLocalHttpServer();
}
/// <summary>
/// 页面导航完成后注入 JS 桥接脚本。
/// </summary>
private async void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e)
{
await InjectBridgeScriptAsync();
}
#endregion
#region
/// <summary>
/// 接收前端消息并进行分发(打开调试工具 / 处理 app 请求)。
/// </summary>
private async void OnWebMessageReceived(object? sender, WebMessageReceivedEventArgs e)
{
var messageJson = e.Body;
if (string.IsNullOrWhiteSpace(messageJson))
{
return;
}
AppResponse? response = null;
try
{
using var document = JsonDocument.Parse(messageJson);
var root = document.RootElement;
if (!root.TryGetProperty("kind", out var kindProperty))
{
return;
}
var kind = kindProperty.GetString();
if (string.Equals(kind, "app-open-devtools", StringComparison.OrdinalIgnoreCase))
{
TryOpenDevTools();
return;
}
if (!string.Equals(kind, "app-request", StringComparison.OrdinalIgnoreCase))
{
return;
}
response = await HandleAppRequestAsync(root);
}
catch (Exception ex)
{
response = new AppResponse
{
Kind = "app-response",
Id = TryGetRequestId(messageJson),
StatusCode = 500,
StatusMessage = "Internal Server Error",
Body = JsonSerializer.Serialize(new { success = false, error = ex.Message }),
Headers = CreateJsonHeaders(),
};
}
if (_webView is not null && response is not null)
{
var responseJson = JsonSerializer.Serialize(response, BridgeJsonSerializerOptions);
var responseJsonLiteral = JsonSerializer.Serialize(responseJson);
await _webView.InvokeScript($"window.__dispatchAppResponse({responseJsonLiteral})");
}
}
/// <summary>
/// 加载初始页面:优先在线地址,其次本地路径(通过本地 HTTP 服务托管)。
/// </summary>
private async Task LoadInitialContentAsync()
{
if (_webView is null)
{
return;
}
var onlineUrl = GetConfiguredOnlineStartupUrl();
if (onlineUrl is not null)
{
StopLocalHttpServer();
_webView.Source = onlineUrl;
return;
}
var localHtmlPath = GetConfiguredLocalStartupPath();
if (string.IsNullOrWhiteSpace(localHtmlPath) || !File.Exists(localHtmlPath))
{
return;
}
var localRoot = Path.GetDirectoryName(localHtmlPath);
if (string.IsNullOrWhiteSpace(localRoot))
{
return;
}
await EnsureLocalHttpServerStartedAsync(localRoot);
if (string.IsNullOrWhiteSpace(_localHttpBaseUrl))
{
_webView.Source = new Uri(localHtmlPath);
return;
}
_webView.Source = new Uri(new Uri(_localHttpBaseUrl), Path.GetFileName(localHtmlPath));
}
/// <summary>
/// 向页面注入桥接脚本,接管 app:// 请求并回传到 C# 处理。
/// </summary>
private async Task InjectBridgeScriptAsync()
{
if (_webView is null)
{
return;
}
await _webView.InvokeScript(BridgeScript);
}
#endregion
#region
/// <summary>
/// 解析前端请求消息并转发到统一请求处理入口。
/// </summary>
private async Task<AppResponse> HandleAppRequestAsync(JsonElement request)
{
var id = request.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null;
var url = request.TryGetProperty("url", out var urlProperty) ? urlProperty.GetString() : null;
var method = request.TryGetProperty("method", out var methodProperty) ? methodProperty.GetString() : "GET";
var body = request.TryGetProperty("body", out var bodyProperty) ? bodyProperty.GetString() : null;
var headers = ExtractHeaders(request);
return await HandleAppRequestAsync(id, url, method, body, headers);
}
/// <summary>
/// 统一请求处理:构建上下文、处理 OPTIONS、使用统一端点适配器分发。
/// </summary>
private async Task<AppResponse> HandleAppRequestAsync(
string? id,
string? rawUrl,
string? method,
string? body,
Dictionary<string, string> headers)
{
var response = new AppResponse
{
Kind = "app-response",
Id = id,
StatusCode = 200,
StatusMessage = "OK",
Headers = CreateJsonHeaders(),
};
try
{
var uri = new Uri(rawUrl ?? throw new InvalidOperationException("请求地址不能为空。"));
if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = 200;
response.StatusMessage = "OK";
response.Body = JsonSerializer.Serialize(new { success = true });
return response;
}
// 使用统一端点适配器处理请求
var (normalizedPath, queryParams) = ParseRequestUri(uri);
var routeResult = await _endpointAdapter.HandleRequestAsync(
path: normalizedPath,
method: method ?? "GET",
body: body,
headers: headers,
query: queryParams);
if (routeResult.IsMatched)
{
response.StatusCode = routeResult.StatusCode;
response.StatusMessage = routeResult.StatusMessage;
response.Body = BuildSuccessResponseBody(routeResult.Data);
foreach (var kvp in routeResult.ResponseHeaders)
{
response.Headers[kvp.Key] = kvp.Value;
}
return response;
}
response.StatusCode = 404;
response.StatusMessage = "Not Found";
response.Body = JsonSerializer.Serialize(new { success = false, error = "API not found" });
return response;
}
catch (Exception ex)
{
response.StatusCode = 500;
response.StatusMessage = "Internal Server Error";
response.Body = JsonSerializer.Serialize(new { success = false, error = ex.Message });
return response;
}
}
/// <summary>
/// 从 URI 解析规范化路径和查询参数(供统一端点适配器使用)。
/// </summary>
private static (string normalizedPath, Dictionary<string, string> query) ParseRequestUri(Uri uri)
{
var host = uri.Host ?? string.Empty;
var absolutePath = uri.AbsolutePath ?? string.Empty;
var combinedPath = $"{host}/{absolutePath}";
var pathSegments = combinedPath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Uri.UnescapeDataString)
.ToArray();
var normalizedPath = string.Join('/', pathSegments);
var query = ParseQueryParameters(uri.Query);
return (normalizedPath, query);
}
/// <summary>
/// 统一构建成功响应体,保持前后端响应结构一致。
/// </summary>
private static string BuildSuccessResponseBody(object? data)
{
return JsonSerializer.Serialize(new { success = true, data });
}
/// <summary>
/// 解析查询字符串为忽略大小写的字典。
/// </summary>
private static Dictionary<string, string> ParseQueryParameters(string? queryString)
{
var query = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(queryString))
{
return query;
}
var raw = queryString.TrimStart('?');
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separatorIndex = pair.IndexOf('=');
if (separatorIndex < 0)
{
query[Uri.UnescapeDataString(pair)] = string.Empty;
continue;
}
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
query[key] = value;
}
return query;
}
/// <summary>
/// 创建桥接响应的默认 JSON/CORS 头。
/// </summary>
private static Dictionary<string, string> CreateJsonHeaders() => new()
{
["Content-Type"] = "application/json; charset=utf-8",
["Access-Control-Allow-Origin"] = "*",
["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS",
["Access-Control-Allow-Headers"] = "Content-Type, Authorization",
};
/// <summary>
/// 从前端请求消息中提取请求头。
/// </summary>
private static Dictionary<string, string> ExtractHeaders(JsonElement request)
{
if (!request.TryGetProperty("headers", out var headersElement) ||
headersElement.ValueKind != JsonValueKind.Object)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in headersElement.EnumerateObject())
{
headers[property.Name] = property.Value.GetString() ?? string.Empty;
}
return headers;
}
/// <summary>
/// 获取授权头,供鉴权逻辑扩展使用。
/// </summary>
private static string? GetAuthorizationHeader(Dictionary<string, string> headers)
{
return headers.FirstOrDefault(
entry => string.Equals(entry.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Value;
}
/// <summary>
/// 在异常情况下尝试提取请求 id确保前端可收到对应错误响应。
/// </summary>
private static string? TryGetRequestId(string messageJson)
{
try
{
using var document = JsonDocument.Parse(messageJson);
return document.RootElement.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null;
}
catch
{
return null;
}
}
#endregion
#region
/// <summary>
/// 获取在线启动地址配置(仅允许 http/https
/// </summary>
private static Uri? GetConfiguredOnlineStartupUrl()
{
if (string.IsNullOrWhiteSpace(OnlineStartupUrl))
{
return null;
}
if (!Uri.TryCreate(OnlineStartupUrl, UriKind.Absolute, out var uri))
{
return null;
}
return uri.Scheme is "http" or "https" ? uri : null;
}
/// <summary>
/// 获取本地启动文件路径,未配置时默认使用输出目录 www/index.html。
/// </summary>
private static string? GetConfiguredLocalStartupPath()
{
if (!string.IsNullOrWhiteSpace(LocalStartupPath))
{
return Path.GetFullPath(LocalStartupPath);
}
return Path.Combine(AppContext.BaseDirectory, "www", "index.html");
}
/// <summary>
/// 确保本地 HTTP 静态服务已启动;根目录变化时会重启。
/// </summary>
private async Task EnsureLocalHttpServerStartedAsync(string localRoot)
{
if (!string.IsNullOrWhiteSpace(_localHttpBaseUrl) &&
_localHttpServer is not null &&
string.Equals(_localHttpRoot, localRoot, StringComparison.OrdinalIgnoreCase))
{
return;
}
StopLocalHttpServer();
var port = GetAvailableTcpPort();
var prefix = $"http://127.0.0.1:{port}/";
_localHttpServerCts = new CancellationTokenSource();
_localHttpServer = new HttpListener();
_localHttpServer.Prefixes.Add(prefix);
_localHttpServer.Start();
_localHttpBaseUrl = prefix;
_localHttpRoot = localRoot;
_ = Task.Run(() => RunLocalHttpServerLoopAsync(_localHttpServer, _localHttpServerCts.Token, localRoot));
}
/// <summary>
/// 本地静态服务主循环,持续接收并分发请求。
/// </summary>
private static async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var context = await listener.GetContextAsync();
_ = Task.Run(() => HandleLocalHttpRequest(context, wwwRoot), cancellationToken);
}
}
catch
{
}
}
/// <summary>
/// 处理本地静态资源请求并返回文件内容。
/// </summary>
private static async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot)
{
try
{
var relativePath = context.Request.Url?.AbsolutePath.TrimStart('/') ?? string.Empty;
if (string.IsNullOrWhiteSpace(relativePath))
{
relativePath = "index.html";
}
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.GetFullPath(Path.Combine(wwwRoot, relativePath));
var fullRoot = Path.GetFullPath(wwwRoot);
if (!fullPath.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase) || !File.Exists(fullPath))
{
context.Response.StatusCode = 404;
context.Response.Close();
return;
}
context.Response.ContentType = GetContentType(fullPath);
await using var input = File.OpenRead(fullPath);
context.Response.ContentLength64 = input.Length;
await input.CopyToAsync(context.Response.OutputStream);
context.Response.OutputStream.Close();
}
catch
{
try
{
context.Response.StatusCode = 500;
context.Response.Close();
}
catch
{
}
}
}
/// <summary>
/// 根据后缀返回静态资源 Content-Type。
/// </summary>
private static string GetContentType(string filePath)
{
return Path.GetExtension(filePath).ToLowerInvariant() switch
{
".html" => "text/html; charset=utf-8",
".js" => "application/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".json" => "application/json; charset=utf-8",
_ => "application/octet-stream",
};
}
/// <summary>
/// 获取一个可用本地端口,用于启动本地静态服务。
/// </summary>
private static int GetAvailableTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>
/// 尝试打开 WebView 开发者工具(兼容不同适配器方法名)。
/// </summary>
private void TryOpenDevTools()
{
if (_webViewAdapter is null)
{
return;
}
var adapterType = _webViewAdapter.GetType();
var method = adapterType.GetMethod("OpenDevTools", BindingFlags.Public | BindingFlags.Instance) ??
adapterType.GetMethod("ShowDevTools", BindingFlags.Public | BindingFlags.Instance);
method?.Invoke(_webViewAdapter, null);
}
/// <summary>
/// 停止并释放本地静态服务资源。
/// </summary>
private void StopLocalHttpServer()
{
try
{
_localHttpServerCts?.Cancel();
_localHttpServer?.Stop();
_localHttpServer?.Close();
}
catch
{
}
finally
{
_localHttpServerCts?.Dispose();
_localHttpServerCts = null;
_localHttpServer = null;
_localHttpBaseUrl = null;
_localHttpRoot = null;
}
}
#endregion
#region DTO /
/// <summary>
/// Bridge 通信响应 DTO用于序列化返回给前端的数据。
/// </summary>
private sealed class AppResponse
{
/// <summary>
/// 获取或设置响应类型标识。
/// </summary>
public string Kind { get; set; } = string.Empty;
/// <summary>
/// 获取或设置请求 ID对应前端请求
/// </summary>
public string? Id { get; set; }
/// <summary>
/// 获取或设置 HTTP 状态码。
/// </summary>
public int StatusCode { get; set; }
/// <summary>
/// 获取或设置状态描述文本。
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// 获取或设置响应体 JSON 字符串。
/// </summary>
public string Body { get; set; } = string.Empty;
/// <summary>
/// 获取或设置响应头字典。
/// </summary>
public Dictionary<string, string> Headers { get; set; } = new();
}
#endregion
}
}

18
app.manifest Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Avalonia_PC.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

51
www/api.js Normal file
View File

@ -0,0 +1,51 @@
// api.js - 跨端统一 API 调用层
const isWebView2 = () => {
return window.isWebView2 === true;
};
const getBaseUrl = () => {
if (isWebView2()) {
return "app://api/";
}
return "https://your-production-api.com/api/";
};
async function callApi(endpoint, options = {}) {
const url = getBaseUrl() + endpoint;
const fetchOptions = {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
...(options.headers || {})
},
...(options.body && { body: JSON.stringify(options.body) })
};
const token = localStorage.getItem("authToken");
if (token) {
fetchOptions.headers.Authorization = `Bearer ${token}`;
}
try {
const response = await fetch(url, fetchOptions);
const data = await response.json();
console.log(data)
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (err) {
console.error(`API call failed: ${endpoint}`, err);
throw err;
}
}
window.api = {
getUser: () => callApi("getUser?t=1"),
processData: (input) => callApi("processData", { method: "POST", body: { input } }),
wData: (input) => callApi("wData", { method: "POST", body: { input } }),
};

62
www/index.html Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>跨端测试</title>
</head>
<body>
<h1>WebView2 自定义协议演示</h1>
<button id="getUserBtn">获取用户信息</button>
<button id="processBtn">处理数据</button>
<button id="wBtn">天气数据</button>
<pre id="output"></pre>
<script src="./api.js"></script>
<script>
const output = document.getElementById('output');
document.getElementById('getUserBtn').onclick = async () => {
try {
const result = await window.api.getUser();
output.textContent = JSON.stringify(result, null, 2);
} catch (err) {
output.textContent = `错误: ${err.message}`;
}
};
document.getElementById('processBtn').onclick = async () => {
try {
const result = await window.api.processData('hello world');
output.textContent = JSON.stringify(result, null, 2);
} catch (err) {
output.textContent = `错误: ${err.message}`;
}
};
document.getElementById('wBtn').onclick = async () => {
try {
const result = await window.api.wData('hello world');
output.textContent = JSON.stringify(result, null, 2);
} catch (err) {
output.textContent = `错误: ${err.message}`;
}
};
const detectIsWebView2 = () => window.isWebView2 === true || typeof window.invokeCSharpAction === 'function';
const renderEnvironment = () => {
const isWV2 = detectIsWebView2();
const existing = document.getElementById('envTip');
if (existing) {
existing.textContent = `当前环境: ${isWV2 ? 'WebView2 (自定义协议)' : '普通浏览器 (HTTP API)'}`;
return;
}
document.body.insertAdjacentHTML('beforeend', `<p id="envTip">当前环境: ${isWV2 ? 'WebView2 (自定义协议)' : '普通浏览器 (HTTP API)'}</p>`);
};
renderEnvironment();
setTimeout(renderEnvironment, 300);
</script>
</body>
</html>