尧图精选

Flet+FastAPI:纯Python实现文件上传,避开常见坑

🕒 发布时间:2026/10/2 14:59:30 📁 来源:尧图网络
简介这份模板演示了如何用Flet编写前端文件上传界面并让FastAPI后端接收与保存文件适合想快速搭建文件上传功能的Python开发者。前端基于Flet丰富的组件库实现文件选择对话框支持一次勾选多个文件并实时显示每个文件的上传进度后端采用FastAPI异步接口处理请求接收完成后自动将文件保存至指定目录并通过环境变量FLET_SECRET_KEY保护敏感配置避免硬编码。压缩包共5个文件其中3个.py文件分别对应前端上传组件、后端API与辅助脚本1个.txt为使用说明另附1个.gif操作演示动画总大小仅108KB结构紧凑、易于阅读。目前已有102人学习过这份模板特别适合正在学习Flet与FastAPI集成、或需要搭建文档管理系统、媒体库、个人云存储等场景的读者。通过模板可快速理解前后端交互流程、文件流处理方式及自定义组件封装思路并可直接修改扩展节省从零开发的时间。1. 纯Python写上传页Flet把前端和后端之间的那堵墙拆了做内部工具的人都有这种经历后端接口半小时写完前端上传页面折腾一晚上。传统前后端分离项目实战里前端要引组件库、配axios、处理跨域一个文件上传功能动不动就牵扯出CORS、请求头、FormData 构造一堆问题。Flet 这个框架不一样它用纯 Python 就能把前端界面跑起来配合 FilePicker 组件文件选取、上传、进度回调全都封装好了后端用 FastAPI 接收保存前后端之间只差一个 POST 请求。这个方向适合内部工具、数据标注平台、模型管理后台这类重功能轻样式的场景也适合想用 Python 一把梭的开发者。下面这套方案我会把最小链路、自定义组件模板、避坑点按可落地的顺序拆开讲。2. 最小上传链路FilePicker 选文件FastAPI 接文件2.1 先搞清楚 Flet 的两种上传pick_files 和 upload() 是两件事第一次用 Flet 做文件上传的人十有八九会卡在这里FilePicker 的on_result回调已经拿到了文件名以为上传就完成了结果后端一个请求都没收到。这是因为pick_files只是调起系统的文件选择对话框把选中文件的路径和元数据文件名、大小交给前端真正的网络传输要靠 FilePicker 实例的upload()方法。这里先记一个结论on_result只负责选文件upload()才负责传文件两件事之间需要你自己把FilePickerUploadFile列表拼出来。Flet 在这个环节没有帮你做隐式关联漏掉upload()调用是上传功能静默失败的常见原因。另外upload()做的是 multipart/form-data POST 请求不是普通 JSON后端如果按app.post接收 JSON 字段去写必然报 422 或字段缺失。下面是一个能跑通的最小前端代码Flet 版本以较新的 0.2x 为准import flet as ft def main(page: ft.Page): page.title Flet 文件上传最小链路 def on_pick_result(e: ft.FilePickerResultEvent): if not e.files: return # 注意这里只拿到了文件信息upload() 还没被调用 for f in e.files: print(选中, f.name, f.path, f.size) fp.upload() # 真正的上传在这里触发 page.update() def on_upload_progress(e: ft.FilePickerUploadEvent): # e.file_name 是正在传的文件名e.progress 是 0.0~1.0 的浮点 print(f{e.file_name} 进度{e.progress}) fp ft.FilePicker( on_resulton_pick_result, on_upload_progresson_upload_progress, upload_url/upload, # 后面解释这个参数 ) page.overlay.append(fp) def open_picker(e): # 只允许图片和压缩包对应 allowed_extensions 参数 fp.pick_files( allow_multipleTrue, allowed_extensions[png, jpg, pdf, zip], ) page.add( ft.ElevatedButton(选择文件并上传, on_clickopen_picker), ) ft.app(targetmain)fp.pick_files()里的allow_multiple决定能不能多选allowed_extensions只是系统对话框的过滤器不是后端防线。fp.upload()不传参数时会使用pick_files选中的文件列表如果你要手动指定可以传入一个ft.FilePickerUploadFile(name..., path...)列表。upload()是异步触发实际网络请求由 Flet 内部调度你不需要自己开线程。有个细节需要注意upload_url是 Flet 前端发请求的路径我在这里写的是/upload它是个相对路径。如果 Flet 页面和后端不在同一个进程就必须配合base_url使用这个参数我在 2.3 节专门讲。2.2 后端接收与保存FastAPI 的 UploadFile 和真实的落盘逻辑Flet 的upload()会发一个 multipart/form-data POST 请求。FastAPI 接收这类请求有现成的UploadFile类型。但这里有个实践要点Flet 发送时 form data 的字段名在不同版本里有过变化网上不少教程直接写file: UploadFile File(...)你照着抄可能会碰到 422 字段缺失。我一般不会赌字段名而是直接读request.form()从表单里取第一个文件对象做了兼容处理。后端代码如下import os import shutil from fastapi import FastAPI, Request, UploadFile app FastAPI() SAVE_DIR /data/uploads os.makedirs(SAVE_DIR, exist_okTrue) app.post(/upload) async def upload_file(request: Request): form await request.form() # Flet 上传的字段名不固定这里直接遍历取第一个文件 uploaded None for key, value in form.items(): if isinstance(value, UploadFile): uploaded value break if uploaded is None: return {ok: False, msg: no file in form} # 只保留原始文件名防止路径穿越和目录拼接 raw_name os.path.basename(uploaded.filename or untitled) dest_path os.path.join(SAVE_DIR, raw_name) # shutil.copyfileobj 是流式拷贝不会把整个文件塞进内存 with open(dest_path, wb) as f: shutil.copyfileobj(uploaded.file, f) return {ok: True, filename: raw_name, saved: dest_path}这段代码有三个值得背下来的点第一await request.form()会把 multipart 表单完整解析出来其中UploadFile类型的值就是文件对象。遍历取值的方式绕开了字段名不确定的问题这在对接非标准客户端时特别有用。第二os.path.basename是必须的。如果客户端传的是../../etc/passwd或者C:\Users\xx\a.png直接拼路径会写错位置甚至产生路径穿越漏洞。第三shutil.copyfileobj内部按块读取写入默认 16KB 缓冲。对大文件来说这比await uploaded.read()一次性读进内存再写要稳妥得多。如果文件可能超过 200MB建议把shutil.copyfileobj的第二个参数换成open(dest_path, wb)的buffering调大或者在拷贝时用自定义块大小。2.3 跑通链路必须调好的两个参数upload_url 和 base_url这两个参数是 Flet 文件上传最玄学的地方很多翻车现场都出在这里。先给结论upload_url告诉 Flet 把文件 POST 到哪个路径base_url告诉 Flet 这个路径归属哪个服务。如果你把 Flet 前端和后端放在两个进程常见做法是 Flet 起 8550 端口FastAPI 起 8000 端口那么upload_url写了/upload请求会打到 Flet 自己的服务上而不是 FastAPI。这时候必须给 FilePicker 设置base_urlfp ft.FilePicker( on_resulton_pick_result, on_upload_progresson_upload_progress, upload_url/upload, ) fp.base_url http://127.0.0.1:8000 # FastAPI 的地址 page.overlay.append(fp)注意base_url是 FilePicker 的属性不是构造函数参数至少在较新版本里这样用是稳的。设置完之后Flet 会把base_url upload_url拼成完整地址发请求。如果你用的是ft.app(targetmain, viewft.AppView.WEB_BROWSER)把 Flet 本身跑在 Web 模式那情况稍微特殊一点文件会先经过 Flet 服务端转发。此时base_url仍然指到 FastAPIFlet 服务端会作为代理把文件转发过去。这个模式下你还要检查 FastAPI 的 CORS 配置因为浏览器到 Flet 服务端的请求会带跨域头。CORS 配置我一般这样写避免上传时预检请求失败from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[*], # 内网工具可以全放 allow_methods[POST], allow_headers[*], )判断上传链路是否打通最快的办法是看后端日志。如果你点上传按钮后 FastAPI 的日志里没有任何访问/upload的记录那问题一定出在base_url没指对或者upload_url拼错。这种问题靠前端抓包是看不到的因为请求可能压根没发出来。3. 把上传封装成自定义组件模板改参数就能复用的上传器3.1 模板结构dataclass 配置 组件类 注册入口文件上传逻辑一旦跑通下一个需求几乎是必然的项目里多个页面都要传文件而且要传的文件类型不一样。数据标注平台要传图片模型管理后台要传模型包日志分析工具要传文本。你不想在每个页面复制粘贴一大段 FilePicker 代码这时候就需要一个自定义组件模板。我设计的模板分三层UploaderConfig保存所有可调参数FileUploader类负责组装界面和处理回调页面里只需要FileUploader(config)就能生成一个上传器。这个结构参考了常见前端组件库的做法把变化点收敛到配置对象里。参数设计上我会让UploaderConfig至少覆盖五个字段标题文字、允许的扩展名、是否多选、后端地址、保存子目录。这些在真实项目中几乎每个上传场景都会变。from dataclasses import dataclass, field dataclass class UploaderConfig: title: str 上传文件 allowed_extensions: list field( default_factorylambda: [png, jpg, pdf, zip] ) allow_multiple: bool True server_base: str http://127.0.0.1:8000 upload_url: str /uploaddataclass 的好处是定义简洁实例化时可以直接用关键字参数覆盖默认值。后续要加字段比如max_size_mb、save_subdir直接加一行属性就行不用动模板类。3.2 模板代码上传器类的完整实现组件类继承ft.UserControl在较新的 Flet 版本里已经不再推荐我直接用普通类包装ft.Column把 FilePicker 和按钮、进度条组装起来。模板如下import flet as ft from dataclasses import dataclass, field dataclass class UploaderConfig: title: str 上传文件 allowed_extensions: list field( default_factorylambda: [png, jpg, pdf, zip] ) allow_multiple: bool True server_base: str http://127.0.0.1:8000 upload_url: str /upload class FileUploader: def __init__(self, config: UploaderConfig): self.config config self._fp None self._progress_rows {} # 用文件名索引进度条控件 def build(self) - ft.Control: # 创建 FilePicker注册回调和上传地址 self._fp ft.FilePicker( on_resultself._on_result, on_upload_progressself._on_progress, upload_urlself.config.upload_url, ) self._fp.base_url self.config.server_base pick_btn ft.ElevatedButton( self.config.title, on_clicklambda e: self._fp.pick_files( allow_multipleself.config.allow_multiple, allowed_extensionsself.config.allowed_extensions, ), ) # uploader 根控件是 ColumnFilePicker 挂到 page.overlay # 所以 build 方法返回的控件里不包含 _fp return ft.Column( controls[ pick_btn, ft.Column(refself._progress_rows, spacing5), ], spacing10, )这里有一个关键设计FilePicker 必须注册到page.overlay不能直接放进 Column。因为 Flet 的文件选择对话框属于页面级浮层不在普通控件树里。所以build()只返回按钮和进度区而注册动作要暴露一个单独的方法def register(self, page: ft.Page): page.overlay.append(self._fp) page.update()register和build分开是为了避免组件内部隐式绑定页面。在复杂的页面布局里你可能想让上传器出现在某个 Tab 里而 overlay 始终挂在页面根上两者不冲突。使用模板的代码就很干净了def main(page: ft.Page): uploader FileUploader(UploaderConfig( title上传标注图片, allowed_extensions[png, jpg, jpeg], allow_multipleTrue, server_basehttp://127.0.0.1:8000, upload_url/upload/images, )) uploader.register(page) page.add(uploader.build())回调方法里要处理进度条的创建和更新。注意_on_progress收到的e.file_name可能带路径用os.path.basename处理一下再作为 keydef _on_result(self, e: ft.FilePickerResultEvent): if not e.files: return for f in e.files: # 每个文件一行进度条显示文件名和 ProgressBar row ft.Row([ ft.Text(f.name, width180, overflowellipsis), ft.ProgressBar(value0, width220), ]) self._progress_rows[f.name] row self._root.controls.append(row) # _root 是 Column 的引用 self._fp.upload() # 这里不调用 page.updatewait 进度回调里统一更新 def _on_progress(self, e: ft.FilePickerUploadEvent): row self._progress_rows.get(e.file_name) if row is not None: row.controls[1].value e.progress row.controls[1].update()这段代码为了可读性做了一点简化_root需要在build()里保存self._root ft.Column(...)而不是直接return。这样_on_result才能往里追加进度行。模板的价值就在这些细节里不把组件状态管理好多文件上传时进度条会串行。3.3 模板应用一个页面放三个不同用途的上传器组件模板做完落地时最常见的场景是一个页面放多个上传器。比如我的一个模型数据管理工具页面上同时有上传原始图片上传模型权重上传标注文件三个区块。用模板只需要实例化三份upload_images FileUploader(UploaderConfig( title上传原始图片, allowed_extensions[png, jpg], server_basehttp://127.0.0.1:8000, upload_url/upload/images, )) upload_model FileUploader(UploaderConfig( title上传模型权重, allowed_extensions[pth, onnx, pt], allow_multipleFalse, server_basehttp://127.0.0.1:8000, upload_url/upload/model, )) upload_labels FileUploader(UploaderConfig( title上传标注文件, allowed_extensions[json, xml], server_basehttp://127.0.0.1:8000, upload_url/upload/labels, ))后端对应三个路由或者一个路由根据 URL 路径分流到不同子目录。我推荐后者因为保存逻辑完全一样只是SAVE_DIR不同。upload_dirs { /upload/images: /data/images, /upload/model: /data/models, /upload/labels: /data/labels, } app.post(/upload/{kind}) async def upload_by_kind(kind: str, request: Request): save_dir upload_dirs.get(f/upload/{kind}) if save_dir is None: return {ok: False, msg: unknown kind} form await request.form() uploaded None for v in form.values(): if isinstance(v, UploadFile): uploaded v break dest os.path.join(save_dir, os.path.basename(uploaded.filename)) os.makedirs(save_dir, exist_okTrue) with open(dest, wb) as f: shutil.copyfileobj(uploaded.file, f) return {ok: True, path: dest}upload_url里的/upload/images和/upload/model会被 Flet 完整地作为请求路径发出去后端用路径参数kind来分流即可。这种方式比三个独立路由少写很多重复代码也方便以后加新的上传类型。4. 多文件上传和进度反馈大文件场景的边界处理4.1 多选文件时的进度管理用字典维护每个文件的进度条多文件上传最烦的问题不是传不上去而是你不知道哪个文件传完了。Flet 的on_upload_progress回调会给你file_name和progress但你要自己维护一个映射表。我在模板里的做法是以文件名作为 key把每一行的ProgressBar控件引用存进字典。这里有个坑如果用户选了同名文件来自不同目录以文件名做 key 会冲突。稳妥的做法是选文件后先用uuid生成一个内部 ID传给FilePickerUploadFile的name字段显示时再展示原始文件名。import uuid import flet as ft internal_id uuid.uuid4().hex upload_files [] for f in e.files: upload_files.append(ft.FilePickerUploadFile( namef{internal_id}_{os.path.basename(f.name)}, pathf.path, )) fp.upload(upload_files)因为upload()发送的FilePickerUploadFile.name会作为上传后的文件名所以后端拿到uploaded.filename时会带上前缀。你可选择保留这个前缀做文件唯一化也可以在保存时去掉。我一般保留因为它本身就是防止重名覆盖的一层保障。进度回调里更新 UI 的写法要注意一点单个进度条的值变了调update()只刷新那个控件不要整个page.update()。多文件同时传的时候全局刷新会造成界面抖动大文件场景尤其明显。4.2 大文件上传的内存与超时问题Flet 的upload()在 Web 浏览器模式下文件会先由浏览器传到 Flet 服务端再转发到 FastAPI。这个过程对超大文件有一个隐蔽的坑Flet 服务端在转发前会把文件完整落在自己的临时目录如果临时目录挂在系统盘容量不够时大文件会直接失败而且失败信息很模糊。我的经验是超过 500MB 的文件不要依赖 Flet 的upload()通道改用先选文件拿到路径再用前端线程配合 requests 直传的方案。Flet 的upload()适合 200MB 以内的常规文件这个边界你要在项目需求评审时就确认清楚。如果确认走 Flet 上传FastAPI 侧要避免超时。默认情况下shutil.copyfileobj是同步阻塞的FastAPI 的异步接口里做同步磁盘读写会卡事件循环。文件小没关系文件大时会影响同一进程里的其他请求。import anyio app.post(/upload/{kind}) async def upload_by_kind(kind: str, request: Request): form await request.form() uploaded None for v in form.values(): if isinstance(v, UploadFile): uploaded v break dest os.path.join(/data, os.path.basename(uploaded.filename)) # 用 anyio 把阻塞的文件写入丢到线程池执行 await anyio.to_thread.run_sync(_save_file, uploaded, dest) return {ok: True} def _save_file(uploaded: UploadFile, dest: str): with open(dest, wb) as f: shutil.copyfileobj(uploaded.file, f)anyio.to_thread.run_sync是 FastAPI 生态里常用的同步转异步工具UploadFile.file本身是一个 SpooledTemporaryFilecopyfileobj从它读数据不会阻塞事件循环太长时间但写入大文件到磁盘是会阻塞的所以丢到线程池是稳妥做法。4.3 进度回显的 UI 组织和失败重试按钮进度条只做涨不做失败态是多数上传组件被吐槽难用的原因。我在模板里为每个文件维护一个三态对象等待中、上传中、完成或失败。Flet 的进度回调里没有直接的失败事件失败只能靠监听 HTTP 异常或者在后端返回非 200 时捕获。捕获上传失败的常见做法是包一层自定义回调。Flet 的 FilePicker 在 0.2x 版本里没有暴露上传失败的单独事件我一般通过后端返回体判断状态再让前端轮询一个确认接口。对内部工具来说轮询太重我改用回调 超时提示def _on_progress(self, e: ft.FilePickerUploadEvent): row self._progress_rows.get(e.file_name) if row is None: return bar row.controls[1] bar.value e.progress if e.progress 1.0: bar.color green row.controls[2].visible False # 隐藏重试按钮 row.update()每个进度行右侧放一个重试按钮初始隐藏。如果你自己在upload()外层做了异常捕获比如连接超时就把对应文件的重试按钮显示出来。重试时只需要重新构造一个FilePickerUploadFile调用upload()因为文件路径还在不需要重新选择文件。5. 避坑笔记Flet 文件上传最容易翻车的 5 个地方5.1 上传 404 且后端没有任何请求日志现象点击上传后 Flet 前端没有报错但后端日志里根本没有/upload的访问记录。原因base_url没设置。Flet 默认把请求发到它自己服务的地址upload_url里的/upload被解释为 Flet 服务端的路径后端自然收不到。解决给 FilePicker 设置base_url指向后端服务地址。检查方法是在后端加一行请求日志确认能收到请求后再调上传逻辑。5.2 保存出来的文件名带路径或者中文乱码现象后端保存后文件名变成images_20240101_102030.png和原始名不一致或者中文文件名变成一串百分号编码。原因客户端传来的filename可能是完整路径也可能做了 URL 编码。直接拿uploaded.filename拼接SAVE_DIR会把斜杠也拼进目录。解决用os.path.basename()取最后一段再用urllib.parse.unquote做一次解码。保存时如果不想依赖原始名可以用uuid4().hex 扩展名替代这是最省心的方案。5.3 Web 端拿到的 path 是虚拟路径不能直接当真实路径用现象在浏览器模式跑 Fleton_result里e.files[0].path看起来像一个临时文件路径但用 Python 的open()去读它提示文件不存在。原因Web 模式下文件路径是 Flet 服务端生成的虚拟引用不是浏览器所在机器的真实文件系统路径。桌面模式下path才是本机路径。解决统一走upload()管道不要试图在前端直接读path对应的文件内容。如果你需要在传文件前做本地预览用e.files[0].name做逻辑判断别依赖path。5.4 进度条全程 0 然后直接跳到 100%现象小文件上传时进度条一直不动传完瞬间跳到满格。原因on_upload_progress回调确实触发了但文件太小Flet 底层在一次数据块传输内就完成了传完中间没有发出中间态进度。解决这是正常现象不用修。如果你追求平滑的进度体验只能人为延迟 UI 更新比如进度到达 1.0 时强制先渲染 90%等确认接口返回后再跳 100%。对内部工具来说我认为不值得加这个逻辑小文件进度跳变反而让用户觉得快。5.5 allowed_extensions 只过滤了对话框没过滤后端现象前端设置了allowed_extensions[png]用户照样传了一个.exe上去后端还保存成功。原因allowed_extensions只是传给系统文件对话框的过滤器用户可以切换所有文件绕过而且恶意构造请求根本不经过前端。解决后端必须自己校验扩展名和 Content-Type。校验逻辑放在保存前不符合就返回 400不给落盘的机会。ALLOWED {png, jpg, pdf, zip} ext os.path.splitext(uploaded.filename or )[1].lstrip(.).lower() if ext not in ALLOWED: return {ok: False, msg: fextension .{ext} not allowed}5.6 同目录重名文件被静默覆盖现象第二次上传同名文件后第一次上传的文件不见了。原因保存路径直接用os.path.join(SAVE_DIR, filename)同名文件直接覆盖写。解决保存时加时间戳或者uuid前缀。更彻底的做法是保存后把最终路径存到数据库展示时读库不依赖文件系统目录。这个坑在内部工具里最容易被忽略因为测试时往往只传一次。6. 验证接口与进阶玩法从能传到传得稳6.1 用 curl 先验证后端接口排除前端干扰前后端联调前先用 curl 打一发后端接口。这样如果出问题你能立刻判断是前端的问题还是后端的问题不用两头猜。curl -X POST http://127.0.0.1:8000/upload \ -F file/tmp/test.png返回{ok: true, filename: test.png}就说明后端链条通了。如果 422检查 FastAPI 的请求体参数写法如果 405检查路由方法是不是 POST如果连接拒绝确认 FastAPI 进程还活着。这个流程我每次都会走一遍能省掉至少半小时的联调时间。6.2 进阶文件唯一化与后端软删除生产环境不要依赖原始文件名。我的习惯是在保存时重命名为{uuid4().hex}.{ext}把原始文件名、保存路径、上传时间、上传者写进一张upload_record表。前端展示时从库里读记录用户看到的还是原始文件名磁盘上则是唯一化后的文件。这个设计让后续做删除、迁移、权限控制都容易得多。CREATE TABLE upload_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, origin_name TEXT NOT NULL, stored_name TEXT NOT NULL, file_path TEXT NOT NULL, ext TEXT NOT NULL, size INTEGER NOT NULL, created_at TEXT DEFAULT (datetime(now)) );所谓的软删除就是删记录不删磁盘文件等定时任务统一清理。恢复误删文件时只需要把数据库里标记 deleted 的记录捞出来重新关联路径。6.3 进阶上传成功后回显缩略图文件上传完用户最想要的是立刻看到结果。图片类上传我习惯在页面里维护一个GridView把上传成功的图片缩略图回显出来。Flet 自带ft.Image支持网络路径后端把/static目录挂成静态文件服务即可。from fastapi.staticfiles import StaticFiles app.mount(/static, StaticFiles(directory/data), namestatic)前端在上传成功后把这个文件的访问路径拼出来追加到 GridView 的 controls 里。注意e.progress到 1.0 和文件真正落盘之间还有一点时间差最好后端返回成功 JSON 后再回显而不是看到进度到 100% 就立刻刷新。这是我踩过的一次小坑图片进度到 100% 但文件还没写完前端立刻加载图片显示一张损坏图。文件上传这个功能只要前后端边界划清楚模板化之后其实没有太多花活。我自己的习惯是凡是新的 Flet 项目文件上传一律从这个模板起步参数改一改就能用。这样做的好处是踩过的坑不会第二次踩新人也只要看配置对象就能理解上传器的行为。希望这篇笔记能帮你省掉摸索的时间早点把上传链路跑通。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →