diff --git a/.gitignore b/.gitignore index 4059021..3eadd6c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ /Avalonia-PC/bin /Avalonia-PC/.vs -/Avalonia-PC/obj \ No newline at end of file +/Avalonia-PC/obj +/Avalonia-API/bin +/Avalonia-API/obj +/Avalonia-Services/bin +/Avalonia-Services/obj \ No newline at end of file diff --git a/Avalonia-API/Avalonia-API.csproj b/Avalonia-API/Avalonia-API.csproj new file mode 100644 index 0000000..3b60685 --- /dev/null +++ b/Avalonia-API/Avalonia-API.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + Avalonia_API + + + + + + + + + + + diff --git a/Avalonia-API/Avalonia-API.csproj.user b/Avalonia-API/Avalonia-API.csproj.user new file mode 100644 index 0000000..9ff5820 --- /dev/null +++ b/Avalonia-API/Avalonia-API.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/Avalonia-API/Avalonia-API.http b/Avalonia-API/Avalonia-API.http new file mode 100644 index 0000000..3dd7f88 --- /dev/null +++ b/Avalonia-API/Avalonia-API.http @@ -0,0 +1,6 @@ +@Avalonia_API_HostAddress = http://localhost:5206 + +GET {{Avalonia_API_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Avalonia-API/Configuration/ServicesConfiguration.cs b/Avalonia-API/Configuration/ServicesConfiguration.cs new file mode 100644 index 0000000..823bc0f --- /dev/null +++ b/Avalonia-API/Configuration/ServicesConfiguration.cs @@ -0,0 +1,15 @@ +using Avalonia_Services.Services; + +namespace Avalonia_API.Configuration +{ + public static class ServicesConfiguration + { + public static void ConfigureServices(this IServiceCollection services) + { + // Register your services here + // For example: + // services.AddSingleton(); + services.AddScoped(); + } + } +} diff --git a/Avalonia-API/Controllers/WeatherForecastController.cs b/Avalonia-API/Controllers/WeatherForecastController.cs new file mode 100644 index 0000000..7b83edf --- /dev/null +++ b/Avalonia-API/Controllers/WeatherForecastController.cs @@ -0,0 +1,20 @@ +using Avalonia_Services.Models; +using Avalonia_Services.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Avalonia_API.Controllers +{ + [ApiController] + [Route("[controller]")] + public class WeatherForecastController(WeatherForecastService weatherForecastService) : ControllerBase + { + + private readonly WeatherForecastService _weatherForecastService = weatherForecastService; + + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable Get() + { + return _weatherForecastService.GetWeatherForecasts(); + } + } +} diff --git a/Avalonia-API/Program.cs b/Avalonia-API/Program.cs new file mode 100644 index 0000000..666a9c5 --- /dev/null +++ b/Avalonia-API/Program.cs @@ -0,0 +1,23 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/Avalonia-API/Properties/launchSettings.json b/Avalonia-API/Properties/launchSettings.json new file mode 100644 index 0000000..00c28bd --- /dev/null +++ b/Avalonia-API/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5206", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7165;http://localhost:5206", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Avalonia-API/appsettings.Development.json b/Avalonia-API/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Avalonia-API/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Avalonia-API/appsettings.json b/Avalonia-API/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Avalonia-API/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Avalonia-PC/Avalonia-PC.slnx b/Avalonia-PC/Avalonia-PC.slnx index 532a743..9200293 100644 --- a/Avalonia-PC/Avalonia-PC.slnx +++ b/Avalonia-PC/Avalonia-PC.slnx @@ -1,3 +1,5 @@ + + diff --git a/Avalonia-PC/Views/MainWindow.axaml.cs b/Avalonia-PC/Views/MainWindow.axaml.cs index 2964059..267b166 100644 --- a/Avalonia-PC/Views/MainWindow.axaml.cs +++ b/Avalonia-PC/Views/MainWindow.axaml.cs @@ -15,6 +15,7 @@ namespace Avalonia_PC.Views public partial class MainWindow : Window { private const string AppScheme = "app"; + //private const string? OnlineStartupUrl = "https://re.laitool.cn"; private const string? OnlineStartupUrl = null; private const string? LocalStartupPath = null; private static readonly JsonSerializerOptions BridgeJsonSerializerOptions = new() @@ -30,6 +31,11 @@ namespace Avalonia_PC.Views private string? _localHttpBaseUrl; private string? _localHttpRoot; + #region 生命周期与 WebView 事件 + + /// + /// 初始化窗口并注册生命周期事件。 + /// public MainWindow() { InitializeComponent(); @@ -38,6 +44,9 @@ namespace Avalonia_PC.Views Closed += OnClosed; } + /// + /// 窗口打开后初始化 WebView、挂载事件并加载入口页面。 + /// private async void OnOpened(object? sender, EventArgs e) { if (_eventsAttached) @@ -59,11 +68,17 @@ namespace Avalonia_PC.Views await LoadInitialContentAsync(); } + /// + /// WebView 适配器创建后缓存实例,用于后续打开开发者工具。 + /// private void OnAdapterCreated(object? sender, WebViewAdapterEventArgs e) { _webViewAdapter = e.GetType().GetProperty("Adapter")?.GetValue(e); } + /// + /// 窗口关闭时解绑事件并释放本地资源。 + /// private void OnClosed(object? sender, EventArgs e) { if (_webView is not null) @@ -77,11 +92,21 @@ namespace Avalonia_PC.Views StopLocalHttpServer(); } + /// + /// 页面导航完成后注入 JS 桥接脚本。 + /// private async void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e) { await InjectBridgeScriptAsync(); } + #endregion + + #region 前端桥接与页面加载 + + /// + /// 接收前端消息并进行分发(打开调试工具 / 处理 app 请求)。 + /// private async void OnWebMessageReceived(object? sender, WebMessageReceivedEventArgs e) { var messageJson = e.Body; @@ -137,6 +162,9 @@ namespace Avalonia_PC.Views } } + /// + /// 加载初始页面:优先在线地址,其次本地路径(通过本地 HTTP 服务托管)。 + /// private async Task LoadInitialContentAsync() { if (_webView is null) @@ -174,6 +202,9 @@ namespace Avalonia_PC.Views _webView.Source = new Uri(new Uri(_localHttpBaseUrl), Path.GetFileName(localHtmlPath)); } + /// + /// 向页面注入桥接脚本,接管 app:// 请求并回传到 C# 处理。 + /// private async Task InjectBridgeScriptAsync() { if (_webView is null) @@ -278,6 +309,13 @@ if (!window.__appBridgeInstalled) { await _webView.InvokeScript(script); } + #endregion + + #region 请求分发与通用响应 + + /// + /// 解析前端请求消息并转发到统一请求处理入口。 + /// private async Task HandleAppRequestAsync(JsonElement request) { var id = request.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null; @@ -289,6 +327,9 @@ if (!window.__appBridgeInstalled) { return await HandleAppRequestAsync(id, url, method, body, headers); } + /// + /// 统一请求处理:构建上下文、处理 OPTIONS、按前缀分发并封装标准响应。 + /// private async Task HandleAppRequestAsync( string? id, string? rawUrl, @@ -308,10 +349,7 @@ if (!window.__appBridgeInstalled) { try { var uri = new Uri(rawUrl ?? throw new InvalidOperationException("请求地址不能为空。")); - var path = uri.AbsolutePath.TrimStart('/'); - var normalizedPath = path.StartsWith("api/", StringComparison.OrdinalIgnoreCase) - ? path - : $"api/{path}"; + var requestContext = CreateRouteRequestContext(uri, body); var authorization = GetAuthorizationHeader(headers); _ = authorization; @@ -323,27 +361,12 @@ if (!window.__appBridgeInstalled) { return response; } - if (string.Equals(normalizedPath, "api/getUser", StringComparison.OrdinalIgnoreCase)) + var routeResult = await DispatchByPrefixAsync(requestContext); + if (routeResult.IsMatched) { - var user = await GetUserFromDatabaseAsync(); - response.Body = JsonSerializer.Serialize(new { success = true, data = user }); - return response; - } - - if (string.Equals(normalizedPath, "api/processData", StringComparison.OrdinalIgnoreCase)) - { - var input = string.Empty; - if (!string.IsNullOrWhiteSpace(body)) - { - using var jsonDocument = JsonDocument.Parse(body); - if (jsonDocument.RootElement.TryGetProperty("input", out var inputProperty)) - { - input = inputProperty.GetString() ?? string.Empty; - } - } - - var result = await ProcessDataAsync(input); - response.Body = JsonSerializer.Serialize(new { success = true, result }); + response.StatusCode = routeResult.StatusCode; + response.StatusMessage = routeResult.StatusMessage; + response.Body = BuildSuccessResponseBody(routeResult.Data); return response; } @@ -361,6 +384,137 @@ if (!window.__appBridgeInstalled) { } } + /// + /// 按请求前缀分发处理器(例如 api、sys、admin 等)。 + /// + private async Task DispatchByPrefixAsync(RouteRequestContext requestContext) + { + if (requestContext.PathSegments.Length > 0 && + string.Equals(requestContext.PathSegments[0], "api", StringComparison.OrdinalIgnoreCase)) + { + return await HandleApiPrefixAsync(requestContext); + } + + return RouteDispatchResult.NotMatched(); + } + + /// + /// 处理 api 前缀下的具体业务路由。 + /// + private static async Task HandleApiPrefixAsync(RouteRequestContext requestContext) + { + if (string.Equals(requestContext.NormalizedPath, "api/getUser", StringComparison.OrdinalIgnoreCase)) + { + var user = await GetUserFromDatabaseAsync(); + return RouteDispatchResult.Success(user); + } + + if (string.Equals(requestContext.NormalizedPath, "api/processData", StringComparison.OrdinalIgnoreCase) || + (requestContext.PathSegments.Length > 1 && + string.Equals(requestContext.PathSegments[1], "processData", StringComparison.OrdinalIgnoreCase))) + { + var input = ExtractInput(requestContext); + var result = await ProcessDataAsync(input); + return RouteDispatchResult.Success(result); + } + + return RouteDispatchResult.NotMatched(); + } + + /// + /// 统一构建成功响应体,保持前后端响应结构一致。 + /// + private static string BuildSuccessResponseBody(object? data) + { + return JsonSerializer.Serialize(new { success = true, data }); + } + + /// + /// 从 URI 解析路径段、查询参数和 body,构建路由上下文。 + /// + private static RouteRequestContext CreateRouteRequestContext(Uri uri, string? body) + { + 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 new RouteRequestContext + { + NormalizedPath = normalizedPath, + PathSegments = pathSegments, + Query = query, + Body = body, + }; + } + + /// + /// 解析查询字符串为忽略大小写的字典。 + /// + private static Dictionary ParseQueryParameters(string? queryString) + { + var query = new Dictionary(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; + } + + /// + /// 按 body -> query -> path 的优先级提取业务输入参数。 + /// + private static string ExtractInput(RouteRequestContext requestContext) + { + if (!string.IsNullOrWhiteSpace(requestContext.Body)) + { + using var jsonDocument = JsonDocument.Parse(requestContext.Body); + if (jsonDocument.RootElement.TryGetProperty("input", out var inputProperty)) + { + return inputProperty.GetString() ?? string.Empty; + } + } + + if (requestContext.Query.TryGetValue("input", out var inputFromQuery) && + !string.IsNullOrWhiteSpace(inputFromQuery)) + { + return inputFromQuery; + } + + if (requestContext.PathSegments.Length > 2) + { + return string.Join('/', requestContext.PathSegments.Skip(2)); + } + + return string.Empty; + } + + /// + /// 创建桥接响应的默认 JSON/CORS 头。 + /// private static Dictionary CreateJsonHeaders() => new() { ["Content-Type"] = "application/json; charset=utf-8", @@ -369,6 +523,9 @@ if (!window.__appBridgeInstalled) { ["Access-Control-Allow-Headers"] = "Content-Type, Authorization", }; + /// + /// 从前端请求消息中提取请求头。 + /// private static Dictionary ExtractHeaders(JsonElement request) { if (!request.TryGetProperty("headers", out var headersElement) || @@ -386,12 +543,18 @@ if (!window.__appBridgeInstalled) { return headers; } + /// + /// 获取授权头,供鉴权逻辑扩展使用。 + /// private static string? GetAuthorizationHeader(Dictionary headers) { return headers.FirstOrDefault( entry => string.Equals(entry.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Value; } + /// + /// 在异常情况下尝试提取请求 id,确保前端可收到对应错误响应。 + /// private static string? TryGetRequestId(string messageJson) { try @@ -405,6 +568,13 @@ if (!window.__appBridgeInstalled) { } } + #endregion + + #region 页面地址配置与本地静态服务 + + /// + /// 获取在线启动地址配置(仅允许 http/https)。 + /// private static Uri? GetConfiguredOnlineStartupUrl() { if (string.IsNullOrWhiteSpace(OnlineStartupUrl)) @@ -420,6 +590,9 @@ if (!window.__appBridgeInstalled) { return uri.Scheme is "http" or "https" ? uri : null; } + /// + /// 获取本地启动文件路径,未配置时默认使用输出目录 www/index.html。 + /// private static string? GetConfiguredLocalStartupPath() { if (!string.IsNullOrWhiteSpace(LocalStartupPath)) @@ -430,6 +603,9 @@ if (!window.__appBridgeInstalled) { return Path.Combine(AppContext.BaseDirectory, "www", "index.html"); } + /// + /// 确保本地 HTTP 静态服务已启动;根目录变化时会重启。 + /// private async Task EnsureLocalHttpServerStartedAsync(string localRoot) { if (!string.IsNullOrWhiteSpace(_localHttpBaseUrl) && @@ -454,6 +630,9 @@ if (!window.__appBridgeInstalled) { _ = Task.Run(() => RunLocalHttpServerLoopAsync(_localHttpServer, _localHttpServerCts.Token, localRoot)); } + /// + /// 本地静态服务主循环,持续接收并分发请求。 + /// private static async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot) { try @@ -469,6 +648,9 @@ if (!window.__appBridgeInstalled) { } } + /// + /// 处理本地静态资源请求并返回文件内容。 + /// private static async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot) { try @@ -509,6 +691,9 @@ if (!window.__appBridgeInstalled) { } } + /// + /// 根据后缀返回静态资源 Content-Type。 + /// private static string GetContentType(string filePath) { return Path.GetExtension(filePath).ToLowerInvariant() switch @@ -521,6 +706,9 @@ if (!window.__appBridgeInstalled) { }; } + /// + /// 获取一个可用本地端口,用于启动本地静态服务。 + /// private static int GetAvailableTcpPort() { var listener = new TcpListener(IPAddress.Loopback, 0); @@ -530,6 +718,9 @@ if (!window.__appBridgeInstalled) { return port; } + /// + /// 尝试打开 WebView 开发者工具(兼容不同适配器方法名)。 + /// private void TryOpenDevTools() { if (_webViewAdapter is null) @@ -543,6 +734,9 @@ if (!window.__appBridgeInstalled) { method?.Invoke(_webViewAdapter, null); } + /// + /// 停止并释放本地静态服务资源。 + /// private void StopLocalHttpServer() { try @@ -564,18 +758,32 @@ if (!window.__appBridgeInstalled) { } } + #endregion + + #region 业务示例方法 + + /// + /// 示例:模拟读取用户数据。 + /// private static async Task GetUserFromDatabaseAsync() { await Task.Delay(100); return new { id = 1, name = "张三", email = "zhangsan@example.com" }; } + /// + /// 示例:模拟处理输入数据。 + /// private static async Task ProcessDataAsync(string? input) { await Task.Delay(200); return $"Processed: {input?.ToUpperInvariant()}"; } + #endregion + + #region DTO / 路由上下文模型 + private sealed class AppResponse { public string Kind { get; set; } = string.Empty; @@ -590,5 +798,46 @@ if (!window.__appBridgeInstalled) { public Dictionary Headers { get; set; } = new(); } + + private sealed class RouteRequestContext + { + public string NormalizedPath { get; init; } = string.Empty; + + public string[] PathSegments { get; init; } = []; + + public Dictionary Query { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + public string? Body { get; init; } + } + + private sealed class RouteDispatchResult + { + public bool IsMatched { get; init; } + + public int StatusCode { get; init; } = 200; + + public string StatusMessage { get; init; } = "OK"; + + public object? Data { get; init; } + + public static RouteDispatchResult Success(object? data) + { + return new RouteDispatchResult + { + IsMatched = true, + Data = data, + }; + } + + public static RouteDispatchResult NotMatched() + { + return new RouteDispatchResult + { + IsMatched = false, + }; + } + } + + #endregion } } diff --git a/Avalonia-PC/www/api.js b/Avalonia-PC/www/api.js index 14b813b..d8fafc7 100644 --- a/Avalonia-PC/www/api.js +++ b/Avalonia-PC/www/api.js @@ -45,6 +45,6 @@ async function callApi(endpoint, options = {}) { } window.api = { - getUser: () => callApi("getUser"), + getUser: () => callApi("getUser?t=1"), processData: (input) => callApi("processData", { method: "POST", body: { input } }) }; diff --git a/Avalonia-Services/Avalonia-Services.csproj b/Avalonia-Services/Avalonia-Services.csproj new file mode 100644 index 0000000..5e3d7d1 --- /dev/null +++ b/Avalonia-Services/Avalonia-Services.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + Avalonia_Services + enable + enable + + + diff --git a/Avalonia-Services/Models/WeatherForecast.cs b/Avalonia-Services/Models/WeatherForecast.cs new file mode 100644 index 0000000..c708921 --- /dev/null +++ b/Avalonia-Services/Models/WeatherForecast.cs @@ -0,0 +1,13 @@ +namespace Avalonia_Services.Models +{ + public class WeatherForecast + { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } + } +} diff --git a/Avalonia-Services/Services/WeatherForecastService.cs b/Avalonia-Services/Services/WeatherForecastService.cs new file mode 100644 index 0000000..9e65602 --- /dev/null +++ b/Avalonia-Services/Services/WeatherForecastService.cs @@ -0,0 +1,23 @@ +using Avalonia_Services.Models; + +namespace Avalonia_Services.Services +{ + public class WeatherForecastService + { + private static readonly string[] Summaries = + [ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + ]; + + public IEnumerable GetWeatherForecasts() + { + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + }) + .ToArray(); + } + } +}