Flet 中级指南

最后更新:2026-03-06 继承已有控件,修改默认样式: 继承容器控件,封装复合 UI: animate 参数说明 常用 AnimationCurve: LINEAR EASE EASE_IN EASE_OUT EASE_IN_OUT BOUNCE_IN BOUNCE_OUT ELASTIC_IN ELASTIC_OUT 路由参数用 URL query string 传递,不用全局变量:通过 page.route(如 /user?id=42)传递页面间数据,而非将数据存到模块级变量。URL 方式支持浏览器历史、书签和刷新恢复,全局变量在多用户 Web

分享

官方文档:https://docs.flet.dev/
适用版本:Flet 0.82+(2026-05-08 核实)

最后更新:2026-03-06

本文全程使用 Flet 0.82+ 声明式语法


一、@ft.observable 深入

嵌套 observable

import flet as ft
from dataclasses import dataclass, field

@ft.observable
@dataclass
class Address:
    city: str = ""
    country: str = ""

@ft.observable
@dataclass
class User:
    name: str = ""
    email: str = ""
    address: Address = field(default_factory=Address)

    def update_name(self, name: str):
        self.name = name

    def move_to(self, city: str, country: str):
        self.address.city = city       # 嵌套 observable 的修改也会触发更新
        self.address.country = country

@ft.component
def UserProfile(user: User) -> ft.Control:
    return ft.Column([
        ft.Text(f"姓名:{user.name}"),
        ft.Text(f"城市:{user.address.city}"),      # 嵌套字段也是响应式的
        ft.Text(f"国家:{user.address.country}"),
    ])

observable 列表操作

@ft.observable
@dataclass
class TaskList:
    tasks: list = field(default_factory=list)
    filter: str = "all"    # "all" | "active" | "done"

    def add(self, text: str):
        self.tasks.append({"id": len(self.tasks), "text": text, "done": False})

    def toggle(self, task_id: int):
        for t in self.tasks:
            if t["id"] == task_id:
                t["done"] = not t["done"]
        # 列表内部对象修改后需要手动通知(触发 tasks 字段的更新)
        self.tasks = list(self.tasks)   # 重新赋值触发更新

    def remove(self, task_id: int):
        self.tasks = [t for t in self.tasks if t["id"] != task_id]

    @property
    def visible_tasks(self):
        if self.filter == "active":
            return [t for t in self.tasks if not t["done"]]
        if self.filter == "done":
            return [t for t in self.tasks if t["done"]]
        return self.tasks

二、自定义控件

@ft.control — 样式化控件

继承已有控件,修改默认样式:

@ft.control
class PrimaryButton(ft.ElevatedButton):
    """统一主色按钮,圆角更大"""
    style: ft.ButtonStyle = field(default_factory=lambda: ft.ButtonStyle(
        shape=ft.RoundedRectangleBorder(radius=20),
        bgcolor=ft.Colors.BLUE_600,
        color=ft.Colors.WHITE,
        padding=ft.Padding(left=24, right=24, top=12, bottom=12),
    ))

@ft.control
class DangerButton(ft.ElevatedButton):
    style: ft.ButtonStyle = field(default_factory=lambda: ft.ButtonStyle(
        bgcolor=ft.Colors.RED_600,
        color=ft.Colors.WHITE,
    ))

# 使用
PrimaryButton("确认", on_click=lambda _: ...)
DangerButton("删除", on_click=lambda _: ...)

@ft.control — 组合控件

继承容器控件,封装复合 UI:

@ft.control
class Card(ft.Container):
    """统一卡片样式"""
    padding: ft.Padding = field(default_factory=lambda: ft.Padding(16, 16, 16, 16))
    border_radius: ft.BorderRadius = field(default_factory=lambda: ft.border_radius.all(12))
    bgcolor: str = ft.Colors.SURFACE
    shadow: ft.BoxShadow = field(default_factory=lambda: ft.BoxShadow(
        blur_radius=8,
        color=ft.Colors.BLACK12,
        offset=ft.Offset(0, 2),
    ))

@ft.control
class Avatar(ft.CircleAvatar):
    """带默认图标的头像控件"""
    radius: float = 24
    bgcolor: str = ft.Colors.BLUE_100
    color: str = ft.Colors.BLUE_600

    def build(self):
        if not self.foreground_image_url:
            self.content = ft.Icon(ft.Icons.PERSON, size=self.radius)
        return super().build()

# 使用
Card(
    content=ft.Column([
        Avatar(foreground_image_url="https://example.com/avatar.jpg"),
        ft.Text("Alice"),
    ])
)

三、路由与导航

基础路由配置

import flet as ft
from dataclasses import dataclass, field

# 页面组件
@ft.component
def HomePage() -> ft.Control:
    return ft.Column([
        ft.Text("首页", size=24, weight=ft.FontWeight.BOLD),
        ft.ElevatedButton(
            "去用户列表",
            on_click=lambda e: e.page.go("/users"),
        ),
    ])

@ft.component
def UserListPage() -> ft.Control:
    users = ["Alice", "Bob", "Carol"]
    return ft.Column([
        ft.Text("用户列表", size=24),
        ft.ListView([
            ft.ListTile(
                title=ft.Text(name),
                on_click=lambda e, n=name: e.page.go(f"/users/{n}"),
            )
            for name in users
        ]),
        ft.TextButton("返回", on_click=lambda e: e.page.go("/")),
    ])

@ft.component
def UserDetailPage(name: str) -> ft.Control:
    return ft.Column([
        ft.Text(f"用户:{name}", size=24),
        ft.TextButton("返回列表", on_click=lambda e: e.page.go("/users")),
    ])

# 路由配置
def main(page: ft.Page):
    page.title = "路由示例"

    def route_change(e: ft.RouteChangeEvent):
        route = e.route

        if route == "/":
            page.render(HomePage)
        elif route == "/users":
            page.render(UserListPage)
        elif route.startswith("/users/"):
            name = route.split("/")[-1]
            page.render(lambda: UserDetailPage(name=name))
        else:
            page.render(NotFoundPage)

    page.on_route_change = route_change
    page.go(page.route or "/")

ft.app(main)

带参数的路由解析

import re

def parse_route(route: str) -> tuple[str, dict]:
    """解析路由,返回 (路由名, 参数字典)"""
    patterns = [
        (r"^/$", "home", {}),
        (r"^/users$", "user_list", {}),
        (r"^/users/(\d+)$", "user_detail", lambda m: {"user_id": int(m.group(1))}),
        (r"^/posts/(\d+)/comments$", "post_comments", lambda m: {"post_id": int(m.group(1))}),
    ]
    for pattern, name, params_fn in patterns:
        m = re.match(pattern, route)
        if m:
            params = params_fn(m) if callable(params_fn) else params_fn
            return name, params
    return "not_found", {}

def main(page: ft.Page):
    def route_change(e: ft.RouteChangeEvent):
        name, params = parse_route(e.route)
        match name:
            case "home":
                page.render(HomePage)
            case "user_list":
                page.render(UserListPage)
            case "user_detail":
                page.render(lambda: UserDetailPage(**params))
            case _:
                page.render(NotFoundPage)

    page.on_route_change = route_change
    page.go(page.route or "/")

四、主题与样式

配置应用主题

def main(page: ft.Page):
    page.title = "主题示例"
    page.theme_mode = ft.ThemeMode.LIGHT   # LIGHT / DARK / SYSTEM

    # 自定义主题
    page.theme = ft.Theme(
        color_scheme_seed=ft.Colors.INDIGO,   # 种子色,自动生成色系
        use_material3=True,
        font_family="Roboto",
    )

    # 暗色主题单独配置
    page.dark_theme = ft.Theme(
        color_scheme_seed=ft.Colors.INDIGO,
        use_material3=True,
    )

    page.render(MyApp)

主题色常量

# Colors 常量(Material Design 色板)
ft.Colors.PRIMARY          # 主色
ft.Colors.SECONDARY        # 辅色
ft.Colors.SURFACE          # 表面色
ft.Colors.BACKGROUND       # 背景色
ft.Colors.ON_PRIMARY       # 主色上的文字色
ft.Colors.ERROR            # 错误色

# 具体颜色(带深度)
ft.Colors.BLUE             # 标准蓝
ft.Colors.BLUE_100         # 浅蓝 100
ft.Colors.BLUE_600         # 深蓝 600
ft.Colors.BLUE_ACCENT_400  # 强调蓝

# 透明度
ft.Colors.with_opacity(0.5, ft.Colors.BLACK)   # 50% 黑色

主题感知组件

@ft.component
def ThemedCard(title: str, content: str) -> ft.Control:
    """自动跟随主题色的卡片"""
    return ft.Container(
        content=ft.Column([
            ft.Text(title, size=18, weight=ft.FontWeight.BOLD,
                    color=ft.Colors.PRIMARY),           # 使用主题主色
            ft.Text(content, color=ft.Colors.ON_SURFACE_VARIANT),
        ]),
        bgcolor=ft.Colors.SURFACE_VARIANT,             # 使用主题表面色
        border_radius=12,
        padding=16,
        border=ft.border.all(1, ft.Colors.OUTLINE),   # 使用主题轮廓色
    )

自定义字体

def main(page: ft.Page):
    # 加载字体
    page.fonts = {
        "NotoSansSC": "fonts/NotoSansSC-Regular.ttf",
        "NotoSansSC-Bold": "fonts/NotoSansSC-Bold.ttf",
    }
    page.theme = ft.Theme(font_family="NotoSansSC")
    page.render(MyApp)

五、动画

隐式动画(属性变化自动过渡)

@ft.component
def AnimatedBox() -> ft.Control:
    big, set_big = ft.use_state(False)

    size = 200 if big else 100
    color = ft.Colors.RED if big else ft.Colors.BLUE

    return ft.Column([
        ft.Container(
            width=size,
            height=size,
            bgcolor=color,
            border_radius=size / 2 if big else 8,
            animate=ft.Animation(duration=400, curve=ft.AnimationCurve.EASE_IN_OUT),
            animate_rotation=ft.Animation(300, ft.AnimationCurve.BOUNCE_OUT),
            on_click=lambda _: set_big(not big),
        ),
        ft.Text("点击切换动画", size=12, color=ft.Colors.GREY),
    ], horizontal_alignment=ft.CrossAxisAlignment.CENTER)

animate 参数说明

参数 类型 说明
duration int 动画持续时间(毫秒)
curve AnimationCurve 缓动曲线

常用 AnimationCurve:
LINEAR EASE EASE_IN EASE_OUT EASE_IN_OUT BOUNCE_IN BOUNCE_OUT ELASTIC_IN ELASTIC_OUT

各属性动画

ft.Container(
    # ...
    animate=ft.Animation(500),           # 容器尺寸/颜色/边框动画
    animate_opacity=ft.Animation(300),   # 透明度动画
    animate_scale=ft.Animation(300),     # 缩放动画
    animate_rotation=ft.Animation(500),  # 旋转动画
    animate_offset=ft.Animation(400),    # 偏移动画(相对位置)
)

AnimatedSwitcher — 切换动画

@ft.component
def PageSwitcher() -> ft.Control:
    page_index, set_page_index = ft.use_state(0)

    pages = [
        ft.Text("第一页", size=32),
        ft.Text("第二页", size=32, color=ft.Colors.BLUE),
        ft.Text("第三页", size=32, color=ft.Colors.RED),
    ]

    return ft.Column([
        ft.AnimatedSwitcher(
            content=pages[page_index],
            transition=ft.AnimatedSwitcherTransition.SCALE,
            duration=300,
            reverse_duration=200,
        ),
        ft.Row([
            ft.Button(str(i+1), on_click=lambda _, i=i: set_page_index(i))
            for i in range(len(pages))
        ]),
    ])

六、对话框与覆盖层

AlertDialog

@ft.component
def DialogExample() -> ft.Control:
    open_dialog, set_open_dialog = ft.use_state(False)
    result, set_result = ft.use_state("")

    return ft.Column([
        ft.ElevatedButton("打开对话框", on_click=lambda _: set_open_dialog(True)),
        ft.Text(result),
        ft.AlertDialog(
            open=open_dialog,
            modal=True,
            title=ft.Text("确认操作"),
            content=ft.Text("你确定要删除这条记录吗?"),
            actions=[
                ft.TextButton("取消", on_click=lambda _: set_open_dialog(False)),
                ft.TextButton(
                    "确认删除",
                    style=ft.ButtonStyle(color=ft.Colors.RED),
                    on_click=lambda _: (set_result("已删除"), set_open_dialog(False)),
                ),
            ],
        ),
    ])

BottomSheet

@ft.component
def BottomSheetExample() -> ft.Control:
    open_sheet, set_open_sheet = ft.use_state(False)

    return ft.Column([
        ft.ElevatedButton("打开底部弹窗", on_click=lambda _: set_open_sheet(True)),
        ft.BottomSheet(
            open=open_sheet,
            on_dismiss=lambda _: set_open_sheet(False),
            content=ft.Container(
                content=ft.Column([
                    ft.Text("底部弹窗", size=18, weight=ft.FontWeight.BOLD),
                    ft.Divider(),
                    ft.ListTile(leading=ft.Icon(ft.Icons.SHARE), title=ft.Text("分享")),
                    ft.ListTile(leading=ft.Icon(ft.Icons.EDIT), title=ft.Text("编辑")),
                    ft.ListTile(
                        leading=ft.Icon(ft.Icons.DELETE, color=ft.Colors.RED),
                        title=ft.Text("删除", style=ft.TextStyle(color=ft.Colors.RED)),
                        on_click=lambda _: set_open_sheet(False),
                    ),
                ]),
                padding=16,
            ),
        ),
    ])

SnackBar(消息提示)

@ft.component
def SnackBarExample() -> ft.Control:
    show_snack, set_show_snack = ft.use_state(False)

    return ft.Column([
        ft.ElevatedButton("显示消息", on_click=lambda _: set_show_snack(True)),
        ft.SnackBar(
            open=show_snack,
            content=ft.Text("操作成功!"),
            action="撤销",
            on_action=lambda _: print("撤销"),
            on_dismiss=lambda _: set_show_snack(False),
            bgcolor=ft.Colors.GREEN_700,
        ),
    ])

七、导航栏与 AppBar

AppBar

def main(page: ft.Page):
    page.appbar = ft.AppBar(
        leading=ft.Icon(ft.Icons.MENU),
        leading_width=40,
        title=ft.Text("我的应用"),
        center_title=False,
        bgcolor=ft.Colors.SURFACE_VARIANT,
        actions=[
            ft.IconButton(ft.Icons.SEARCH, on_click=lambda _: ...),
            ft.IconButton(ft.Icons.NOTIFICATIONS, on_click=lambda _: ...),
            ft.PopupMenuButton(
                items=[
                    ft.PopupMenuItem("设置", icon=ft.Icons.SETTINGS),
                    ft.PopupMenuItem("帮助", icon=ft.Icons.HELP),
                    ft.PopupMenuItem(),   # 分割线
                    ft.PopupMenuItem("退出", icon=ft.Icons.EXIT_TO_APP),
                ]
            ),
        ],
    )
    page.render(MyApp)
@ft.component
def AppWithNavBar() -> ft.Control:
    selected_index, set_selected_index = ft.use_state(0)

    pages = [HomePage, SearchPage, ProfilePage]
    CurrentPage = pages[selected_index]

    return ft.Column(
        controls=[
            CurrentPage(expand=True),
            ft.NavigationBar(
                selected_index=selected_index,
                on_change=lambda e: set_selected_index(e.control.selected_index),
                destinations=[
                    ft.NavigationBarDestination(icon=ft.Icons.HOME, label="首页"),
                    ft.NavigationBarDestination(icon=ft.Icons.SEARCH, label="搜索"),
                    ft.NavigationBarDestination(icon=ft.Icons.PERSON, label="我的"),
                ],
            ),
        ],
        expand=True,
        spacing=0,
    )
@ft.component
def AppWithRail() -> ft.Control:
    selected, set_selected = ft.use_state(0)
    pages = [HomePage, SettingsPage, AboutPage]
    CurrentPage = pages[selected]

    return ft.Row(
        controls=[
            ft.NavigationRail(
                selected_index=selected,
                on_change=lambda e: set_selected(e.control.selected_index),
                label_type=ft.NavigationRailLabelType.ALL,
                destinations=[
                    ft.NavigationRailDestination(icon=ft.Icons.HOME, label="首页"),
                    ft.NavigationRailDestination(icon=ft.Icons.SETTINGS, label="设置"),
                    ft.NavigationRailDestination(icon=ft.Icons.INFO, label="关于"),
                ],
            ),
            ft.VerticalDivider(width=1),
            CurrentPage(expand=True),
        ],
        expand=True,
        spacing=0,
    )

八、表单与验证

from dataclasses import dataclass

@dataclass
class FormState:
    username: str = ""
    email: str = ""
    password: str = ""

    def validate(self) -> dict[str, str]:
        errors = {}
        if len(self.username) < 3:
            errors["username"] = "用户名至少 3 个字符"
        if "@" not in self.email:
            errors["email"] = "请输入有效的邮箱地址"
        if len(self.password) < 6:
            errors["password"] = "密码至少 6 个字符"
        return errors

@ft.component
def RegisterForm() -> ft.Control:
    form, set_form = ft.use_state(FormState())
    errors, set_errors = ft.use_state({})
    submitted, set_submitted = ft.use_state(False)

    def update_field(field: str, value: str):
        new_form = FormState(**{**form.__dict__, field: value})
        set_form(new_form)
        # 实时清除已修改字段的错误
        if field in errors:
            set_errors({k: v for k, v in errors.items() if k != field})

    def submit():
        validation_errors = form.validate()
        if validation_errors:
            set_errors(validation_errors)
        else:
            set_submitted(True)

    if submitted:
        return ft.Column([
            ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN, size=64),
            ft.Text(f"注册成功!欢迎,{form.username}", size=18),
        ], horizontal_alignment=ft.CrossAxisAlignment.CENTER)

    return ft.Column(
        controls=[
            ft.Text("注册账号", size=24, weight=ft.FontWeight.BOLD),
            ft.TextField(
                label="用户名",
                value=form.username,
                error_text=errors.get("username"),
                on_change=lambda e: update_field("username", e.control.value),
            ),
            ft.TextField(
                label="邮箱",
                value=form.email,
                error_text=errors.get("email"),
                keyboard_type=ft.KeyboardType.EMAIL,
                on_change=lambda e: update_field("email", e.control.value),
            ),
            ft.TextField(
                label="密码",
                value=form.password,
                error_text=errors.get("password"),
                password=True,
                can_reveal_password=True,
                on_change=lambda e: update_field("password", e.control.value),
            ),
            ft.ElevatedButton(
                "注册",
                width=300,
                on_click=lambda _: submit(),
            ),
        ],
        width=320,
        spacing=16,
    )

九、异步编程

async 组件与异步事件

import asyncio
import flet as ft

@ft.component
def AsyncDataView() -> ft.Control:
    loading, set_loading = ft.use_state(False)
    data, set_data = ft.use_state(None)
    error, set_error = ft.use_state(None)

    async def load_data():
        set_loading(True)
        set_error(None)
        try:
            await asyncio.sleep(1.5)   # 模拟网络请求
            set_data({"name": "Alice", "score": 98})
        except Exception as e:
            set_error(str(e))
        finally:
            set_loading(False)

    if loading:
        return ft.Column([
            ft.ProgressRing(),
            ft.Text("加载中...", color=ft.Colors.GREY),
        ], horizontal_alignment=ft.CrossAxisAlignment.CENTER)

    if error:
        return ft.Column([
            ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED, size=48),
            ft.Text(f"错误:{error}", color=ft.Colors.RED),
            ft.ElevatedButton("重试", on_click=lambda _: asyncio.create_task(load_data())),
        ])

    if data is None:
        return ft.ElevatedButton("加载数据", on_click=lambda _: asyncio.create_task(load_data()))

    return ft.Column([
        ft.Text(f"姓名:{data['name']}"),
        ft.Text(f"分数:{data['score']}"),
        ft.TextButton("刷新", on_click=lambda _: asyncio.create_task(load_data())),
    ])

与 httpx 集成(真实网络请求)

import httpx
import asyncio
import flet as ft
from dataclasses import dataclass, field

@ft.observable
@dataclass
class PostsState:
    posts: list = field(default_factory=list)
    loading: bool = False
    error: str = ""

    async def fetch(self):
        self.loading = True
        self.error = ""
        try:
            async with httpx.AsyncClient() as client:
                resp = await client.get("https://jsonplaceholder.typicode.com/posts?_limit=10")
                resp.raise_for_status()
                self.posts = resp.json()
        except httpx.HTTPError as e:
            self.error = f"网络错误:{e}"
        finally:
            self.loading = False

@ft.component
def PostsList() -> ft.Control:
    state, _ = ft.use_state(PostsState())

    if state.loading:
        return ft.ProgressRing()

    if state.error:
        return ft.Column([
            ft.Text(state.error, color=ft.Colors.RED),
            ft.ElevatedButton("重试", on_click=lambda _: asyncio.create_task(state.fetch())),
        ])

    if not state.posts:
        return ft.ElevatedButton(
            "加载文章",
            on_click=lambda _: asyncio.create_task(state.fetch()),
        )

    return ft.Column([
        ft.Text("文章列表", size=20, weight=ft.FontWeight.BOLD),
        ft.ListView(
            controls=[
                ft.ListTile(
                    title=ft.Text(p["title"][:40]),
                    subtitle=ft.Text(p["body"][:60]),
                )
                for p in state.posts
            ],
            spacing=4,
            expand=True,
        ),
    ], expand=True)

十、客户端存储

import flet as ft
from dataclasses import dataclass
import json

@ft.observable
@dataclass
class Settings:
    theme: str = "light"
    language: str = "zh"
    notifications: bool = True

@ft.component
def SettingsView() -> ft.Control:
    settings, set_settings = ft.use_state(Settings())
    saved, set_saved = ft.use_state(False)

    async def load_settings(page: ft.Page):
        """从本地存储加载设置"""
        stored = await page.client_storage.get_async("app_settings")
        if stored:
            data = json.loads(stored)
            set_settings(Settings(**data))

    async def save_settings(page: ft.Page):
        """保存设置到本地存储"""
        await page.client_storage.set_async(
            "app_settings",
            json.dumps(settings.__dict__)
        )
        set_saved(True)

    def update_settings(**kwargs):
        new = Settings(**{**settings.__dict__, **kwargs})
        set_settings(new)
        set_saved(False)

    return ft.Column([
        ft.Text("应用设置", size=20, weight=ft.FontWeight.BOLD),
        ft.Dropdown(
            label="主题",
            value=settings.theme,
            options=[
                ft.dropdown.Option("light", "浅色"),
                ft.dropdown.Option("dark", "深色"),
                ft.dropdown.Option("system", "跟随系统"),
            ],
            on_change=lambda e: update_settings(theme=e.control.value),
        ),
        ft.Switch(
            label="开启通知",
            value=settings.notifications,
            on_change=lambda e: update_settings(notifications=e.control.value),
        ),
        ft.ElevatedButton(
            "保存设置",
            on_click=lambda e: asyncio.create_task(save_settings(e.page)),
        ),
        ft.Text("已保存!", color=ft.Colors.GREEN) if saved else ft.Container(),
    ], spacing=16)

十一、拖放(Drag & Drop)

import flet as ft
from dataclasses import dataclass, field

@ft.observable
@dataclass
class KanbanState:
    todo: list = field(default_factory=lambda: ["任务A", "任务B", "任务C"])
    doing: list = field(default_factory=lambda: ["任务D"])
    done: list = field(default_factory=list)

    def move(self, item: str, from_col: str, to_col: str):
        getattr(self, from_col).remove(item)
        getattr(self, to_col).append(item)
        # 触发更新
        self.todo = list(self.todo)
        self.doing = list(self.doing)
        self.done = list(self.done)

@ft.component
def KanbanColumn(title: str, items: list, col_name: str, state: KanbanState) -> ft.Control:
    return ft.Container(
        content=ft.Column([
            ft.Text(title, weight=ft.FontWeight.BOLD),
            ft.DragTarget(
                group="kanban",
                content=ft.Column(
                    controls=[
                        ft.Draggable(
                            group="kanban",
                            data=f"{col_name}|{item}",
                            content=ft.Container(
                                content=ft.Text(item),
                                bgcolor=ft.Colors.SURFACE,
                                padding=8,
                                border_radius=4,
                                border=ft.border.all(1, ft.Colors.OUTLINE),
                            ),
                        )
                        for item in items
                    ],
                    spacing=4,
                    height=200,
                ),
                on_accept=lambda e: state.move(
                    *e.data.split("|"),
                    col_name,
                ),
            ),
        ]),
        bgcolor=ft.Colors.SURFACE_VARIANT,
        padding=12,
        border_radius=8,
        width=180,
    )

@ft.component
def KanbanBoard() -> ft.Control:
    state, _ = ft.use_state(KanbanState())

    return ft.Row([
        KanbanColumn("待处理", state.todo, "todo", state),
        KanbanColumn("进行中", state.doing, "doing", state),
        KanbanColumn("已完成", state.done, "done", state),
    ], spacing=12)

最佳实践

路由参数用 URL query string 传递,不用全局变量:通过 page.route(如 /user?id=42)传递页面间数据,而非将数据存到模块级变量。URL 方式支持浏览器历史、书签和刷新恢复,全局变量在多用户 Web 部署时会产生数据混用。

主题统一在 page.theme 配置,不要在控件上硬编码颜色:用 ft.Theme(color_scheme_seed=ft.Colors.BLUE) 和语义颜色(ft.Colors.PRIMARYft.Colors.ON_SURFACE),使暗色模式切换自动生效,避免手动维护两套颜色值。

动画使用 AnimatedSwitcher 而非手动控制 visible:切换控件显隐时,ft.AnimatedSwitcher 自动处理进出场动画,且无需管理 page.update() 时机;手动切换 visible 没有过渡效果且更难维护。

表单验证在 on_submit 中统一处理:不要在每个字段的 on_change 里实时校验所有规则(性能差),而是在提交时做完整校验,并在 TextField.error_text 上反馈错误信息,校验通过后清空 error_text

拖拽列表用 key 参数保证控件身份ft.Draggable 列表在重排后,Flet 通过 key 匹配控件身份以计算最小 diff;不设置 key 时可能出现控件状态错位(如输入框内容跑到相邻控件中)。


常见陷阱

陷阱:路由切换后旧页面的事件回调仍然触发

现象: 导航到新页面后,旧页面的定时器或后台任务仍在运行并调用 page.update(),导致 UI 异常或报错。
原因: Flet 不会自动取消旧视图的异步任务;路由切换只是替换 page.views,旧回调仍持有对 page 的引用。
解决:on_view_pop 或路由变化回调中显式取消后台任务(task.cancel());使用 page.on_disconnect 钩子做清理。

陷阱:暗色模式下自定义颜色与主题不协调

现象: 切换到暗色模式后,某些区域颜色与背景反差不足或过于突兀。
原因: 控件上直接写了 bgcolor=ft.Colors.WHITEcolor="#333333" 等固定颜色值,暗色模式不会自动反转。
解决: 统一使用主题语义颜色(ft.Colors.SURFACEft.Colors.ON_PRIMARY 等),或通过 page.theme_mode 变化时动态更新颜色。

陷阱:Dropdownvalueoptions 中的值不匹配导致显示空白

现象: Dropdown 设置了 value="active",但下拉框显示为空。
原因: options 中的每个 ft.dropdown.Optionkey 必须与 value 精确匹配(区分大小写和类型),若 key"Active"(首字母大写),则 value="active" 无法匹配。
解决: 确保 value 的值与 Option(key=...)key 完全一致;建议统一用枚举或常量管理选项值,避免字符串拼写错误。


参见

Flet入门指南
Flet高级指南
asyncio异步编程完全指南

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog