Flet文件上传实战:从FilePicker到upload_dir完整落地
简介面向需要快速搭建文件上传系统的开发者这份Flet与FastAPI组合的自定义组件模板实现了前端多文件选择、上传进度实时显示以及后端自动接收保存的完整流程。资源包共5个文件其中3个Python脚本分别对应前端逻辑、上传进度处理与FastAPI后端接口1个txt说明文档用于环境配置与启动指引1个GIF动图可直观预览页面效果整体压缩包仅108KB轻量易上手。模板采用前后端分离架构通过环境变量FLET_SECRET_KEY保护应用安全目录结构清晰模块化设计便于二次扩展适用于文档管理、媒体库或个人云存储等场景。目前已有102人浏览学习对于正在使用Flet做桌面或Web界面又希望对接FastAPI实现文件上传的开发者而言是一份可快速复用并改造的实用参考。1. Flet 前后端一套代码上传文件到底怎么落地Flet 是用 Python 写 Flutter 界面的框架前端 UI、交互逻辑、后端文件落盘可以放在同一个工程里不用像 vuespringboot 那样拆两个项目、写两套接口。做内部数据平台、附件收集、运维日志上报这类“今天提需求明天就要上线”的工具Flet 是很顺手的选择。这篇我把 Flet 上传文件这条链路完整拆开FilePicker 选文件、page.upload 流式上传、服务端 upload_dir 落盘、归档目录保存再到把上传控件封装成自定义组件模板最后是前后端对接里最常见的几个坑。照着走一遍你手头那个“前端传文件、后端接住存下来”的需求就能直接落地。2. 先看懂 Flet 的上传机制FilePicker、page.upload 与落盘路径2.1 FilePicker 是唯一的文件入口控件初始化与事件绑定Flet 的前端控件里没有原生input typefile这种东西所有文件选择都要通过FilePicker这个控件完成。它跟网页里的 upload 控件长得不一样本质上是一个对话框由你在按钮点击事件里主动调起。import flet as ft def main(page: ft.Page): # 初始化 FilePickeron_result 是用户选完文件后的回调 picker ft.FilePicker(on_resultlambda e: print(e.files)) # 按钮点击时把 picker 挂到页面 overlay 再调起对话框 def open_picker(e): page.overlay.append(picker) page.update() picker.pick_files( allow_multipleTrue, allowed_extensions[jpg, png, pdf], dialog_title请选择要上传的文件 ) page.add(ft.ElevatedButton(选择文件, on_clickopen_picker)) ft.app(targetmain)这里的逻辑分三步先建FilePicker并绑定on_result再把 picker 挂进page.overlay最后调用pick_files打开系统对话框。on_result回调里能拿到一个结果对象里面最关键的是files列表每个元素有name、size和path三个属性。参数层面有几个容易踩的地方。allow_multipleFalse时单选True时多选allowed_extensions控制可选文件类型但不同 Flet 版本对扩展名的格式要求不一致有的要jpg不带点有的要.jpg带点后面避坑章节会专门说。dialog_title是对话框标题不传就是系统默认文本在 Windows 和 Linux 上显示的中文可能有字体问题建议做工具类应用时传英文标题。on_result在用户点了“取消”时也会触发此时e.files是None不是空列表代码里一定要判空否则直接去遍历e.files就会抛TypeError。这是我每次写 Flet 上传都会顺手写的防御逻辑。2.2 文件从浏览器到 Pythonpage.upload 与 upload_dirFilePicker 只是选文件真正把文件内容送到后端的是page.upload。这个方法做的事情是把文件从客户端流式上传到服务端的指定目录服务端不需要你有任何额外的表单解析、multipart 处理代码因为 Flet 已经把这层封装在框架内部了。import flet as ft def main(page: ft.Page): picker ft.FilePicker(on_resulton_upload_result) progress ft.ProgressBar(value0, visibleFalse) 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_callbackon_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(targetmain, upload_diruploads)这段代码最核心的一点ft.app(targetmain, upload_diruploads)启动时指定的upload_dir就是所有上传文件的服务端落盘位置。page.upload的第二个参数/upload/{f.name}只是上传路由的标识告诉 Flet 这是一次文件上传请求它并不映射到你服务器的真实路径真正写文件的位置始终是upload_dir。upload_progress_callback会在上传过程中多次触发回调对象里有file_name和progressprogress范围是 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_multipleTrue, allowed_extensionsNone, max_size_mb100, on_upload_doneNone, ): 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_resultself._handle_pick_result) self.upload_btn ft.ElevatedButton(label, iconft.icons.UPLOAD_FILE) self.status_text ft.Text(未选择文件, size12) self.progress_bar ft.ProgressBar(value0, visibleFalse) 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, ], spacing8, ) 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_multipleself.allow_multiple, allowed_extensionsNone 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_callbackself._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_multipleFalse、allowed_extensions[pdf]数据平台批量导入 Excelallow_multipleTrue、allowed_extensions[xlsx, xls]、max_size_mb20日志收集工具则干脆放开扩展名只限制大小。参数设计上有两个细节值得注意。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_multipleTrue, allowed_extensions[xlsx, xls], max_size_mb20, on_upload_doneon_file_uploaded, ) page.add(upload_zone) ft.app(targetmain, upload_diruploads)用起来跟 Flet 内置控件没有区别页面上就是一个Column。想要多个上传入口就实例化多个FileUploadZone各自配自己的参数和回调互不干扰。这种自定义组件模板的方式比每个页面重复写 FilePicker 逻辑省太多事了尤其是公司内部有多个工具都要用上传功能时组件库就是靠这种方式一点点沉淀出来的。4. 后端接收与保存从 upload_dir 到业务归档目录的完整代码4.1 目录结构设计临时区、归档区、配置隔离文件上传到服务端后upload_dir只是一个临时堆放区真实的业务文件必须存到结构化的归档目录里。我的标准目录设计是data/tmp对应upload_dirdata/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_extsNone, max_sizeNone) - 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_okTrue) 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_okTrue) return {ok: False, msg: f扩展名 {ext} 不在白名单中} # 4. 按日期归档生成不重复的文件名 today datetime.now().strftime(%Y%m%d) dest_dir STORE_ROOT / today dest_dir.mkdir(parentsTrue, exist_okTrue) # 用 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]}), bgcolorft.colors.GREEN_400, ) else: page.snack_bar ft.SnackBar( ft.Text(f保存失败: {result[msg]}), bgcolorft.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 坑 1Web 模式下拿 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 坑 4upload_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(parentsTrue, exist_okTrue) 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_sizechunk_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 里硬套前后端分离的思路它本质上是“一套代码跑通前端和后端”顺着这个思路走文件上传反而比 vuespringboot 那一套省事得多。希望这篇能帮你在自己的项目里少踩几个坑。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →