快轉到主要內容
  1. 教學文章/

Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換

·5 分鐘· loading · loading · ·
Python Textual TUI Forms Validation Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 93: 本文

featured
終端機工具只要出現三個以上的輸入欄位,就很容易變成一坨 prompt 地獄。 一開始用 input() 很快,後來使用者打錯版本號、忘記填通知頻道、按到 Enter 才發現要重來,就會開始想念表單。 今天拍拍君要用 Textual 做一個 release checklist wizard。 它不是網站,也不是單純 CLI。 它是一個本機終端機 TUI,適合內部工具、部署流程、資料清理任務、設定檔產生器。 如果你還沒寫過第一個 Textual App,可以先看 Python Textual 實戰。 如果你想處理長任務與進度條,請看 Textual Background Workers。 這篇專心處理另一個痛點:多步驟表單、validation、狀態切換。

一. 我們要做的工具
#

這次的工具會收集一份 release draft:

Step 1: 基本資訊
  - release name
  - version
  - target environment
Step 2: 風險設定
  - run migration?
  - requires downtime?
  - rollback owner
Step 3: 通知設定
  - Slack channel
  - summary
Step 4: Review and save

最後輸出一個 JSON:

{
  "name": "checkout-api",
  "version": "2026.06.18",
  "environment": "staging",
  "run_migration": true,
  "requires_downtime": false,
  "rollback_owner": "maho",
  "notify_channel": "#release",
  "summary": "Deploy checkout API with schema migration"
}

這種工具用 Web App 略重,用普通 CLI 又太陽春。 Textual 的位置剛好在中間:有 UI、有焦點、有按鈕,但仍然是本機命令列工具。

二. 安裝與專案結構
#

先建立專案:

uv init textual-form-wizard
cd textual-form-wizard
uv add textual

不用 uv 也可以:

python -m venv .venv
source .venv/bin/activate
pip install textual

這篇用單檔示範:

release_wizard.py
release-draft.json

正式專案可以再拆:

app/
  models.py
  screens/
    basic.py
    risk.py
    notify.py
    review.py

拍拍君建議先不要急著拆。 wizard 最難的不是檔案位置,而是狀態要放哪裡。 原則很簡單:

畫面可以分散,資料狀態要集中。 每一頁都直接修改同一份 draft。 這樣 Back、Next、Review、Save 才不會各自藏一份資料。

三. 先寫 Draft Model
#

先不碰 UI,先定義資料。

from dataclasses import asdict, dataclass
import json
from pathlib import Path
@dataclass
class ReleaseDraft:
    name: str = ""
    version: str = ""
    environment: str = "staging"
    run_migration: bool = False
    requires_downtime: bool = False
    rollback_owner: str = ""
    notify_channel: str = ""
    summary: str = ""
    def save(self, path: Path) -> None:
        text = json.dumps(asdict(self), indent=2, ensure_ascii=False)
        path.write_text(text + "\n", encoding="utf-8")

接著寫 validation。 這裡不用魔法,普通 function 就夠。

def validate_basic(draft: ReleaseDraft) -> list[str]:
    errors: list[str] = []
    if not draft.name.strip():
        errors.append("Release name 不能空白")
    if not draft.version.strip():
        errors.append("Version 不能空白")
    if draft.environment not in {"dev", "staging", "prod"}:
        errors.append("Environment 必須是 dev、staging 或 prod")
    return errors
def validate_risk(draft: ReleaseDraft) -> list[str]:
    errors: list[str] = []
    if draft.requires_downtime and not draft.rollback_owner.strip():
        errors.append("需要 downtime 時,必須填 rollback owner")
    if draft.environment == "prod" and draft.run_migration and not draft.rollback_owner.strip():
        errors.append("Production migration 必須指定 rollback owner")
    return errors
def validate_notify(draft: ReleaseDraft) -> list[str]:
    errors: list[str] = []
    if not draft.notify_channel.startswith("#"):
        errors.append("Slack channel 要用 # 開頭,例如 #release")
    if len(draft.summary.strip()) < 10:
        errors.append("Summary 至少寫 10 個字元")
    return errors

欄位格式錯誤,可以在該步驟擋下來。 跨步驟規則,例如 production migration 必須有 rollback owner,最好放在 model 附近,不要散在 UI handler 裡。

四. 建立 Textual App
#

App 持有 draft。 Screen 負責顯示與更新欄位。

from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import Button, Checkbox, Input, Label, Select, Static, TextArea
def bullet_list(errors: list[str]) -> str:
    return "\n".join(f"- {error}" for error in errors)
class ReleaseWizard(App[None]):
    CSS = """
    Screen { align: center middle; }
    .panel {
        width: 72;
        border: solid $primary;
        padding: 1 2;
    }
    .title {
        text-style: bold;
        margin-bottom: 1;
    }
    .error {
        color: $error;
        min-height: 3;
        margin-top: 1;
    }
    .actions {
        height: auto;
        margin-top: 1;
    }
    Input, Select, TextArea { margin-bottom: 1; }
    """
    BINDINGS = [("q", "quit", "Quit")]
    def __init__(self) -> None:
        super().__init__()
        self.draft = ReleaseDraft()
    def on_mount(self) -> None:
        self.push_screen(BasicScreen())

這裡有一個重要選擇: draft 放在 ReleaseWizard,不是放在每個 screen。 Screen 可以 push、pop、replace。 App 會活到程式結束。 所以 App 是放共享狀態的自然位置。

五. Step 1:基本資訊
#

第一頁收集 name、version、environment。

class BasicScreen(Screen[None]):
    def compose(self) -> ComposeResult:
        draft = self.app.draft
        with Vertical(classes="panel"):
            yield Static("Step 1 / 4 - 基本資訊", classes="title")
            yield Label("Release name")
            yield Input(value=draft.name, id="name", placeholder="checkout-api")
            yield Label("Version")
            yield Input(value=draft.version, id="version", placeholder="2026.06.18")
            yield Label("Environment")
            yield Select(
                [("dev", "dev"), ("staging", "staging"), ("prod", "prod")],
                value=draft.environment,
                id="environment",
            )
            yield Static("", id="errors", classes="error")
            with Horizontal(classes="actions"):
                yield Button("Next", id="next", variant="primary")
    def update_draft(self) -> None:
        draft = self.app.draft
        draft.name = self.query_one("#name", Input).value.strip()
        draft.version = self.query_one("#version", Input).value.strip()
        draft.environment = str(self.query_one("#environment", Select).value)
    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id != "next":
            return
        self.update_draft()
        errors = validate_basic(self.app.draft)
        if errors:
            self.query_one("#errors", Static).update(bullet_list(errors))
            return
        self.app.push_screen(RiskScreen())

這個 pattern 會一直重複:

  1. 從 widgets 把值寫回 draft
  2. validate draft
  3. 有錯就停在原頁
  4. 沒錯才切到下一頁 不要在使用者每打一個字時就跑全部 validation。 wizard 裡按 Next 再檢查,通常比較不吵。

六. Step 2:風險設定
#

第二頁用 checkbox 表示 yes/no。 不要叫使用者手打 yesnotruefalse

class RiskScreen(Screen[None]):
    def compose(self) -> ComposeResult:
        draft = self.app.draft
        with Vertical(classes="panel"):
            yield Static("Step 2 / 4 - 風險設定", classes="title")
            yield Checkbox("Run database migration", value=draft.run_migration, id="migration")
            yield Checkbox("Requires downtime", value=draft.requires_downtime, id="downtime")
            yield Label("Rollback owner")
            yield Input(value=draft.rollback_owner, id="rollback_owner", placeholder="maho")
            yield Static("", id="errors", classes="error")
            with Horizontal(classes="actions"):
                yield Button("Back", id="back")
                yield Button("Next", id="next", variant="primary")
    def update_draft(self) -> None:
        draft = self.app.draft
        draft.run_migration = self.query_one("#migration", Checkbox).value
        draft.requires_downtime = self.query_one("#downtime", Checkbox).value
        draft.rollback_owner = self.query_one("#rollback_owner", Input).value.strip()
    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "back":
            self.update_draft()
            self.app.pop_screen()
            return
        if event.button.id != "next":
            return
        self.update_draft()
        errors = validate_risk(self.app.draft)
        if errors:
            self.query_one("#errors", Static).update(bullet_list(errors))
            return
        self.app.push_screen(NotifyScreen())

Back button 也先 update_draft()。 不然使用者改完欄位按上一頁,再回來時資料可能消失。 這種小 bug 很討厭,因為它不像 crash 那麼明顯,但會讓工具變得不可信。

七. Step 3:通知設定
#

第三頁收集通知頻道與摘要。

class NotifyScreen(Screen[None]):
    def compose(self) -> ComposeResult:
        draft = self.app.draft
        with Vertical(classes="panel"):
            yield Static("Step 3 / 4 - 通知設定", classes="title")
            yield Label("Slack channel")
            yield Input(value=draft.notify_channel, id="notify_channel", placeholder="#release")
            yield Label("Summary")
            yield TextArea(draft.summary, id="summary")
            yield Static("", id="errors", classes="error")
            with Horizontal(classes="actions"):
                yield Button("Back", id="back")
                yield Button("Review", id="next", variant="primary")
    def update_draft(self) -> None:
        draft = self.app.draft
        draft.notify_channel = self.query_one("#notify_channel", Input).value.strip()
        draft.summary = self.query_one("#summary", TextArea).text.strip()
    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "back":
            self.update_draft()
            self.app.pop_screen()
            return
        if event.button.id != "next":
            return
        self.update_draft()
        errors = validate_notify(self.app.draft)
        if errors:
            self.query_one("#errors", Static).update(bullet_list(errors))
            return
        self.app.push_screen(ReviewScreen())

TextArea 很適合 release summary。 如果只是短字串,Input 就好。 表單 UI 的小訣竅是:能限制輸入就限制,不能限制的才用自由文字。

八. Step 4:確認與儲存
#

確認頁不再收集新欄位。 它讓使用者看一次所有資料,然後儲存。

class ReviewScreen(Screen[None]):
    def compose(self) -> ComposeResult:
        with Vertical(classes="panel"):
            yield Static("Step 4 / 4 - 確認送出", classes="title")
            yield Static(self.summary_text(), id="summary")
            yield Static("", id="errors", classes="error")
            with Horizontal(classes="actions"):
                yield Button("Back", id="back")
                yield Button("Save", id="save", variant="success")
    def summary_text(self) -> str:
        draft = self.app.draft
        return "\n".join(
            [
                f"Release name: {draft.name}",
                f"Version: {draft.version}",
                f"Environment: {draft.environment}",
                f"Run migration: {draft.run_migration}",
                f"Requires downtime: {draft.requires_downtime}",
                f"Rollback owner: {draft.rollback_owner or '(none)'}",
                f"Notify channel: {draft.notify_channel}",
                "",
                "Summary:",
                draft.summary,
            ]
        )
    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "back":
            self.app.pop_screen()
            return
        if event.button.id != "save":
            return
        errors = (
            validate_basic(self.app.draft)
            + validate_risk(self.app.draft)
            + validate_notify(self.app.draft)
        )
        if errors:
            self.query_one("#errors", Static).update(bullet_list(errors))
            return
        self.app.draft.save(Path("release-draft.json"))
        self.app.exit()

確認頁再跑一次全部 validation 是值得的。 因為未來你可能會加上:

  • 從 JSON 載入草稿
  • 用 CLI arguments 預填資料
  • 根據 environment 跳過某些步驟
  • 從 API 帶入預設值 最後一道 validation 可以防止髒資料存出去。

九. 啟動程式
#

把最後一段接上:

if __name__ == "__main__":
    ReleaseWizard().run()

執行:

uv run python release_wizard.py

填完後檢查:

cat release-draft.json

如果你想做 autosave,可以在每次 Back 或 Next 後呼叫:

self.app.draft.save(Path("release-draft.json"))

但要記得:草稿可以不完整。 如果有 final output,最好分成兩個檔名:

release-draft.json
release-final.json

名字清楚,比註解清楚。

結語
#

多步驟表單不是把畫面切成很多頁而已。 真正重要的是狀態管理。 今天的模式可以記成四句話:

  • draft model 集中管理資料
  • 每個 screen 只處理自己的欄位
  • Next 前先 update draft 再 validate
  • Review 頁再做一次全域 validation 這樣寫出來的 Textual wizard 比較耐改。 你可以加新步驟、加草稿、接 API、補測試,而不會到處追資料藏在哪裡。 好的內部工具不一定要很大。 它只要讓人少重來一次,就已經很值得了。拍拍君認真覺得,這種小工具很有投資報酬率。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 93: 本文

相關文章

Textual + SQLite 實戰:做一個終端機資料管理小工具
·8 分鐘· loading · loading
Python Textual SQLite TUI Database Developer-Tools
Textual Background Workers 實戰:長任務、Progress、取消與 Log Console
·9 分鐘· loading · loading
Python Textual TUI Background-Workers Async Developer-Tools
Textual + DuckDB 實戰:終端機資料 Dashboard 小工具
·6 分鐘· loading · loading
Python Textual DuckDB TUI Dashboard Data-Analysis
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal
uv + GitHub Actions 實戰:Lockfile、Cache 與可重現 CI
·5 分鐘· loading · loading
Python Uv GitHub Actions Ci Lockfile Cache Developer-Tools
Python OpenTelemetry 實戰:Trace、Span 與 FastAPI 觀測流程
·6 分鐘· loading · loading
Python OpenTelemetry Observability FastAPI Tracing Developer-Tools