Kto-Blog
Published on

FastAPI+PyWebView+前端打造Python应用界面

Authors
  • avatar
    Name
    Kto

告别传统GUI:用FastAPI + PyWebView + 现代前端技术打造Python应用界面

引言

在Python应用程序开发中,GUI(图形用户界面)的实现一直是一个痛点。传统的GUI库如 PySide6TkinterwxPython 等虽然功能强大,但开发复杂、样式定制困难,且难以适应现代前端技术的快速发展。此外,像 Kivy 这样的库虽然支持跨平台和丰富的交互效果,但其学习曲线较陡,且对现代Web技术的支持有限。而 Dear PyGuiPySimpleGUI 等新兴库虽然简化了开发流程,但在复杂应用场景中仍存在一定的局限性。

为了解决这一问题,我们提出了一种全新的解决方案:使用 FastAPI 作为后端服务,结合 PyWebView 和现代前端技术(如 React、Vue.js 或 Tailwind CSS),构建现代化的 Python 应用程序 GUI。这种架构不仅能够充分利用现代前端技术的灵活性和强大功能,还能通过 FastAPI 提供高效的后端支持,实现前后端分离的开发模式,从而显著提升开发效率和用户体验。

这种方案的核心思想是:

  1. 后端使用FastAPI:提供高性能的API和WebSocket支持。
  2. 前端使用现代前端技术:无论是React、Vue、Angular等前端框架,还是Tailwind CSS、Bootstrap等CSS框架,甚至是Three.js、D3.js等可视化库,都可以无缝集成。
  3. 通过WebView嵌入前端页面:将前端页面直接嵌入到桌面应用中,实现无缝的GUI体验。

为了帮助开发者更好地理解和实践这一方案,我创建了一个示例项目:fastapi-blog-tutorial。这个项目展示了如何使用FastAPI、WebView和现代前端技术构建一个完整的Python应用程序,涵盖了从后端API设计到前端页面开发的完整流程。如果你访问不了github,可以使用gitee链接 fastapi-blog-tutorial

接下来的教学将基于此项目,逐步讲解如何实现以下功能:

  • 使用FastAPI构建RESTful API和WebSocket服务。
  • 使用WebView将前端页面嵌入到Python应用中。
  • 使用现代前端技术(如HTML、CSS、JavaScript)构建美观的用户界面。
  • 实现前后端的实时通信和数据交互。

通过这个项目,你将掌握如何利用FastAPI和现代前端技术,快速构建现代化、高性能的Python应用程序。无论你是Python开发者,还是前端开发者,都可以从中获得启发和实用的技巧。


架构畅想

1. 架构图

+-------------------+       +-------------------+       +-------------------+
|                   |       |                   |       |                   |
|   FastAPI 后端    |<----->|   WebView 前端    |<----->|   现代前端技术    |
|                   |       |                   |       |                   |
+-------------------+       +-------------------+       +-------------------+

2. 组件说明

  • FastAPI 后端

    • 提供RESTful API和WebSocket支持。
    • 处理业务逻辑、数据存储和通信。
    • 通过uvicorn运行,支持高并发和实时通信。
  • WebView 前端

    • 使用pywebview库将前端页面嵌入到桌面应用中。
    • 提供原生的窗口管理功能(如最小化、最大化、关闭)。
    • 支持与后端的双向通信(通过HTTP API或WebSocket)。
  • 现代前端技术

    • 使用React、Vue、Angular等前端框架构建用户界面。
    • 利用Tailwind CSS、Bootstrap等CSS框架实现快速样式开发。
    • 集成Three.js、D3.js等可视化库,实现复杂的数据可视化。
    • 通过WebSocket与后端实时交互,实现动态数据更新。

优缺点分析

优点

  1. 开发效率高

    • 前端使用现代前端技术,开发速度快,样式定制灵活。
    • 后端使用FastAPI,代码简洁,易于维护。
  2. 跨平台支持

    • WebView和现代前端技术天然支持跨平台,可以在Windows、macOS和Linux上运行。
  3. 现代化UI

    • 利用现代前端技术(如Flexbox、CSS Grid、动画效果)实现美观的用户界面。
  4. 实时通信

    • 通过WebSocket实现前后端的实时通信,适合需要实时更新的应用场景。
  5. 可扩展性强

    • 前后端分离,便于团队协作和功能扩展。

缺点

  1. 性能开销

    • WebView和前端渲染需要一定的系统资源,可能不适合对性能要求极高的场景。
  2. 依赖浏览器引擎

    • WebView依赖于系统或内置的浏览器引擎,可能存在兼容性问题。
  3. 打包体积较大

    • 由于需要嵌入浏览器引擎,打包后的应用程序体积较大。
  4. 学习曲线

    • 需要掌握现代前端技术,对纯Python开发者可能有一定学习成本。

适用场景

  • 桌面应用:需要现代化UI的Python桌面应用程序。
  • 实时数据展示:如监控系统、实时数据仪表盘。
  • 跨平台工具:需要在多个操作系统上运行的工具类应用。
  • 快速原型开发:需要快速构建和迭代的应用场景。

通过这种方案,开发者可以摆脱传统GUI库的束缚,充分利用现代前端技术的优势,快速构建现代化、高性能的Python应用程序。如果你正在为Python GUI开发而烦恼,不妨试试这种全新的解决方案!

环境准备

在开始之前,确保你已经安装了以下Python库:

  • fastapi
  • uvicorn
  • loguru
  • jinja2

你可以通过以下命令安装这些依赖:

pip install fastapi uvicorn loguru jinja2

项目结构

以下是项目的目录结构:

.
├── fastapi-blog-tutorial/                 # 应用代码目录
│   ├── main.py          # 主入口文件
│   ├── routers/         # 路由定义
│   ├── templates/       # 模板文件
│   └── models/          # 数据模型(如果使用数据库)
├── tests/               # 测试代码目录
├── requirements.txt     # 项目依赖文件
└── README.md            # 项目介绍文件
  • main.py:主程序入口,包含FastAPI应用实例。
  • app.py:路由发现及注册器。
  • routers/:存放所有路由模块的目录。
  • templates/:存放Jinja2模板文件的目录。
  • static/:存放静态文件的目录。

代码解析

1. WebSocket命令处理

我们实现了一个WebSocket服务器,支持多种命令处理。以下是核心代码:

1.1 导入必要的库

# 导入必要的库和模块
import datetime
import json

from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from loguru import logger

# 创建一个API路由实例,用于定义WebSocket端点
router = APIRouter()

# 定义一个字典来存储命令及其对应的处理函数
command_handlers = {}

1.2 注册命令处理器

我们定义了一个装饰器register_command,用于注册命令处理器:

def register_command(command):
    """
    注册命令处理器的装饰器函数。

    参数:
        command (str): 要注册的命令名称。

    返回:
        decorator (function): 实际的装饰器函数,用于包装命令处理函数。
    """
    def decorator(func):
        # 记录正在注册的命令及其处理函数名
        logger.debug(f"正在注册命令 '{command}',处理函数为 '{func.__name__}'")
        command_handlers[command] = func

        # 记录当前所有已注册的命令处理器
        logger.info(
            f"当前注册的命令处理器: {', '.join(f'{cmd}: {handler.__name__}' for cmd, handler in command_handlers.items())}")

        return func  # 返回原始函数,不改变其行为

    return decorator

1.3 定义命令处理器

我们定义了三个示例命令处理器:

@register_command('echo')
async def echo_handler(message, websocket):
    """
    处理 'echo' 命令的异步函数。

    参数:
        message (str): 收到的消息内容。
        websocket (WebSocket): WebSocket连接对象。
    """
    logger.debug(f"echo_handler 被调用,消息为: {message}")
    try:
        # 向客户端发送回显消息
        await websocket.send_text(f"回显: {message}")
    except Exception as e:
        # 捕获并记录任何异常
        logger.error(f"echo_handler 中发生错误: {e}")


@register_command('custom')
async def custom_handler(message, websocket):
    """
    处理 'custom' 命令的异步函数。

    参数:
        message (str): 收到的消息内容。
        websocket (WebSocket): WebSocket连接对象。
    """
    logger.debug(f"custom_handler 被调用,消息为: {message}")
    try:
        # 向客户端发送自定义回显消息
        await websocket.send_text(f"自定义回显: {message}")
    except Exception as e:
        # 捕获并记录任何异常
        logger.error(f"custom_handler 中发生错误: {e}")


@register_command('time')
async def time_handler(message, websocket):
    """
    处理 'time' 命令的异步函数。

    参数:
        message (str): 收到的消息内容(本例中未使用)。
        websocket (WebSocket): WebSocket连接对象。
    """
    logger.debug(f"time_handler 被调用,消息为: {message}")
    try:
        # 获取当前时间并格式化为ISO8601字符串
        now = datetime.datetime.now().isoformat()
        # 向客户端发送服务器时间
        await websocket.send_text(f"服务器时间是 {now}")
    except Exception as e:
        # 捕获并记录任何异常
        logger.error(f"time_handler 中发生错误: {e}")

1.4 定义WebSocket路由

我们定义了一个WebSocket路由,处理客户端连接和消息:

@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    """
    WebSocket端点的主处理函数。

    参数:
        websocket (WebSocket): WebSocket连接对象。
    """
    await websocket.accept()  # 接受WebSocket连接
    logger.info("正在处理连接")

    try:
        while True:
            # 接收来自客户端的消息
            data = await websocket.receive_text()

            # 尝试将接收到的数据解析为JSON格式
            try:
                request = json.loads(data)
            except json.JSONDecodeError:
                logger.error(f"无法解码JSON消息: {data}")
                await websocket.send_text("无效的JSON格式")
                continue

            # 提取命令和数据
            command = request.get('command')
            data = request.get('data', '')

            # 检查命令是否合法
            if command not in command_handlers:
                logger.warning(f"非法命令: {command}")
                await websocket.send_text("非法命令")
                continue

            # 记录解析出的命令和数据
            logger.debug(f"解析命令: {command}, 数据: {data}")

            # 查找并调用相应的命令处理函数
            if command in command_handlers:
                logger.debug(f"找到命令 '{command}',调用处理函数 '{command_handlers[command].__name__}'")
                await command_handlers[command](data, websocket)
            else:
                logger.warning(f"未知命令: {command}")
                await websocket.send_text(f"未知命令: {command}")

    except WebSocketDisconnect:
        # 处理客户端断开连接的情况
        logger.info("客户端已断开连接")
    except Exception as e:
        # 捕获并记录其他任何异常
        logger.error(f"处理消息时发生错误: {data}, 错误: {e}")


2. 模板渲染

我们使用Jinja2模板引擎渲染动态HTML页面。以下是核心代码:

2.1 导入必要的库

from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates

# 创建一个APIRouter实例,用于定义API的路由
router = APIRouter()

# 初始化Jinja2Templates实例,指定模板文件的目录
templates = Jinja2Templates(directory='templates')

2.2 定义HTTP路由

我们定义了三个HTTP路由,用于渲染HTML页面:

# 定义根路径的GET请求处理函数
# 返回index.html模板,同时传入一个空的request对象
@router.get("/")
async def get():
    # 使用TemplateResponse方法渲染index.html模板,并传递一个空的request对象
    return templates.TemplateResponse("index.html", {"request": {}})

# 定义/test路径的GET请求处理函数
# 在控制台打印"test",并返回"test"作为HTTP响应
@router.get("/test")
async def test():
    # 在控制台中打印 "test"
    print("test")
    # 返回 "test" 作为响应
    return "test"

# 定义/info路径的GET请求处理函数
# 接收Request对象作为参数,用于获取请求相关数据
@router.get("/info")
async def info(request: Request):
    # 下面是用于演示的变量,包含不同类型的参数
    custom_param = "这是一个自定义参数"  # 自定义字符串参数
    number_param = 42  # 整数参数
    list_param = ["item1", "item2", "item3"]  # 列表参数
    dict_param = {"key1": "value1", "key2": "value2"}  # 字典参数

    # 构造静态文件的URL,这里假设你有一个 style.css 文件在 static 目录下
    static_file_url = request.url_for('static', path='style.css')

    # 返回info.html模板,传入request对象和多个参数
    return templates.TemplateResponse("info.html", {
        "request": request,  # 传递request对象,用于模板渲染
        "custom_param": custom_param,  # 传递自定义字符串参数
        "number_param": number_param,  # 传递整数参数
        "list_param": list_param,  # 传递列表参数
        "dict_param": dict_param,  # 传递字典参数
        "static_file_url": static_file_url  # 传递静态文件的URL
    })

3. APP路由注册中转

app.py中,我们使用自动注册函数发现并注册所有路由地址,并挂载静态资源:

# 导入必要的模块
from importlib import import_module  # 动态导入模块
from pathlib import Path  # 操作文件路径
from pkgutil import iter_modules  # 遍历包中的模块

from fastapi import FastAPI, APIRouter  # 创建FastAPI应用和API路由
from fastapi.staticfiles import StaticFiles  # 提供静态文件服务
from loguru import logger  # 记录日志

# 创建FastAPI应用实例
app = FastAPI()

# 挂载静态文件目录,使得可以通过/static/访问静态资源
app.mount("/static", StaticFiles(directory="static"), name="static")

# 定义一个字典用于缓存已导入的模块,避免重复导入
_imported_modules = {}

def register_routers(package_name='routers'):
    """
    自动注册指定包下的所有API路由。

    参数:
        package_name (str): 包含API路由的包名,默认为'routers'
    """
    # 获取当前文件所在目录,并拼接上包名得到包的实际路径
    package_dir = Path(__file__).resolve().parent / package_name

    # 记录正在注册路由的日志信息
    logger.info(f"正在注册路由,包目录: {package_dir}")

    try:
        # 遍历包中的所有模块
        for (_, module_name, _) in iter_modules([str(package_dir)]):
            # 如果模块已经导入过,则直接使用缓存中的模块
            if module_name in _imported_modules:
                module = _imported_modules[module_name]
            else:
                # 否则动态导入模块并缓存
                module = import_module(f"{package_name}.{module_name}")
                _imported_modules[module_name] = module

            # 记录成功导入模块的日志信息
            logger.debug(f"导入模块: {module_name}")

            # 尝试从模块中获取名为'router'的对象
            router = getattr(module, 'router', None)

            # 如果获取到的对象是APIRouter实例,则将其注册到FastAPI应用中
            if isinstance(router, APIRouter):
                app.include_router(router)
                logger.debug(f"已注册路由: {module_name}")
            else:
                # 如果未找到有效的APIRouter实例,记录警告日志
                logger.warning(f"模块 {module_name} 没有找到有效的 APIRouter 实例")
    except Exception as e:
        # 如果发生任何异常,记录错误日志
        logger.error(f"注册路由时发生错误: {e}")

# 调用函数注册所有路由
register_routers()


4. 主程序入口

main.py中,我们整合了所有路由并启动FastAPI应用:

# 导入必要的库和模块
import os  # 提供与操作系统交互的功能,如文件路径操作
import threading  # 允许程序并发运行多个线程,用于同时启动服务器和webview窗口
import time  # 提供时间处理函数,如休眠
import argparse  # 解析命令行参数

import uvicorn  # 用于运行FastAPI应用的ASGI服务器实现
import webview  # 创建原生桌面GUI窗口,用于显示HTML页面
from loguru import logger  # 强大的日志记录库,提供简洁的日志输出

from app import app  # 导入FastAPI应用实例


class WebSocketServer:
    def __init__(self, **kwargs):
        """
        初始化WebSocketServer类的实例。

        参数:
            kwargs (dict): 包含配置项的字典,支持以下键值:
                - host (str): 服务器监听地址,默认为 '0.0.0.0'
                - port (int): 服务器监听端口,默认为 8000
                - index_path (str): 网页入口文件路径,默认为 "templates/index.html"
        """
        self.host = kwargs.get('host', '0.0.0.0')  # 设置服务器监听地址
        self.port = kwargs.get('port', 8000)  # 设置服务器监听端口
        self.index_path = kwargs.get('index_path', os.path.join("templates", "index.html"))  # 设置网页入口文件路径
        self.app = app  # 绑定FastAPI应用实例

    def start_webview(self):
        """
        启动webview窗口,加载指定的HTML文件。
        """
        webview.create_window('Web Socket Example', str(self.index_path), width=1000, height=800, resizable=True)
        # 创建一个名为'Web Socket Example'的窗口,加载index_path指定的HTML文件,
        # 设置窗口宽度为1000px,高度为800px,并允许调整大小
        webview.start(self.start_server, gui='cef')  # 启动webview事件循环,保持窗口打开,并在启动时调用start_server方法

    def start_server(self):
        """
        启动FastAPI服务器。
        """
        uvicorn.run(self.app, host=self.host, port=self.port)
        # 使用uvicorn运行绑定的FastAPI应用实例,
        # 监听指定的host和port


if __name__ == "__main__":
    # 创建参数解析器
    parser = argparse.ArgumentParser(description='启动WebSocket服务器和Webview窗口')
    parser.add_argument('--server', action='store_true', help='仅启动服务器')
    parser.add_argument('--host', default='0.0.0.0', help='服务器监听地址')
    parser.add_argument('--port', type=int, default=8000, help='服务器监听端口')
    parser.add_argument('--index-path', default=os.path.join("templates", "index.html"), help='网页入口文件路径')

    # 解析命令行参数
    args = parser.parse_args()

    # 检查 index 文件是否存在
    if not os.path.exists(args.index_path):
        logger.error(f"文件 {args.index_path} 不存在,无法启动服务器。")
    else:
        logger.info(
            f"服务器已启动,地址为 http://localhost:{args.port} 和 ws://localhost:{args.port}/ws")

        # 实例化WebSocketServer类
        server = WebSocketServer(host=args.host, port=args.port, index_path=args.index_path)

        if args.server:
            # 仅启动服务器
            server.start_server()
        else:
            # 启动webview窗口
            server.start_webview()


网页部分

1. 前端页面设计

我们使用HTML、CSS和JavaScript实现了一个美观的WebSocket客户端页面。以下是核心代码:

1.1 HTML结构

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WebSocket 客户端 v1.1</title>
    <link rel="stylesheet" href="/static/style.css" />
  </head>
  <body>
    <h1>WebSocket 客户端 v1.1</h1>
    <div id="messages"></div>
    <div class="button-container">
      <button class="button" onclick="sendMessage('echo', 'Hello Server!')">
        💬 发送 Echo 命令
      </button>
      <button class="button" onclick="sendMessage('time', 'Get Time')">🕒 发送 Time 命令</button>
      <button class="button" onclick="clearMessages()">🗑️ 清除消息</button>
      <button class="button" onclick="reconnect()">🔄 重新连接</button>
      <button class="button" onclick="openSettings()">⚙️ 设置</button>
    </div>
    <div class="input-container">
      <input type="text" id="messageInput" placeholder="输入消息..." />
      <button
        class="button"
        onclick="sendMessage('custom', document.getElementById('messageInput').value.trim())"
      >
        📤 发送
      </button>
    </div>
    <div id="connectionStatus" class="connection-status">
      <div class="status-icon"></div>
      <span>连接状态:未连接</span>
    </div>
    <div id="toast" class="toast"></div>
    <div class="overlay" id="overlay"></div>
    <div class="settings-panel" id="settingsPanel">
      <h2>设置</h2>
      <label for="serverPort">端口号</label>
      <input type="text" id="serverPort" placeholder="例如: 8000" />
      <label for="serverPath">路径</label>
      <input type="text" id="serverPath" placeholder="例如: /ws" />
      <button onclick="saveSettings()">保存</button>
    </div>
    <script src="/static/script.js"></script>
  </body>
</html>

1.2 CSS样式

:root {
  --primary-color: #6c7a89;
  --secondary-color: #5c6a79;
  --background-gradient: linear-gradient(135deg, #f5f7fa, #c3cfe2);
  --box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  --transition: all 0.3s ease;
  --text-color: #2c3e50;
  --white: rgba(255, 255, 255, 0.95);
  --success-color: #27ae60;
  --error-color: #e74c3c;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
    sans-serif;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: 0;
  padding: 20px;
  background: var(--background-gradient);
  color: var(--text-color);
  height: 100vh;
  overflow: hidden;
}

h1 {
  margin-bottom: 20px;
  font-size: clamp(2rem, 5vw, 2.5rem);
  color: #34495e;
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
  animation: fadeIn 0.8s ease-in-out;
}

.button-container {
  display: flex;
  justify-content: center;
  gap: 10px;
  margin: 20px 0;
  flex-wrap: wrap;
  animation: fadeIn 0.8s ease-in-out;
}

.button {
  background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
  color: #fff;
  border: none;
  padding: 12px 24px;
  cursor: pointer;
  border-radius: 30px;
  font-size: 14px;
  display: flex;
  align-items: center;
  gap: 8px;
  transition: var(--transition);
  box-shadow: var(--box-shadow);
}

.button:hover {
  transform: translateY(-2px);
  box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
  opacity: 0.9;
}

#messages {
  margin: 20px 0;
  padding: 20px;
  width: 90%;
  max-width: 800px;
  height: 300px;
  overflow-y: scroll;
  background: var(--white);
  border-radius: 15px;
  box-shadow: var(--box-shadow);
  backdrop-filter: blur(10px);
}

.message {
  padding: 12px;
  border-radius: 15px;
  margin-bottom: 15px;
  max-width: 80%;
  word-wrap: break-word;
  background: var(--white);
  box-shadow: var(--box-shadow);
  display: flex;
  justify-content: space-between;
  align-items: center;
  opacity: 0;
  animation: fadeIn 0.5s forwards ease-in-out;
  transition: var(--transition);
  will-change: transform, opacity;
}

.message-client {
  background: linear-gradient(135deg, #e3f2fd, #bbdefb);
  margin-left: auto;
  border-radius: 15px 15px 0 15px;
}

.message-server {
  background: linear-gradient(135deg, #f0f4f8, #d9e2ec);
  margin-right: auto;
  border-radius: 15px 15px 15px 0;
  color: #2d3748;
}

.input-container {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 90%;
  max-width: 800px;
  margin-top: 20px;
  gap: 10px;
}

input[type='text'] {
  flex: 1;
  padding: 12px;
  border: 1px solid rgba(0, 0, 0, 0.1);
  border-radius: 30px;
  outline: none;
  background: var(--white);
  font-size: 14px;
  color: inherit;
  transition: var(--transition);
}

input[type='text']:focus {
  border-color: var(--primary-color);
  box-shadow: 0 0 12px rgba(108, 122, 137, 0.3);
  background: rgba(255, 255, 255, 1);
}

.connection-status {
  margin-top: 10px;
  font-size: 14px;
  color: inherit;
  display: flex;
  animation: fadeIn 0.8s ease-in-out;
  align-items: center;
  gap: 8px;
}

.connection-status.connected {
  color: var(--success-color);
}

.connection-status.disconnected {
  color: var(--error-color);
}

.toast {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  background: var(--primary-color);
  color: #fff;
  padding: 16px 32px;
  border-radius: 50px;
  box-shadow: var(--box-shadow);
  opacity: 0;
  transform: translate(-50%, 20px);
  transition: var(--transition);
  font-size: 16px;
  z-index: 1000;
}

.toast.show {
  opacity: 1;
  transform: translate(-50%, 0);
}

.settings-panel {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: var(--white);
  padding: 30px;
  border-radius: 15px;
  box-shadow: var(--box-shadow);
  backdrop-filter: blur(10px);
  z-index: 1001;
  display: none;
  width: 90%;
  max-width: 400px;
  text-align: center;
}

.settings-panel.show {
  display: block;
}

.overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
  display: none;
}

.overlay.show {
  display: block;
}

1.3 JavaScript逻辑

let ws
const connectionStatus = document.getElementById('connectionStatus')
const toast = document.getElementById('toast')
const overlay = document.getElementById('overlay')
const settingsPanel = document.getElementById('settingsPanel')
const serverPortInput = document.getElementById('serverPort')
const serverPathInput = document.getElementById('serverPath')

function showToast(message, duration = 2000) {
  toast.textContent = message
  toast.classList.add('show')
  setTimeout(() => toast.classList.remove('show'), duration)
}

function openSettings() {
  overlay.classList.add('show')
  settingsPanel.classList.add('show')
  const savedPort = localStorage.getItem('serverPort') || '8000'
  const savedPath = localStorage.getItem('serverPath') || '/ws'
  serverPortInput.value = savedPort
  serverPathInput.value = savedPath
}

function saveSettings() {
  const port = serverPortInput.value.trim()
  const path = serverPathInput.value.trim()
  localStorage.setItem('serverPort', port)
  localStorage.setItem('serverPath', path)
  overlay.classList.remove('show')
  settingsPanel.classList.remove('show')
  reconnect()
}

function initWebSocket() {
  const port = localStorage.getItem('serverPort') || '8000'
  const path = localStorage.getItem('serverPath') || '/ws'
  ws = new WebSocket(`ws://localhost:${port}${path}`)
  ws.onopen = () => {
    addMessage('已连接到服务器', 'server')
    connectionStatus.innerHTML = `<div class="status-icon"></div><span>连接状态:已连接</span>`
    connectionStatus.classList.add('connected')
    connectionStatus.classList.remove('disconnected')
    showToast('已连接到服务器')
  }
  ws.onmessage = (e) => addMessage(`服务器消息: ${e.data}`, 'server')
  ws.onclose = () => {
    addMessage('已断开与服务器的连接', 'server')
    connectionStatus.innerHTML = `<div class="status-icon"></div><span>连接状态:已断开</span>`
    connectionStatus.classList.add('disconnected')
    connectionStatus.classList.remove('connected')
    showToast('已断开与服务器的连接')
  }
  ws.onerror = (e) => {
    addMessage(`WebSocket 错误: ${e.message}`, 'server')
    connectionStatus.innerHTML = `<div class="status-icon"></div><span>连接状态:错误</span>`
    connectionStatus.classList.add('disconnected')
    showToast('WebSocket 错误: ' + e.message)
  }
}

function sendMessage(command, data) {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ command, data }))
    addMessage(`客户端消息: ${data}`, 'client')
    document.getElementById('messageInput').value = ''
    showToast('消息已发送')
  } else {
    addMessage('WebSocket 连接未打开,请重新连接', 'server')
    showToast('WebSocket 连接未打开,请重新连接')
  }
}

function clearMessages() {
  document.getElementById('messages').innerHTML = ''
  showToast('消息已清除')
}

function reconnect() {
  clearMessages()
  connectionStatus.innerHTML = `<div class="loading"></div><span>连接状态:重新连接中...</span>`
  initWebSocket()
  showToast('正在重新连接...')
}

function addMessage(message, type) {
  const messagesDiv = document.getElementById('messages')
  const messageDiv = document.createElement('div')
  messageDiv.classList.add('message', `message-${type}`)
  messageDiv.innerHTML = `<p>${message}</p><time datetime="${new Date().toISOString()}">${new Date().toLocaleTimeString()}</time>`
  messagesDiv.appendChild(messageDiv)
  messagesDiv.scrollTop = messagesDiv.scrollHeight
}

document.getElementById('messageInput').addEventListener('keypress', (e) => {
  if (e.key === 'Enter') sendMessage('custom', document.getElementById('messageInput').value.trim())
})

initWebSocket()

程序展示


1.运行项目

  1. 克隆我的示例项目:
git clone https://github.com/Ktovoz/fastapi-blog-tutorial.git
cd fastapi-blog-tutorial

2. 安装依赖

确保你已经安装了所有必要的依赖项,可以通过运行以下命令来完成:

pip install -r requirements.txt

3. 启动应用

3.1 启动 FastAPI 服务器

在终端中运行以下命令来启动 FastAPI 服务器:

python main.py --server

3.2 启动桌面应用

在终端中运行以下命令来启动 PyWebview 桌面应用:

python main.py

3.3 参数说明

你可以通过以下参数来配置应用的启动行为:

  • --server: 仅启动 FastAPI 服务器,不启动 PyWebview 桌面应用。
  • --host <地址>: 指定 FastAPI 服务器监听的地址,默认为 0.0.0.0
  • --port <端口>: 指定 FastAPI 服务器监听的端口,默认为 8000
  • --index-path <路径>: 指定网页入口文件的路径,默认为 templates/index.html

例如,如果你想启动服务器并指定监听地址和端口,可以运行:

python app/main.py --server --host 127.0.0.1 --port 8080

如果你想启动桌面应用并指定网页入口文件路径,可以运行:

python app/main.py --index-path templates/custom_index.html

4. 访问主页

打开浏览器,访问 http://127.0.0.1:8000,你应该能够看到主页。


总结

通过本文,你已经学会了如何使用FastAPI构建一个支持WebSocket和模板渲染的完整Web应用。这种方法非常适合需要实时通信和动态页面生成的项目。

如果你有任何问题或建议,欢迎在评论区留言,或者直接在我的GitHub项目 fastapi-blog-tutorial 或者gitee项目 fastapi-blog-tutorial中提交Issue。


参考文档


版权声明:本文为博主的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。


希望这篇博文对你的项目有所帮助!如果有其他需求,欢迎随时提出!