☰
Flet文件上传实战:从FilePicker到upload_dir完整落地
2026/10/2 14:59:29 网站建设 项目流程

简介:面向需要快速搭建文件上传系统的开发者,这份Flet与FastAPI组合的自定义组件模板,实现了前端多文件选择、上传进度实时显示,以及后端自动接收保存的完整流程。资源包共5个文件,其中3个Python脚本分别对应前端逻辑、上传进度处理与FastAPI后端接口,1个txt说明文档用于环境配置与启动指引,1个GIF动图可直观预览页面效果,整体压缩包仅108KB,轻量易上手。模板采用前后端分离架构,通过环境变量FLET_SECRET_KEY保护应用安全,目录结构清晰,模块化设计便于二次扩展,适用于文档管理、媒体库或个人云存储等场景。目前已有102人浏览学习,对于正在使用Flet做桌面或Web界面,又希望对接FastAPI实现文件上传的开发者而言,是一份可快速复用并改造的实用参考。

1. Flet 前后端一套代码:上传文件到底怎么落地

Flet 是用 Python 写 Flutter 界面的框架,前端 UI、交互逻辑、后端文件落盘可以放在同一个工程里,不用像 vue+springboot 那样拆两个项目、写两套接口。做内部数据平台、附件收集、运维日志上报这类“今天提需求明天就要上线”的工具,Flet 是很顺手的选择。这篇我把 Flet 上传文件这条链路完整拆开:FilePicker 选文件、page.upload 流式上传、服务端 upload_dir 落盘、归档目录保存,再到把上传控件封装成自定义组件模板,最后是前后端对接里最常见的几个坑。照着走一遍,你手头那个“前端传文件、后端接住存下来”的需求就能直接落地。

2. 先看懂 Flet 的上传机制:FilePicker、page.upload 与落盘路径

2.1 FilePicker 是唯一的文件入口:控件初始化与事件绑定

Flet 的前端控件里没有原生<input type="file">这种东西,所有文件选择都要通过FilePicker这个控件完成。它跟网页里的 upload 控件长得不一样,本质上是一个对话框,由你在按钮点击事件里主动调起。

import flet as ft def main(page: ft.Page): # 初始化 FilePicker,on_result 是用户选完文件后的回调 picker = ft.FilePicker(on_result=lambda e: print(e.files)) # 按钮点击时把 picker 挂到页面 overlay 再调起对话框 def open_picker(e): page.overlay.append(picker) page.update() picker.pick_files( allow_multiple=True, allowed_extensions=["jpg", "png", "pdf"], dialog_title="请选择要上传的文件" ) page.add(ft.ElevatedButton("选择文件", on_click=open_picker)) ft.app(target=main)

这里的逻辑分三步:先建FilePicker并绑定on_result,再把 picker 挂进page.overlay,最后调用pick_files打开系统对话框。on_result回调里能拿到一个结果对象,里面最关键的是files列表,每个元素有name、size和path三个属性。

参数层面有几个容易踩的地方。allow_multiple=False时单选,True时多选;allowed_extensions控制可选文件类型,但不同 Flet 版本对扩展名的格式要求不一致,有的要"jpg"不带点,有的要".jpg"带点,后面避坑章节会专门说。dialog_title是对话框标题,不传就是系统默认文本,在 Windows 和 Linux 上显示的中文可能有字体问题,建议做工具类应用时传英文标题。

on_result在用户点了“取消”时也会触发,此时e.files是None,不是空列表,代码里一定要判空,否则直接去遍历e.files就会抛TypeError。这是我每次写 Flet 上传都会顺手写的防御逻辑。

2.2 文件从浏览器到 Python:page.upload 与 upload_dir

FilePicker 只是选文件,真正把文件内容送到后端的是page.upload。这个方法做的事情是把文件从客户端流式上传到服务端的指定目录,服务端不需要你有任何额外的表单解析、multipart 处理代码,因为 Flet 已经把这层封装在框架内部了。

import flet as ft def main(page: ft.Page): picker = ft.FilePicker(on_result=on_upload_result) progress = ft.ProgressBar(value=0, visible=False) def on_upload(e: ft.FilePickerResultEvent): if not e.files: return for f in e.files: page.upload( f, f"/upload/{f.name}", upload_progress_callback=on_upload_progress ) def on_upload_progress(e: ft.FilePickerUploadEvent): progress.value = e.progress progress.visible = True page.update() if e.progress == 1.0: # 此时文件已经落盘到 upload_dir pass page.add(picker, progress) # 启动时指定 upload_dir,这是服务端保存上传文件的临时目录 ft.app(target=main, upload_dir="uploads")

这段代码最核心的一点:ft.app(target=main, upload_dir="uploads")启动时指定的upload_dir就是所有上传文件的服务端落盘位置。page.upload的第二个参数"/upload/{f.name}"只是上传路由的标识,告诉 Flet 这是一次文件上传请求,它并不映射到你服务器的真实路径,真正写文件的位置始终是upload_dir。

upload_progress_callback会在上传过程中多次触发,回调对象里有file_name和progress,progress范围是 0 到 1.0。当进度到 1.0 时,文件已经完整写到服务端upload_dir,你可以在这时候做后续处理,比如移动文件、写数据库记录。注意不要在on_result里立刻去服务端找文件,因为page.upload是异步的,选完文件不代表上传完成。

2.3 内存放还是磁盘放:不同运行模式的处理策略

Flet 有两种运行形态,桌面应用和 Web 应用,这两种形态下文件处理的策略完全不同。桌面模式跑在本地,FilePicker返回的path就是本机真实路径,服务端的 Python 代码可以直接用Path(path)读写,不需要走page.upload。Web 模式就麻烦了,前端跑在浏览器里,path是浏览器沙箱里的临时路径,服务端 Python 进程根本访问不到,必须走page.upload把文件拉到服务端。

运行模式FilePicker 拿到的 path推荐做法
桌面应用本机真实路径直接用 Path 读取,或统一走 page.upload 保持代码一致
Web 应用浏览器沙箱临时路径必须走 page.upload,服务端从 upload_dir 取文件
Web 应用部署到服务器远程浏览器路径page.upload + upload_dir,服务端再归档

我的习惯是即使在桌面模式也走page.upload,因为开发环境和生产环境的行为一致,不会出现“本地好好的,部署到服务器就找不到文件”的诡异现象。upload_dir只当临时缓冲区用,文件落盘后要尽快移动到业务归档目录,不要让upload_dir长期堆积文件,否则重启应用时这些临时文件没人清理,磁盘迟早被撑爆。

3. 把上传控件封装成自定义组件模板:参数、事件与复用

3.1 组件模板的结构:UI、参数、回调三件事分开管

Flet 官方推荐的组件复用方式是继承ft.UserControl,把 UI 构建、状态维护、事件回调全部封装在一个类里。自建上传组件模板时,我习惯把组件拆成三个层面:UI 层管长得什么样,参数层管行为怎么配,回调层管业务代码怎么接。

import flet as ft class FileUploadZone(ft.UserControl): """ 可复用的文件上传组件模板 参数说明: label: 按钮显示文本 allow_multiple: 是否多选 allowed_extensions: 文件类型白名单,如 ["pdf", "docx"] max_size_mb: 单个文件大小上限,单位 MB on_upload_done: 上传完成后的回调,参数为 (file_name, remote_path) """ def __init__( self, label="上传文件", allow_multiple=True, allowed_extensions=None, max_size_mb=100, on_upload_done=None, ): super().__init__() self.label = label self.allow_multiple = allow_multiple self.allowed_extensions = allowed_extensions or ["*"] self.max_size_mb = max_size_mb self.on_upload_done = on_upload_done # UI 控件在 __init__ 里创建,但不要在这里 page.add self.picker = ft.FilePicker(on_result=self._handle_pick_result) self.upload_btn = ft.ElevatedButton(label, icon=ft.icons.UPLOAD_FILE) self.status_text = ft.Text("未选择文件", size=12) self.progress_bar = ft.ProgressBar(value=0, visible=False) self._progress = {} def build(self): # build 返回的是组件渲染的控件树 self.upload_btn.on_click = self._open_picker return ft.Column( [ ft.Row([self.upload_btn, self.status_text]), self.progress_bar, ], spacing=8, ) def _open_picker(self, e): if self.page is None: return # FilePicker 必须挂在 overlay 上才能被调起 self.page.overlay.append(self.picker) self.page.update() self.picker.pick_files( allow_multiple=self.allow_multiple, allowed_extensions=None if self.allowed_extensions == ["*"] else self.allowed_extensions, dialog_title="请选择要上传的文件", ) def _handle_pick_result(self, e: ft.FilePickerResultEvent): if not e.files: self.status_text.value = "已取消选择" self.page.update() return self.status_text.value = f"已选择 {len(e.files)} 个文件,正在上传..." self.progress_bar.visible = True self.page.update() for f in e.files: if f.size > self.max_size_mb * 1024 * 1024: self.status_text.value = f"{f.name} 超过大小上限 {self.max_size_mb}MB" self.page.update() continue self.page.upload( f, f"/upload/{f.name}", upload_progress_callback=self._handle_upload_progress, ) def _handle_upload_progress(self, e: ft.FilePickerUploadEvent): self._progress[e.file_name] = e.progress self.progress_bar.value = e.progress self.status_text.value = f"{e.file_name}: {int(e.progress * 100)}%" self.page.update() if e.progress == 1.0 and self.on_upload_done: # 此时服务端 upload_dir 里已经有这个文件了 self.on_upload_done(e.file_name)

这个组件模板做到了几点:__init__里只存参数、创建控件,UI 由build统一构建;FilePicker在点击时才挂到 overlay,避免重复挂载导致弹窗叠加;进度回调按文件名记录进度,因为多文件上传时回调可能交错触发,单用一个变量会显示错乱。on_upload_done把业务逻辑从组件里剥离出去,页面拿到文件名后自己决定往哪个目录归档。

3.2 参数模板该怎么设计:从单文件到多文件的场景覆盖

FileUploadZone的参数不是拍脑袋定的,来自我实际做过的几个场景。内部 OA 系统传单个附件,allow_multiple=False、allowed_extensions=["pdf"];数据平台批量导入 Excel,allow_multiple=True、allowed_extensions=["xlsx", "xls"]、max_size_mb=20;日志收集工具则干脆放开扩展名,只限制大小。

参数设计上有两个细节值得注意。allowed_extensions传None表示不限制类型,但 Flet 的pick_files对None和空列表的解释不同,空列表可能直接让对话框里所有文件都不可选,所以组件内部把None统一转成["*"],在调用pick_files时再转回None。max_size_mb这个校验必须在两端同时做,前端拦一次给用户即时反馈,后端保存前再拦一次防止绕过前端直接调接口。

事件回调的参数不要设计成对象引用,直接传file_name字符串最稳。因为e.files里的文件对象是临时的,上传完成后如果还抱着引用不放,某些 Flet 版本会触发内存回收问题,虽然概率不高,但传字符串能规避整个这一类坑。

3.3 在页面里使用组件:一处封装、多处复用

组件模板封装好之后,页面代码变得很干净。你需要做的只是实例化组件、传参数、挂回调,然后把组件add到页面里。

import flet as ft def main(page: ft.Page): page.title = "数据导入工具" def on_file_uploaded(file_name: str): # 这里只拿到文件名,真正的文件已经在服务端 upload_dir 里 page.snack_bar = ft.SnackBar(ft.Text(f"{file_name} 已上传")) page.snack_bar.open = True page.update() upload_zone = FileUploadZone( label="上传 Excel", allow_multiple=True, allowed_extensions=["xlsx", "xls"], max_size_mb=20, on_upload_done=on_file_uploaded, ) page.add(upload_zone) ft.app(target=main, upload_dir="uploads")

用起来跟 Flet 内置控件没有区别,页面上就是一个Column。想要多个上传入口就实例化多个FileUploadZone,各自配自己的参数和回调,互不干扰。这种自定义组件模板的方式比每个页面重复写 FilePicker 逻辑省太多事了,尤其是公司内部有多个工具都要用上传功能时,组件库就是靠这种方式一点点沉淀出来的。

4. 后端接收与保存:从 upload_dir 到业务归档目录的完整代码

4.1 目录结构设计:临时区、归档区、配置隔离

文件上传到服务端后,upload_dir只是一个临时堆放区,真实的业务文件必须存到结构化的归档目录里。我的标准目录设计是data/tmp对应upload_dir,data/store对应归档区,归档区按日期分目录,避免所有文件堆在一个文件夹里越来越难维护。

project_root/ ├── app.py # Flet 应用主入口 ├── data/ │ ├── tmp/ # upload_dir,上传文件的临时落盘位置 │ └── store/ │ ├── 20260412/ # 按日期归档 │ ├── 20260413/ │ └── ... └── config.py # 路径和大小限制配置

config.py单独抽出来,不要把这些路径硬编码在业务代码里。因为upload_dir在ft.app()启动时就要指定,业务代码里也要引用同一个目录,两边要是各写一个路径字符串,改配置时漏改一处就是事故。

4.2 后端保存函数:校验、重命名、落盘

on_upload_done回调触发时,文件已经在upload_dir里了。后端要做的事就是:校验文件是否完整、检查扩展名和大小、移动文件到归档目录并重命名。

import shutil import uuid from pathlib import Path from datetime import datetime # 这个路径必须和 ft.app(upload_dir=...) 保持一致 TMP_UPLOAD_DIR = Path("data/tmp") STORE_ROOT = Path("data/store") # 允许的扩展名白名单,空集合表示不限制 ALLOWED_EXTS = {"png", "jpg", "pdf", "xlsx", "xls", "docx"} # 单个文件大小上限 50MB MAX_FILE_SIZE = 50 * 1024 * 1024 def save_uploaded_file(file_name: str, allowed_exts=None, max_size=None) -> dict: """ 将 upload_dir 下的文件校验并归档到 store 目录 file_name: 上传落盘后的文件名,注意可能是 URL 编码形式 """ exts = ALLOWED_EXTS if allowed_exts is None else allowed_exts size_limit = MAX_FILE_SIZE if max_size is None else max_size # 1. 先做 URL 解码,再取文件名 src_name = unquote(file_name) src = TMP_UPLOAD_DIR / src_name if not src.exists(): # 上传可能失败,或者 upload_dir 配置不一致 return {"ok": False, "msg": f"临时文件不存在: {src_name}"} # 2. 大小校验 if src.stat().st_size > size_limit: src.unlink(missing_ok=True) return {"ok": False, "msg": f"文件超过大小限制: {src.stat().st_size} bytes"} # 3. 扩展名白名单校验 ext = src.suffix.lower().lstrip(".") if exts and ext not in exts: src.unlink(missing_ok=True) return {"ok": False, "msg": f"扩展名 {ext} 不在白名单中"} # 4. 按日期归档,生成不重复的文件名 today = datetime.now().strftime("%Y%m%d") dest_dir = STORE_ROOT / today dest_dir.mkdir(parents=True, exist_ok=True) # 用 uuid 重命名,保留原始扩展名 final_name = f"{uuid.uuid4().hex}.{ext}" dest = dest_dir / final_name # 5. 移动文件并返回结果 shutil.move(str(src), str(dest)) return { "ok": True, "saved_path": str(dest), "saved_name": final_name, "size": dest.stat().st_size, }

这段代码有五个关键点。第一步unquote很重要,客户端上传时如果对文件名做了 URL 编码,服务端落盘的文件名可能保留编码形式,unquote之后才能找到真实文件。校验失败时顺手unlink删除临时文件,否则upload_dir会积累垃圾。扩展名校验用lstrip(".")是为了同时兼容".pdf"和"pdf"两种写法。归档目录按日期分,每天一个文件夹,查找和清理都方便。文件名用uuid.uuid4().hex重命名,彻底规避中文名、特殊字符、重名覆盖的问题,原始文件名如果想保留,可以写进数据库记录,而不是直接用在文件系统里。

4.3 把保存结果回传给前端:状态更新与错误提示

后端保存函数的结果要回到界面上,用户才能知道上传是成功还是失败。我在组件里预留的on_upload_done只负责通知“文件已到服务端”,真实的保存结果应该通过页面自己的回调链处理。

def handle_upload_done(file_name: str): result = save_uploaded_file(file_name) if result["ok"]: page.snack_bar = ft.SnackBar( ft.Text(f"保存成功: {result['saved_name']}"), bgcolor=ft.colors.GREEN_400, ) else: page.snack_bar = ft.SnackBar( ft.Text(f"保存失败: {result['msg']}"), bgcolor=ft.colors.RED_400, ) page.snack_bar.open = True page.update()

这里要注意一个时序问题:on_upload_done触发时文件才刚写完upload_dir,如果立即去访问归档目录里的最终文件,会失败。正确做法是回调里调用save_uploaded_file,把临时文件移动过去,再刷新界面。不要尝试把“保存”放到on_upload_done的异步线程里做,Flet 的 UI 更新必须回到主线程,直接用回调链路保持同步最省心。

5. 避坑排查:前后端对接的 4 个典型坑与修复

5.1 坑 1:Web 模式下拿 path 直接读写文件,服务端永远找不到

现象:本地桌面模式跑得好好的,部署成 Web 模式后,选完文件后端立刻报FileNotFoundError,日志里显示path指向一个完全不存在的目录。

原因:桌面模式下FilePicker返回的path是本机真实路径,服务端就在本机,读写没问题。Web 模式下前端在用户浏览器里,path是浏览器沙箱分配的临时路径,服务端 Python 进程根本没有权限访问这个路径,甚至这个路径在遥远的用户电脑上。

解决:判断你自己的运行形态。只要部署成 Web 服务,一律放弃path直读,改用page.upload把文件上传到服务端upload_dir。我后来在组件里直接统一走page.upload,不再判断path是否可用,反而省了很多分支逻辑。

5.2 坑 2:中文文件名、空格、# 号让上传 URL 解析错乱

现象:上传“项目报告(终版).pdf”这类文件名,服务端upload_dir里出现%E9%A1%B9%E7%9B%AE%E6%8A%A5%E5%91%8A乱码文件名,或者#后面的内容被截断。

原因:page.upload的第二个参数是 URL 形式的路径,中文和特殊字符没有做 URL 编码,服务端解析时按原始字节处理,导致文件名错乱。

解决:上传前用urllib.parse.quote对文件名编码。更彻底的做法是上传时就不要用原始文件名,直接用uuid重命名,这样服务端落盘文件名永远是 ASCII 字符,后续保存、归档、下载都干净。

import uuid from urllib.parse import quote def build_upload_url(file_name: str) -> str: # 用 uuid 做服务端文件名,避免中文和特殊字符问题 remote_name = f"{uuid.uuid4().hex}_{file_name}" return f"/upload/{quote(remote_name)}"

5.3 坑 3:大文件没有进度反馈,界面像卡死

现象:点击上传后,按钮没反应,界面停在那里,过了几十秒才突然完成。传输期间用户以为程序崩溃了,频繁点击按钮,结果同一个文件被上传多次。

原因:page.upload是流式上传,文件一直在传,但 UI 没有收到任何进度信息,用户无法感知传输状态。upload_progress_callback不绑定就是默认行为,网页和大文件场景下体验极差。

解决:必须在page.upload里传upload_progress_callback,回调里更新ProgressBar和状态文本。如果文件超过 1GB,建议参考worker 上传大文件的思路,考虑分片上传,这个我放在下一章展开。

5.4 坑 4:upload_dir 当成永久存储,同名文件互相覆盖

现象:连续上传两个同名文件,后一个把前一个覆盖了,数据库里两条记录指向同一个文件路径。

原因:upload_dir按文件名落盘,同名文件自然落到同一个路径。更隐蔽的是,两个用户在同一秒上传同名文件,Flet 内部可能加了后缀避免覆盖,但你拿到的文件名跟落盘文件名对不上,导致归档时拿错文件。

解决:两条规则。第一,upload_dir只是临时区,上传完成立刻归档,归档时用uuid重命名,彻底消灭同名问题。第二,归档完成后立即清理upload_dir里的临时文件,不要在临时区保留任何业务文件。我把清理逻辑放在save_uploaded_file成功分支里,移动完成后临时文件自然就没了。

6. 进阶:大文件分片、安全校验与上传链路的自动化验证

6.1 大文件上传:依赖内置流式还是自己做分片

Flet 的page.upload本身是流式的,传输过程不占内存,对 500MB 以内的文件,直接传没有任何问题。但超过 1GB 的文件,单次上传的体验就很差了,没有断点续传,网络一抖整个文件重来。

我的做法是分两种情况。Web 模式部署时,不要自己造分片轮子,Flet 的流式上传配合进度条已经能覆盖绝大多数场景,分片意味着要改 Flet 内部的传输协议,成本高收益低。桌面模式就灵活多了,直接用 Python 读取源文件,按 4MB 切片写入目标路径,失败重试只需要重传当前片。

def copy_large_file_chunked(src: str, dst: str, chunk_size: int = 4 * 1024 * 1024): """大文件分块复制,适合桌面模式下本地文件归档""" src_path = Path(src) dst_path = Path(dst) dst_path.parent.mkdir(parents=True, exist_ok=True) with open(src_path, "rb") as r, open(dst_path, "wb") as w: while True: chunk = r.read(chunk_size) if not chunk: break w.write(chunk) return dst_path.stat().st_size

chunk_size取 4MB 是磁盘读写和内存占用之间的折中,太大浪费内存,太小增加 IO 次数。这个函数在桌面模式下可以直接替换shutil.move,对超大文件更友好。

6.2 三层安全校验:类型、大小、后缀白名单

安全校验是上传功能里绝对不能省的一环。我在save_uploaded_file里已经写了大小和扩展名两层校验,实际生产环境要三层:前端拦截给用户反馈,后端保存前校验防绕过,归档后再做一次内容级校验。第三层不是每个项目都需要,但如果是接收用户上传的脚本文件、压缩包,建议用 Python 读文件头做 magic number 校验,防止改后缀绕过白名单。

校验层级校验内容手段
前端扩展名、大小上限FilePicker 的 allowed_extensions + max_size_mb
后端入口扩展名白名单、文件大小save_uploaded_file 中的 exts 和 size_limit
归档后文件头 magic number读取前几个字节判断真实类型

自动化验证方面,我会把保存函数单独抽出来,写一个不依赖 Flet UI 的单元测试,模拟文件写入upload_dir,然后调save_uploaded_file断言归档成功。这样每次改代码跑一次测试,就能确认上传链路没被破坏。如果你想做接口级压测,也可以用 jmeter 模拟整条上传流程,不过 Flet 的上传端点走的是框架内部协议,比直接压 multipart 接口复杂,一般只验证单文件上传逻辑,不做高并发测试。

我个人的习惯是:上传组件模板沉淀到公司内部组件库后,每接手一个新项目,复制模板改三个参数就能上线。这个方案我用了小半年,最大的心得就是别在 Flet 里硬套前后端分离的思路,它本质上是“一套代码跑通前端和后端”,顺着这个思路走,文件上传反而比 vue+springboot 那一套省事得多。希望这篇能帮你在自己的项目里少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询