基于python3+wxpython的简单http 文件服务器

前言


ai时代就是好,只需要提供思路,程序的问题完全可以交给ai,下面全部内容由traecode和deepseek生成。

很多时候,需要跨系统传输文件,如果有多个跳板机的情况下,使用http 文件服务器的形式进行文件传输会方便很多

最简单的方式就是在文件所在目录使用命令行 python3 -m http.server --bind 0.0.0.0 端口号 的方式,快速开启一个http server 后台。

我的需求是,最好有一个界面化的程序,固定在我的windows任务栏,直接点击程序,然后选择需要代理的目录,即可快速实现http 文件服务。在windows环境下,相较上面唤起命令行,输入指令,这样可以节省一些步骤。

下面是具体实现

正文


图标制作程序

generate_icon.py

# -*- coding: utf-8 -*-
"""
生成 HTTP 文件服务器的图标 (server.ico)
包含 16x16 ~ 256x256 多尺寸
需要 Pillow 库
"""

import os
from PIL import Image, ImageDraw, ImageFont

def create_icon():
    # 定义所有尺寸
    sizes = [16, 24, 32, 48, 64, 128, 256]
    images = []

    for size in sizes:
        # 创建 RGBA 图像(透明背景)
        img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
        draw = ImageDraw.Draw(img)

        # ---------- 绘制圆角矩形背景(深蓝渐变效果)----------
        # 因为绘制渐变较复杂,这里用纯色 + 圆角模拟
        bg_color = (30, 80, 180, 255)  # 深蓝
        margin = size // 16
        radius = size // 8
        # 画圆角矩形(用矩形+四个小圆弧组合,简化用普通矩形)
        # 为了美观,我们画一个圆角矩形(利用PIL的rounded_rectangle,需要新版)
        try:
            draw.rounded_rectangle(
                [margin, margin, size - margin, size - margin],
                radius=radius,
                fill=bg_color
            )
        except AttributeError:
            # 旧版本PIL,用普通矩形代替
            draw.rectangle(
                [margin, margin, size - margin, size - margin],
                fill=bg_color
            )

        # ---------- 绘制文件夹图标(白色)---------
        # 文件夹:一个梯形+矩形组合,简化:矩形加一条横线
        # 根据尺寸缩放
        f_w = size * 0.7
        f_h = size * 0.55
        f_x = (size - f_w) // 2
        f_y = (size - f_h) // 2 + size * 0.05
        # 文件夹主体(白色半透明)
        draw.rectangle(
            [f_x, f_y, f_x + f_w, f_y + f_h],
            fill=(255, 255, 255, 220),
            outline=(255, 255, 255, 255)
        )
        # 文件夹“舌头”
        tab_w = f_w * 0.3
        tab_h = f_h * 0.15
        draw.rectangle(
            [f_x, f_y - tab_h, f_x + tab_w, f_y],
            fill=(255, 255, 255, 220),
            outline=(255, 255, 255, 255)
        )

        # ---------- 在地球/网络符号(表示互联网)---------
        # 绘制一个简易地球:两个弧线 + 竖线
        earth_cx = size // 2
        earth_cy = size // 2 - size * 0.05
        earth_r = size * 0.2
        draw.ellipse(
            [earth_cx - earth_r, earth_cy - earth_r,
             earth_cx + earth_r, earth_cy + earth_r],
            outline=(255, 255, 255, 200),
            width=max(1, size // 30)
        )
        # 横向弧线(赤道)
        draw.arc(
            [earth_cx - earth_r, earth_cy - earth_r,
             earth_cx + earth_r, earth_cy + earth_r],
            start=0, end=180,
            fill=(255, 255, 255, 200),
            width=max(1, size // 30)
        )
        # 纵向弧线(经线)
        draw.arc(
            [earth_cx - earth_r, earth_cy - earth_r,
             earth_cx + earth_r, earth_cy + earth_r],
            start=90, end=270,
            fill=(255, 255, 255, 200),
            width=max(1, size // 30)
        )
        # 小圆点(代表卫星或连接点)
        dot_r = max(1, size // 40)
        draw.ellipse(
            [earth_cx - dot_r, earth_cy - dot_r,
             earth_cx + dot_r, earth_cy + dot_r],
            fill=(255, 255, 255, 255)
        )

        # ---------- 底部添加小文字 "HTTP"(仅大尺寸)----------
        if size >= 64:
            try:
                font = ImageFont.truetype("arial.ttf", size // 10)
            except:
                font = ImageFont.load_default()
            text = "HTTP"
            # 获取文本尺寸
            bbox = draw.textbbox((0, 0), text, font=font)
            tw = bbox[2] - bbox[0]
            th = bbox[3] - bbox[1]
            tx = (size - tw) // 2
            ty = f_y + f_h + size * 0.05
            if ty + th < size:
                draw.text((tx, ty), text, font=font, fill=(255, 255, 255, 200))

        # 保存该尺寸图像
        images.append(img)

    # 保存为 ICO 文件(多尺寸)
    if images:
        # 以第一个图像作为主图像,保存所有尺寸
        images[0].save(
            'server.ico',
            format='ICO',
            sizes=[(img.width, img.height) for img in images],
            append_images=images[1:]
        )
        print("✅ 图标已生成:server.ico")
    else:
        print("❌ 生成失败")

if __name__ == "__main__":
    create_icon()

使用方式,运行:python3 generate_icon.py 即可生成一个名字为 server.ico 的图标

主程序以及打包脚本

主程序
wx_http_server_gui.py

# -*- coding: utf-8 -*-
"""
本地 HTTP 文件服务器 GUI 工具
- wxPython 界面
- 支持自定义 IP 绑定(默认 127.0.0.1)
- 自动显示本机可访问 IP
- 配置自动保存
- 可打包为 exe
"""

import os
import sys
import threading
import socket
import webbrowser
import time
import json
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer

import wx
import wx.richtext as rt

# ------------------ 常量 ------------------
APP_TITLE = "本地 HTTP 文件服务器"
APP_VERSION = "1.3.0"
DEFAULT_PORT = 12123
DEFAULT_HOST = "127.0.0.1"
CONFIG_FILE = "server_gui_config.json"


# ------------------ 日志重定向 ------------------
class RedirectStream:
    def __init__(self, callback):
        self._callback = callback
        self._buffer = ""

    def write(self, text):
        if not text:
            return
        self._buffer += text
        while "\n" in self._buffer:
            line, self._buffer = self._buffer.split("\n", 1)
            if line:
                self._callback(line)

    def flush(self):
        if self._buffer:
            self._callback(self._buffer)
            self._buffer = ""


# ------------------ HTTP 请求处理器 ------------------
class FileServerHandler(SimpleHTTPRequestHandler):
    log_callback = None

    def __init__(self, *args, directory=None, **kwargs):
        self._directory = directory
        super().__init__(*args, directory=directory, **kwargs)

    def log_message(self, format, *args):
        msg = format % args
        client = self.address_string()
        if self.log_callback:
            self.log_callback(f"{client} - {msg}")

    def end_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        super().end_headers()


# ------------------ 主窗口 ------------------
class MainFrame(wx.Frame):
    def __init__(self):
        super().__init__(None, title=f"{APP_TITLE} v{APP_VERSION}", size=(820, 560))

        self.httpd = None
        self.server_thread = None
        self.server_lock = threading.Lock()

        self.config = self._load_config()
        self._init_ui()
        self._bind_events()
        self._apply_config()
        self._update_button_state(running=False)

        # 设置窗口图标(支持打包后加载)
        self.SetIcon(self._get_app_icon())

        # 重定向 stdout/stderr
        self._redirect = RedirectStream(self._on_redirect_output)
        sys.stdout = self._redirect
        sys.stderr = self._redirect

        FileServerHandler.log_callback = self._on_handler_log

        self.log_line(f"{APP_TITLE} v{APP_VERSION} 已就绪。", color="gray")

    # ------------------ UI 构建 ------------------
    def _init_ui(self):
        panel = wx.Panel(self)
        vbox = wx.BoxSizer(wx.VERTICAL)

        # ----- 第1行:共享目录 -----
        row_dir = wx.BoxSizer(wx.HORIZONTAL)
        row_dir.Add(wx.StaticText(panel, label="共享目录:"),
                    flag=wx.ALIGN_CENTER_VERTICAL | wx.LEFT, border=8)
        self.dir_picker = wx.DirPickerCtrl(
            panel,
            message="选择要共享的文件夹",
            style=wx.DIRP_USE_TEXTCTRL | wx.DIRP_DIR_MUST_EXIST
        )
        self.dir_picker.SetMinSize((400, -1))
        row_dir.Add(self.dir_picker, proportion=1,
                    flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=4)
        vbox.Add(row_dir, flag=wx.EXPAND | wx.TOP, border=8)

        # ----- 第2行:主机IP + 端口 + 状态/清空按钮 -----
        row_host_port = wx.BoxSizer(wx.HORIZONTAL)

        row_host_port.Add(wx.StaticText(panel, label="主机 IP:"),
                          flag=wx.ALIGN_CENTER_VERTICAL | wx.LEFT, border=8)
        self.host_text = wx.TextCtrl(panel, value=DEFAULT_HOST, size=(130, -1))
        row_host_port.Add(self.host_text,
                          flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=4)

        row_host_port.Add(wx.StaticText(panel, label="端口:"),
                          flag=wx.ALIGN_CENTER_VERTICAL | wx.LEFT, border=12)
        self.port_text = wx.TextCtrl(panel, value=str(DEFAULT_PORT), size=(80, -1))
        row_host_port.Add(self.port_text,
                          flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=4)

        self.status_indicator = wx.StaticText(panel, label="● 已停止",
                                              style=wx.ALIGN_CENTER_VERTICAL)
        self.status_indicator.SetForegroundColour(wx.Colour(120, 120, 120))
        row_host_port.Add(self.status_indicator,
                          flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=20)

        row_host_port.AddStretchSpacer()

        self.clear_log_btn = wx.Button(panel, label="清空日志")
        row_host_port.Add(self.clear_log_btn,
                          flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=4)

        vbox.Add(row_host_port, flag=wx.EXPAND | wx.TOP, border=8)

        # ----- 第3行:控制按钮 -----
        row_btns = wx.BoxSizer(wx.HORIZONTAL)
        self.start_btn = wx.Button(panel, label="开启服务")
        self.stop_btn = wx.Button(panel, label="关闭服务")
        self.open_browser_btn = wx.Button(panel, label="在浏览器打开")
        row_btns.Add(self.start_btn, flag=wx.LEFT, border=8)
        row_btns.Add(self.stop_btn, flag=wx.LEFT, border=4)
        row_btns.Add(self.open_browser_btn, flag=wx.LEFT, border=4)
        vbox.Add(row_btns, flag=wx.EXPAND | wx.TOP, border=8)

        # ----- 第4行:日志区域 -----
        vbox.Add(wx.StaticText(panel, label="日志:"),
                 flag=wx.LEFT | wx.TOP, border=8)
        self.log_ctrl = rt.RichTextCtrl(
            panel,
            style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.BORDER_SIMPLE
        )
        vbox.Add(self.log_ctrl, proportion=1,
                 flag=wx.EXPAND | wx.ALL, border=8)

        panel.SetSizer(vbox)
        self.Centre()

    def _bind_events(self):
        self.dir_picker.Bind(wx.EVT_DIRPICKER_CHANGED, self.on_dir_changed)
        self.port_text.Bind(wx.EVT_CHAR, self.on_port_char)

        self.start_btn.Bind(wx.EVT_BUTTON, self.on_start)
        self.stop_btn.Bind(wx.EVT_BUTTON, self.on_stop)
        self.open_browser_btn.Bind(wx.EVT_BUTTON, self.on_open_browser)
        self.clear_log_btn.Bind(wx.EVT_BUTTON, self.on_clear_log)

        self.Bind(wx.EVT_CLOSE, self.on_close)

    # ------------------ 图标加载 ------------------
    def _get_app_icon(self):
        """获取应用程序图标,支持 PyInstaller 打包后的路径。"""
        if getattr(sys, 'frozen', False):
            base_path = sys._MEIPASS
        else:
            base_path = os.path.dirname(os.path.abspath(__file__))
        icon_path = os.path.join(base_path, 'server.ico')
        if os.path.exists(icon_path):
            icon = wx.Icon()
            if icon.LoadFile(icon_path, wx.BITMAP_TYPE_ICO):
                return icon
        return wx.NullIcon

    # ------------------ 配置读写 ------------------
    def _load_config(self):
        default = {"directory": os.getcwd(), "host": DEFAULT_HOST, "port": DEFAULT_PORT}
        try:
            if os.path.exists(CONFIG_FILE):
                with open(CONFIG_FILE, "r", encoding="utf-8") as f:
                    data = json.load(f)
                    return {k: data.get(k, default[k]) for k in default}
        except Exception:
            pass
        return default

    def _save_config(self):
        try:
            data = {
                "directory": self.dir_picker.GetPath(),
                "host": self.host_text.GetValue().strip(),
                "port": int(self.port_text.GetValue().strip())
            }
            with open(CONFIG_FILE, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2)
        except Exception:
            pass

    def _apply_config(self):
        self.dir_picker.SetPath(self.config["directory"])
        self.host_text.SetValue(self.config["host"])
        self.port_text.SetValue(str(self.config["port"]))

    # ------------------ 事件处理 ------------------
    def on_dir_changed(self, event):
        self._save_config()

    def on_port_char(self, event):
        key = event.GetKeyCode()
        if (47 < key < 58) or key in (8, 127, 314, 315, 316, 317):
            event.Skip()
        else:
            if not event.ShiftDown():
                wx.Bell()

    def on_start(self, event):
        with self.server_lock:
            if self.httpd is not None:
                self.log_line("服务已在运行中。", color="orange")
                return

            # 校验目录
            directory = self.dir_picker.GetPath().strip()
            if not directory or not os.path.isdir(directory):
                self.log_line("请选择有效的共享目录。", color="red")
                wx.MessageBox("请选择有效的共享目录。", "提示", wx.OK | wx.ICON_WARNING)
                return

            # 校验IP
            host = self.host_text.GetValue().strip()
            if not host:
                self.log_line("请输入有效的主机IP。", color="red")
                return

            # 校验端口
            try:
                port = int(self.port_text.GetValue().strip())
            except ValueError:
                self.log_line("端口号无效,请输入数字。", color="red")
                return
            if not (1 <= port <= 65535):
                self.log_line("端口号必须在 1-65535 之间。", color="red")
                return

            # 检查端口是否被占用(针对指定IP)
            if self._is_port_in_use(host, port):
                self.log_line(f"{host}:{port} 已被占用,请更换。", color="red")
                wx.MessageBox(f"{host}:{port} 已被占用,请更换。", "提示", wx.OK | wx.ICON_WARNING)
                return

            # 创建服务器
            try:
                handler_class = partial(FileServerHandler, directory=directory)
                self.httpd = ThreadingHTTPServer((host, port), handler_class)
                self.httpd.daemon_threads = True
            except Exception as e:
                self.log_line(f"启动失败:{e}", color="red")
                wx.MessageBox(f"启动服务器失败:{e}", "错误", wx.OK | wx.ICON_ERROR)
                self.httpd = None
                return

            # 启动服务线程
            self.server_thread = threading.Thread(
                target=self._serve_loop,
                args=(self.httpd,),
                daemon=True
            )
            self.server_thread.start()

            # ---------- 日志显示访问地址 ----------
            if host == '0.0.0.0':
                local_ips = self._get_local_ips()
                if local_ips:
                    self.log_line(f"服务已启动,监听所有接口 (0.0.0.0:{port})。可通过以下地址访问:", color="green")
                    for ip in local_ips:
                        self.log_line(f"  http://{ip}:{port}/", color="blue")
                else:
                    self.log_line(f"服务已启动,但未检测到有效局域网 IP。", color="orange")
            elif host == '127.0.0.1':
                self.log_line(f"服务已启动 → http://127.0.0.1:{port}/  仅本机可访问", color="green")
            else:
                self.log_line(f"服务已启动 → http://{host}:{port}/  目录:{directory}", color="green")

            self.status_indicator.SetLabel("● 运行中")
            self.status_indicator.SetForegroundColour(wx.Colour(0, 180, 0))
            self._update_button_state(running=True)
            self._save_config()

    def on_stop(self, event):
        with self.server_lock:
            if self.httpd is None:
                self.log_line("服务未运行。", color="orange")
                return
            try:
                self.httpd.shutdown()
                self.httpd.server_close()
                if self.server_thread and self.server_thread.is_alive():
                    self.server_thread.join(1.0)
            except Exception as e:
                self.log_line(f"关闭异常:{e}", color="red")
            finally:
                self.httpd = None
                self.server_thread = None
                self.log_line("服务已停止。", color="gray")
                self.status_indicator.SetLabel("● 已停止")
                self.status_indicator.SetForegroundColour(wx.Colour(120, 120, 120))
                self._update_button_state(running=False)

    def on_open_browser(self, event):
        if self.httpd is None:
            self.log_line("服务未运行,无法打开浏览器。", color="orange")
            return
        host = self.host_text.GetValue().strip()
        try:
            port = int(self.port_text.GetValue().strip())
        except ValueError:
            port = DEFAULT_PORT

        if host == "0.0.0.0":
            display_host = "127.0.0.1"
        else:
            display_host = host
        url = f"http://{display_host}:{port}/"
        self.log_line(f"在浏览器中打开:{url}", color="blue")
        webbrowser.open(url)

    def on_clear_log(self, event):
        self.log_ctrl.Clear()

    def on_close(self, event):
        if self.httpd is not None:
            self.on_stop(None)
        sys.stdout = sys.__stdout__
        sys.stderr = sys.__stderr__
        self.Destroy()

    # ------------------ 服务线程 ------------------
    def _serve_loop(self, httpd):
        try:
            self.log_line("服务线程进入监听循环。", color="gray")
            httpd.serve_forever()
        except Exception as e:
            wx.CallAfter(self.log_line, f"服务异常:{e}", "red")
        finally:
            wx.CallAfter(self.log_line, "服务线程已退出。", color="gray")

    # ------------------ 日志处理 ------------------
    def _on_handler_log(self, line):
        wx.CallAfter(self.log_line, line, "black")

    def _on_redirect_output(self, line):
        wx.CallAfter(self.log_line, line, "black")

    def log_line(self, line, color="black"):
        if not line:
            return
        ts = time.strftime("%H:%M:%S")
        self.log_ctrl.SetInsertionPointEnd()
        attr = wx.TextAttr()
        attr.SetTextColour(wx.Colour(self._color_to_rgb(color)))
        self.log_ctrl.SetDefaultStyle(attr)
        self.log_ctrl.WriteText(f"[{ts}] {line}\n")
        self.log_ctrl.ShowPosition(self.log_ctrl.GetLastPosition())

    @staticmethod
    def _color_to_rgb(name):
        mapping = {
            "black": (0, 0, 0),
            "red": (220, 20, 20),
            "green": (0, 128, 0),
            "blue": (0, 0, 200),
            "orange": (200, 120, 0),
            "gray": (120, 120, 120),
        }
        return mapping.get(name, (0, 0, 0))

    # ------------------ 辅助方法 ------------------
    def _is_port_in_use(self, host, port):
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.settimeout(0.5)
                s.bind((host, port))
                return False
        except Exception:
            return True

    def _get_local_ips(self):
        """获取本机所有非环回 IPv4 地址(用于 0.0.0.0 绑定时的提示)。"""
        ips = set()
        try:
            hostname = socket.gethostname()
            for ip in socket.gethostbyname_ex(hostname)[2]:
                if ip != '127.0.0.1' and not ip.startswith('169.254.'):
                    ips.add(ip)
        except Exception:
            pass
        # 备用方法:通过连接外部获得默认出口 IP
        if not ips:
            try:
                s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                s.connect(('8.8.8.8', 80))
                ip = s.getsockname()[0]
                s.close()
                if ip and ip != '127.0.0.1' and not ip.startswith('169.254.'):
                    ips.add(ip)
            except Exception:
                pass
        return list(ips)

    def _update_button_state(self, running=False):
        self.start_btn.Enable(not running)
        self.stop_btn.Enable(running)
        self.open_browser_btn.Enable(running)


# ------------------ 应用程序类 ------------------
class ServerApp(wx.App):
    def OnInit(self):
        try:
            self.frame = MainFrame()
            self.SetTopWindow(self.frame)
            self.frame.Show()
            return True
        except Exception as e:
            import traceback
            tb = traceback.format_exc()
            try:
                err_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "crash.log")
                with open(err_path, "w", encoding="utf-8") as f:
                    f.write(tb)
            except Exception:
                pass
            try:
                dlg = wx.MessageDialog(None, f"初始化失败:{e}\n\n详情见 crash.log", "严重错误",
                                       wx.OK | wx.ICON_ERROR)
                dlg.ShowModal()
                dlg.Destroy()
            except Exception:
                pass
            return False


def main():
    app = ServerApp()
    app.MainLoop()


if __name__ == "__main__":
    main()

打包配置文件
WxHttpFileServer.spec

# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all

# 收集 wx 所有依赖
tmp_ret = collect_all('wx')
datas = tmp_ret[0] + [('server.ico', '.')]   # 添加自定义图标
binaries = tmp_ret[1]
hiddenimports = tmp_ret[2]

a = Analysis(
    ['wx_http_server_gui.py'],
    pathex=[],
    binaries=binaries,
    datas=datas,          # 包含图标文件
    hiddenimports=hiddenimports,
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    noarchive=False,
)

pyz = PYZ(a.pure)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.datas,
    [],
    name='WxHttpFileServer',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=False,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
    icon='server.ico',      # 设置 exe 文件的图标(资源管理器显示)
)

打包脚本
build.bat

@echo off
chcp 65001 >nul
title 打包 HTTP 文件服务器

echo ============================================
echo   开始打包 WxHttpFileServer
echo ============================================

:: 检查 PyInstaller 是否安装
pip show pyinstaller >nul 2>&1
if errorlevel 1 (
    echo [错误] 未找到 PyInstaller,请先安装:pip install pyinstaller
    pause
    exit /b 1
)

:: 清理旧构建文件(可选)
if exist "build" (
    echo 删除旧的 build 目录...
    rmdir /s /q build
)
if exist "dist" (
    echo 删除旧的 dist 目录...
    rmdir /s /q dist
)

:: 执行打包(使用 spec 文件)
echo 正在执行打包,请稍候...
pyinstaller WxHttpFileServer.spec

if errorlevel 1 (
    echo [错误] 打包失败,请检查错误信息。
    pause
    exit /b 1
)

echo ============================================
echo   打包完成!可执行文件位于 dist\ 目录下
echo ============================================
pause

使用方式

  1. 把上述所有文件保存到同一个目录下;
  2. 制作图标 python3 generate_icon.py ;
  3. 打包:双击或在命令行执行:build.bat,然后会在dist目录生成exe文件
  4. 进入dist目录,双击或在命令行打开打包好的程序:WxHttpFileServer.exe

运行效果如下:
image

image

posted @ 2026-09-03 16:08  BrianSun  阅读(8)  评论(0)    收藏  举报