This commit is contained in:
luoqiang 2026-04-23 16:23:40 +08:00
parent 8fcfad5357
commit 9b90a471c2
15 changed files with 898 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
################################################################################
# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。
################################################################################
/Avalonia-PC/bin
/Avalonia-PC/.vs
/Avalonia-PC/obj

15
Avalonia-PC/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>

29
Avalonia-PC/App.axaml.cs Normal file
View File

@ -0,0 +1,29 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia_PC.ViewModels;
using Avalonia_PC.Views;
namespace Avalonia_PC
{
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
<Content Include="www\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</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="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,3 @@
<Solution>
<Project Path="Avalonia-PC.csproj" />
</Solution>

35
Avalonia-PC/Program.cs Normal file
View File

@ -0,0 +1,35 @@
using Avalonia;
using System;
namespace Avalonia_PC
{
internal sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
#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);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}
}

View File

@ -0,0 +1,38 @@
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")]
public class ViewLocator : IDataTemplate
{
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 };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
}

View File

@ -0,0 +1,7 @@
namespace Avalonia_PC.ViewModels
{
public partial class MainWindowViewModel : ViewModelBase
{
public string Greeting { get; } = "Welcome to Avalonia!";
}
}

View File

@ -0,0 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Avalonia_PC.ViewModels
{
public abstract class ViewModelBase : ObservableObject
{
}
}

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>

View File

@ -0,0 +1,594 @@
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
{
public partial class MainWindow : Window
{
private const string AppScheme = "app";
private const string? OnlineStartupUrl = null;
private const string? LocalStartupPath = null;
private static readonly JsonSerializerOptions BridgeJsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private NativeWebView? _webView;
private bool _eventsAttached;
private object? _webViewAdapter;
private HttpListener? _localHttpServer;
private CancellationTokenSource? _localHttpServerCts;
private string? _localHttpBaseUrl;
private string? _localHttpRoot;
public MainWindow()
{
InitializeComponent();
Opened += OnOpened;
Closed += OnClosed;
}
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();
}
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)
{
_webView.NavigationCompleted -= OnNavigationCompleted;
_webView.WebMessageReceived -= OnWebMessageReceived;
_webView.AdapterCreated -= OnAdapterCreated;
}
_webViewAdapter = null;
StopLocalHttpServer();
}
private async void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e)
{
await InjectBridgeScriptAsync();
}
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})");
}
}
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));
}
private async Task InjectBridgeScriptAsync()
{
if (_webView is null)
{
return;
}
const string script = """
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;
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();
}
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}`));
}, 30000);
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: init?.method ?? request?.method ?? 'GET',
headers,
body: body ?? null
}));
return responsePromise;
};
}
""";
await _webView.InvokeScript(script);
}
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);
}
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("请求地址不能为空。"));
var path = uri.AbsolutePath.TrimStart('/');
var normalizedPath = path.StartsWith("api/", StringComparison.OrdinalIgnoreCase)
? path
: $"api/{path}";
var authorization = GetAuthorizationHeader(headers);
_ = authorization;
if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = 200;
response.StatusMessage = "OK";
response.Body = JsonSerializer.Serialize(new { success = true });
return response;
}
if (string.Equals(normalizedPath, "api/getUser", StringComparison.OrdinalIgnoreCase))
{
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 });
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;
}
}
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",
};
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;
}
private static string? GetAuthorizationHeader(Dictionary<string, string> headers)
{
return headers.FirstOrDefault(
entry => string.Equals(entry.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Value;
}
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;
}
}
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;
}
private static string? GetConfiguredLocalStartupPath()
{
if (!string.IsNullOrWhiteSpace(LocalStartupPath))
{
return Path.GetFullPath(LocalStartupPath);
}
return Path.Combine(AppContext.BaseDirectory, "www", "index.html");
}
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));
}
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
{
}
}
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
{
}
}
}
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",
};
}
private static int GetAvailableTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
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);
}
private void StopLocalHttpServer()
{
try
{
_localHttpServerCts?.Cancel();
_localHttpServer?.Stop();
_localHttpServer?.Close();
}
catch
{
}
finally
{
_localHttpServerCts?.Dispose();
_localHttpServerCts = null;
_localHttpServer = null;
_localHttpBaseUrl = null;
_localHttpRoot = null;
}
}
private static async Task<object> GetUserFromDatabaseAsync()
{
await Task.Delay(100);
return new { id = 1, name = "张三", email = "zhangsan@example.com" };
}
private static async Task<string> ProcessDataAsync(string? input)
{
await Task.Delay(200);
return $"Processed: {input?.ToUpperInvariant()}";
}
private sealed class AppResponse
{
public string Kind { get; set; } = string.Empty;
public string? Id { get; set; }
public int StatusCode { get; set; }
public string StatusMessage { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public Dictionary<string, string> Headers { get; set; } = new();
}
}
}

18
Avalonia-PC/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>

50
Avalonia-PC/www/api.js Normal file
View File

@ -0,0 +1,50 @@
// 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"),
processData: (input) => callApi("processData", { method: "POST", body: { input } })
};

View File

@ -0,0 +1,41 @@
<!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>
<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}`;
}
};
const isWV2 = window.isWebView2 === true;
setTimeout(() => {
document.body.insertAdjacentHTML('beforeend', `<p>当前环境: ${isWV2 ? 'WebView2 (自定义协议)' : '普通浏览器 (HTTP API)'}</p>`);
}, 100)
</script>
</body>
</html>