# 手動降版面向一般使用者:流程設計與 Design 需求清單 > 對應 research index §42 > 範圍:B2 階段——把 FW 降版功能暴露給一般使用者(不只 dev mode / 內部測試)、所需 driver / bridge / API endpoint 細節 + 給 Design Agent 的 UX 補強需求 > 撰寫日期:2026-05-24 > 限制:架構面、不出 code、UX 細節留給 Design Agent > 路徑使用相對路徑(相對於 `/Users/jimchen/visionA/local-tool/`) --- ## 0. TL;DR 1. **使用者決策(2026-05-24)翻案前一份研究 §3 的「降版僅內部測試」假設**——降版面向一般使用者 2. 一般使用者面向後、責任分工: - **Architect(我,本檔)**:標記技術需求、定義 safety guards、API 簽名、儲存結構、driver 與 bridge.py 介面 - **Design Agent(另派)**:警告語、二次確認流程、版本 dropdown 視覺、降版進行中互動、失敗復原 UX - **Frontend Agent**:實作 UI、串 API、i18n - **Backend Agent**:實作 driver method、bridge.py handler、API endpoint 3. 「面向一般使用者」≠ 「無腦讓使用者隨便降」——必須有**多層 safety net**: - UI 警告 + 二次確認(design 領域、本檔只標需求) - Driver 層 guard(拒絕跨晶片誤匹配、拒絕「降版」到比 current 還新的版本) - bridge.py 層 guard(checksum 驗證 firmware 完整性、version 字串嚴格 match) 4. 暴露範圍:Settings → 「韌體管理」面板(不在 Devices 頁主流程、避免誤觸) --- ## 1. 使用情境分析 ### 1.1 一般使用者為什麼會降版 | 情境 | 頻率 | 描述 | 處理建議 | |------|------|------|---------| | 「我的舊 model 在新 FW 上跑不出結果」 | 中 | 內建升版到 v2.2 後、舊 NEF model(v2.1 編的)相容性問題 | 提供降版回 v2.1 選項 | | 「跟某個 third-party tool 不相容」 | 低 | 例如某些 model debugging tool 只認 KDP1 | 提供降版回 KDP1 選項(KL520) | | 「我升錯了、想回到原本狀態」 | 低 | 使用者誤觸升級、想 revert | 提供「降版回上次的版本」選項 | | 「測試環境需要特定版本」 | 中 | 開發者場景(但你說也要 face 一般使用者)| 提供降版到任意 bundled 版本 | | 「就是想試試看」 | 中 | 好奇心驅動 | 用警告語勸阻、但不阻止 | → 「跟一般使用者解釋降版」必須包裝成「**FW 版本切換**」、不要用「**降版**」這個帶負面含義詞——但內部技術文件仍叫 downgrade(程式碼/log/Architect 文件用語)。 ### 1.2 UI 觸發位置(建議給 Design Agent 評估) | 位置 | 優點 | 缺點 | 建議 | |------|------|------|------| | Devices 頁主卡片 | 醒目、與 FW badge 同位置 | 太容易誤觸、新手使用者不需要 | **不建議** | | Devices 頁 → Device Detail Modal | 進階入口、需要點開 | 仍偏 device-centric | **次佳** | | Settings → 韌體管理 | 進階入口、與其他 settings 同位置 | 較深入路徑 | **推薦** | | Settings → 進階(advanced) | 最深入口 | 但使用者面向後不應藏太深 | 折衷 | **架構建議**:**Settings → 韌體管理** 是主入口、Devices 頁 device card 顯示 FW badge 但不放降版按鈕。 --- ## 2. Driver / Bridge / API 詳細設計 ### 2.1 driver interface 擴展 承前一份 30-integration-plan §1 M9-2 已定義的 `UpgradeFirmware()`、本檔加: ```go // 偽碼、不出 code(給 architect 補 TDD 用) type DeviceDriver interface { // ... 既有 methods 與 UpgradeFirmware() ... // B2 階段新增 DowngradeFirmware(version string, progressCh chan<- FirmwareProgress) error ListFirmwareVersions() ([]FirmwareVersion, error) GetCurrentFirmwareVersion() (FirmwareVersion, error) } type FirmwareVersion struct { Version string `json:"version"` // "v2.2.0" / "v2.1.0" / "kdp1" DisplayName string `json:"displayName"` // "v2.2.0 (current)" / "v2.1.0 (older)" IsCurrent bool `json:"isCurrent"` IsBundled bool `json:"isBundled"` // 是否在安裝包內、總是 true(不做線上更新) ReleaseDate string `json:"releaseDate,omitempty"` // ISO 8601、optional Notes string `json:"notes,omitempty"` // 給使用者看的說明 } type FirmwareProgress struct { // ... 既有欄位 ... Direction string `json:"direction"` // "upgrade" / "downgrade" } ``` ### 2.2 bridge.py handler 設計 #### 2.2.1 `handle_firmware_list_versions` ```python # 偽碼 def handle_firmware_list_versions(params): """List bundled firmware versions for a chip. Params: chip: str ("KL520" | "KL720" | "KL630" | "KL730", required) Returns: {"versions": [{"version":"v2.2.0", "isCurrent":true, "isBundled":true, ...}, ...]} or {"error": "..."} """ chip = params.get("chip", "").upper() if chip not in ("KL520", "KL720", "KL630", "KL730"): return {"error": f"unknown chip: {chip}"} base = os.path.dirname(os.path.abspath(__file__)) fw_dir = os.path.join(base, "firmware", chip) if not os.path.isdir(fw_dir): return {"error": f"firmware dir not found for {chip}"} # 讀 current/VERSION current_version = _read_version_file(os.path.join(fw_dir, "current", "VERSION")) versions = [] for entry in os.listdir(fw_dir): entry_path = os.path.join(fw_dir, entry) if not os.path.isdir(entry_path): continue if entry == "current": continue # current 是 alias、不重複列 version_file = os.path.join(entry_path, "VERSION") if not os.path.exists(version_file): continue v = _read_version_file(version_file) versions.append({ "version": v, "displayName": _format_display_name(v, current_version), "isCurrent": v == current_version, "isBundled": True, "directory": entry, # 給 bridge.py 內部用、不外露給 API }) return {"versions": versions, "current": current_version} ``` #### 2.2.2 `handle_firmware_downgrade` ```python # 偽碼 def handle_firmware_downgrade(params): """Downgrade firmware to a specific bundled version. Params: port: int (USB port id, required) chip: str ("KL520" | "KL720" | "KL630" | "KL730", required) version: str (target version, e.g. "v2.1.0" / "kdp1", required) Returns: {"status":"downgraded", "before_version":"v2.2.0", "after_version":"v2.1.0", "duration_ms":31000, "stage":"done"} or {"error":"...", "stage":"validate|connect|download|verify"} """ chip = params.get("chip", "") target_version = params.get("version", "") target_port = params.get("port", "") # Stage 1: validate # - chip 必須是支援的 # - version 必須在 bundled list 內 # - target_version 不能等於 current(那是 no-op) # - target_version 不能比 current 還新(那是 upgrade、走另一支) if not _validate_downgrade_request(chip, target_version): return {"error": "invalid downgrade request", "stage": "validate"} # Stage 2: 解析 firmware paths fw_paths = _resolve_firmware_paths_versioned(chip, target_version) if fw_paths is None: return {"error": f"firmware not found: {chip}/{target_version}", "stage": "validate"} # Stage 3: connect # 跟 firmware_upgrade 一樣的 connect_with_magic 流程 # ... # Stage 4: 跑 SDK API # 視 chip: # - KL520 KDP2 → KDP1: kp.core.update_kdp_firmware_from_files(loader, ..., auto_reboot=True) + load_firmware # - KL520 v2.2 → v2.1: 同上但用不同 .bin # - KL720: 同上(KL720 是 flash-based、要寫 flash) # - KL630/KL730: 用 update_*_from_tar API(M9-6 驗證後填) # ... # Stage 5: verify # disconnect → sleep 3s → rescan → 看 firmware 字串 / kn_number 是否符合預期 # ... return { "status": "downgraded", "before_version": before_version, "after_version": after_version, "duration_ms": duration, "stage": "done", } ``` ### 2.3 API endpoint 設計 | Endpoint | Method | Request Body | Response | |----------|--------|-------------|----------| | `GET /api/devices/:id/firmware/versions` | GET | — | `{success:true, data:{versions:[...], current:"v2.2.0"}}` | | `POST /api/devices/:id/firmware/downgrade` | POST | `{version:"v2.1.0", confirmToken:"DOWNGRADE"}` | `202 {success:true, data:{taskId:"..."}}` | | WebSocket room `firmware:` | — | — | progress events(同 upgrade 流) | **`confirmToken` 設計**: - API 層要求 body 含 `confirmToken: "DOWNGRADE"`(字面字串) - 沒帶 / 帶錯 → API 直接 400 - **目的**:防止 CSRF、防止前端 bug 誤觸發、強制 UI 二次確認流程 - Frontend 必須先讓使用者輸入字面字串 / 點兩次按鈕、才把 token 加進 request ### 2.4 driver 層 safety guards `KneronDriver.DowngradeFirmware(version)` 內必做: 1. **不能跨晶片**:呼叫前驗 `version` 在 `ListFirmwareVersions()` 結果內、不接受任意字串 2. **不能升版偽裝**:比較 `version` 與 `GetCurrentFirmwareVersion()` 結果、若目標版本 >= current → 拒絕(要走升版 API) 3. **不能 no-op**:若 `version == current` → 拒絕(節省時間 + 避免 device 不必要 reset) 4. **status guard**:device 必須是 `StatusDetected` 或 `StatusConnected`、不能在 `StatusInferencing` / `StatusFlashing` / `StatusUpgrading` 期間降版(會卡 mutex) ```go // 偽碼 func (d *KneronDriver) DowngradeFirmware(version string, progressCh chan<- driver.FirmwareProgress) error { d.mu.Lock() if d.info.Status == driver.StatusInferencing || d.info.Status == driver.StatusFlashing { d.mu.Unlock() return fmt.Errorf("device busy: %s", d.info.Status) } chip := d.chipType current := d.info.FirmwareVer d.mu.Unlock() // Validate version exists versions, err := d.ListFirmwareVersions() if err != nil { return err } var targetVer *driver.FirmwareVersion for _, v := range versions { if v.Version == version { targetVer = &v break } } if targetVer == nil { return fmt.Errorf("version %s not found in bundled firmware for %s", version, chip) } // Validate it's actually a downgrade(順序比較邏輯 chip-specific) if !isOlderVersion(version, current, chip) { return fmt.Errorf("target version %s is not older than current %s", version, current) } // 進實際降版流程 // ... 跟 UpgradeFirmware 類似、call bridge.py firmware_downgrade ... } ``` --- ## 3. 多版本 firmware 儲存結構(B2 細化) ### 3.1 完整目錄結構 ``` server/scripts/firmware/ ├── KL520/ │ ├── current/ ← 預設 firmware(A 階段位置 = MVP 既有) │ │ ├── fw_scpu.bin ← KDP2 v2.2.0 │ │ ├── fw_ncpu.bin │ │ ├── fw_loader.bin ← KDP1→KDP2 升級用(A 階段補進來) │ │ └── VERSION ← "v2.2.0" │ ├── v2.2.0/ ← 跟 current 同內容、用於版本切換 reference │ │ ├── fw_scpu.bin │ │ ├── fw_ncpu.bin │ │ ├── fw_loader.bin │ │ └── VERSION │ ├── v2.1.0/ ← 舊版本(如果決定 bundle) │ │ ├── fw_scpu.bin │ │ ├── fw_ncpu.bin │ │ ├── fw_loader.bin │ │ └── VERSION │ └── kdp1/ ← KDP1 降版 │ ├── fw_scpu.bin │ ├── fw_ncpu.bin │ └── VERSION ← "KDP1"(特殊標記、不是 semver) ├── KL720/ │ ├── current/ ← v2.2.0 │ ├── v2.2.0/ │ └── v2.1.0/ ← 視是否 bundle ├── KL630/ │ ├── current/ ← SDK-v2.5.7 │ │ ├── kp_firmware.tar │ │ ├── kp_loader.tar │ │ ├── extracted/ ← build time 解壓(如選策略 Y) │ │ │ ├── fw_scpu.bin │ │ │ └── fw_ncpu.bin │ │ └── VERSION ← "SDK-v2.5.7" │ └── SDK-v2.4.0/ ← 視是否 bundle 舊 SDK │ └── ... └── KL730/ ├── current/ ← SDK-v1.3.0 │ ├── kp_firmware.tar │ ├── kp_loader.tar │ ├── extracted/ │ └── VERSION └── ... ``` ### 3.2 `current/` 是什麼 **選項 A:symbolic link** → `current/` 是 symlink 指向 `v2.2.0/` - 優點:節省空間(不重複 binary) - 缺點:Windows symbolic link 需要 admin 權限、`tar`/zip 壓縮對 symlink 處理各 OS 不同 - **不推薦**(跨平台 symlink 太脆弱) **選項 B:實體副本** → `current/` 是 `v2.2.0/` 的 file copy - 優點:跨平台、簡單 - 缺點:每個 chip 多佔 1 份(KL520 ~100KB、KL720 ~250KB、KL630/KL730 ~3-4MB)、合計 ~7-8MB 額外 - **推薦** **選項 C:取消 `current/`、用 metadata 記錄當前版本** - 結構簡化:`firmware//{v2.2.0,v2.1.0,kdp1}/` + `firmware//CURRENT_VERSION`(單行檔 = "v2.2.0") - 優點:節省空間、structure 清晰 - 缺點:bridge.py 多一次 file read 才知道用哪個版本(trivial 成本) - **可選**(架構乾淨度 vs 簡單度的 trade-off) **建議選 C**——架構最乾淨、空間最省、跨平台無 symlink 風險。 ### 3.3 採選項 C 的具體結構 ``` server/scripts/firmware/ ├── KL520/ │ ├── CURRENT_VERSION ← 單行檔:"v2.2.0" │ ├── v2.2.0/ │ │ ├── fw_scpu.bin │ │ ├── fw_ncpu.bin │ │ ├── fw_loader.bin │ │ └── VERSION ← "v2.2.0" │ ├── v2.1.0/ │ │ └── ... │ └── kdp1/ │ └── ... ├── KL720/ │ └── ...同結構... ├── KL630/ │ └── ...同結構(.tar / extracted)... └── KL730/ └── ... ``` `_resolve_firmware_paths_versioned(chip, version=None)`: - `version=None` → 讀 `CURRENT_VERSION` 拿當前版本 → 找 `//` - `version="v2.1.0"` → 直接找 `/v2.1.0/` **A 階段(MVP)相容性**:A 階段 `_resolve_firmware_paths(chip)` 找 `/fw_scpu.bin`、B2 階段重整目錄時、要把 A 階段檔案搬進 `/v2.2.0/` 並加 `CURRENT_VERSION`。**A 階段的 caller 必須升級成 `_resolve_firmware_paths_versioned`**(B2 migration step)。 --- ## 4. Bundle 多版本對安裝包大小的精確估算 ### 4.1 預估 bundle 策略 | Chip | current | 額外 bundled | 合計(每 chip)| |------|---------|------------|--------------| | KL520 | v2.2.0 (~100KB) | v2.1.0 (~100KB) + kdp1 (~90KB) | ~290KB | | KL720 | v2.2.0 (~250KB) | v2.1.0 (~250KB)(如果 bundle) | ~500KB | | KL630 | SDK-v2.5.7 (~3MB) | SDK-v2.4.0 (~3MB)(如果 bundle)+ extracted (~3MB) | ~9MB | | KL730 | SDK-v1.3.0 (~4MB) | + extracted (~4MB) | ~8MB | **合計 B2 階段所有 bundled firmware**:~18MB(多版本全部 bundle) ### 4.2 與既有 dmg 163MB 比 | 階段 | 累計 | 衝擊 | |------|------|------| | 既有 | 163MB | baseline | | A 階段(MVP)| 163MB + 10KB | <0.01% | | B 階段(單版本 KL630/KL730 + 降版用 KL520_kdp)| 163MB + 6-8MB ≈ 170MB | +4-5% | | B2 階段(每 chip 多 bundle 1 舊版) | 163MB + 14-18MB ≈ 178-181MB | +9-11% | → **使用者「+5MB 接受」對 B 階段成立、但 B2 多版本超出**。 ### 4.3 取捨建議 | 策略 | 大小 | 取捨 | |------|------|------| | **保守**:每 chip 只 bundle current + 1 個降版選項(KL520 v2.1.0、其他不 bundle 舊版) | +7MB | 滿足使用者「+5MB 接受」邊緣、降版選擇變少 | | **完整**:每 chip current + 2 個舊版 | +18MB | 超出 +5MB 估算、但選擇豐富 | | **極簡**:只 bundle current + KDP1 降版(KL520 only) | +5MB | 嚴格 +5MB、其他 chip 沒降版選擇 | **建議選保守策略**——KL520 提供「kdp1」「v2.1.0」兩個降版、KL720/KL630/KL730 提供 current + 1 個舊版(如有可用)。 **重要前提**:因 SDK release 不一定提供舊版 firmware、KL630/KL730 「舊版」可能取不到(M9-6 待驗證)。 --- ## 5. Design Agent 需求清單(給 Orchestrator 派 Design 用) > **注意**:以下是 architect 標記的「**功能性需求**」、Design Agent 負責設計具體 wireframe / 視覺 / 文案。Architect 不畫設計稿、不寫文案。 ### 5.1 Settings → 韌體管理面板(新頁面) **功能需求**: - F1. 列出所有偵測到的 dongle、每張 dongle 一張卡片 - F2. 卡片顯示:dongle 名稱("KL520 #1")、kn_number、當前 FW 版本、bundled current 版本(讓使用者看出是否最新) - F3. 卡片內 actions: - 「升級到最新」按鈕(如果 current 與 bundled 不同) - 「切換 FW 版本」按鈕(永遠顯示、暴露多版本選擇) - F4. 「切換 FW 版本」展開後:版本 dropdown + 詳細說明(每個版本一段話)+ 警告語 + 「降版」按鈕 - F5. 二次確認 modal(見 §5.3) - F6. 降版進行中 progress UI(progress bar + 階段提示 + 不可中斷警告) - F7. 降版完成後 toast 通知 + 自動 rescan + 卡片更新到新版本 ### 5.2 Devices 頁面 FW badge(補強既有 A 階段) **功能需求**: - 既有:紅/黃/綠 badge 顯示 FW 健康度 - 新增:badge 旁加「⚙️」icon、點擊 deep-link 到 Settings → 韌體管理對應卡片 - 不在 Devices 頁放降版按鈕(避免誤觸) ### 5.3 二次確認 modal(最關鍵的 design 細節) **功能需求**(**design 要全部達成**): - D1. 警告語必須包含: - 「降版可能導致現有 model 無法運作」 - 「降版過程不可中斷、否則裝置可能損壞」 - 「降版完成後可能需要重新插拔裝置」 - 「請確認版本相容性、降版至 KDP1 的舊版會限制可用功能」 - D2. **使用者必須輸入字面字串「DOWNGRADE」確認**(防誤觸) - 不接受其他大小寫 - 輸入欄為空 / 不對時、確認按鈕 disabled - D3. 顯示 before/after 版本比對表(當前 vs 目標) - D4. 顯示預估時間(KL520 ~30s、KL720 ~180s、KL630/KL730 估計 ~60s) - D5. modal 不可被點外部關閉(必須點「取消」明確關閉) - D6. 「確認降版」按鈕視覺要 destructive(紅色 / 警告色) - D7. 確認後 modal 不關閉、直接切到「進行中」狀態(避免使用者誤以為什麼都沒發生) ### 5.4 「進行中」UI **功能需求**: - E1. progress bar(雖然底層是 stage-based、給使用者看百分比更直觀) - E2. 階段文字(連線中 / 載入引導程式 / 寫入韌體 / 驗證 / 完成) - E3. **不顯示「取消」按鈕**(降版不可中斷、有按鈕會誘惑使用者點) - E4. 顯示「請勿拔除裝置」persistent banner(不可關閉) - E5. 如果失敗、顯示 friendly error 訊息 + 「重新插拔裝置後重試」指引 ### 5.5 失敗復原 UX **功能需求**: - R1. 區分失敗類型: - 「升級時 device disconnect」→ 訊息:「裝置已斷開、請重新插入後重試」 - 「Firmware 損毀 / 寫入失敗」→ 訊息:「韌體寫入失敗、請聯絡客服」(罕見、可能 brick) - 「Timeout」→ 訊息:「升級時間過長、可能成功也可能未完成、請拔插裝置再 scan」 - R2. 失敗後不自動關閉 modal、給使用者讀完訊息再關 - R3. 提供「重試」按鈕(如果是可重試的 error) - R4. 提供「複製錯誤訊息」按鈕(給技術支援用) ### 5.6 i18n keys(給 frontend 估算) 預估 新增中英雙語 keys: - Settings 韌體管理頁標題、說明:~3 個 - 卡片內各欄位、按鈕:~8 個 - 二次確認 modal:~12 個 - 進行中 UI:~8 個 - 失敗訊息:~10 個 - toast / banner:~5 個 - **合計 ~46 個新 i18n keys** --- ## 6. 一般使用者誤觸降版 brick 風險評估 ### 6.1 風險矩陣 | 誤操作 | 機率 | 嚴重性 | 已緩解 | 殘餘風險 | |--------|------|-------|--------|---------| | 點錯按鈕(沒看清楚就降版)| 中 | 中 | 二次確認 + 輸入「DOWNGRADE」字串 | 低 | | 降版到不相容版本(KL520 KDP2 → KDP1,且使用者依賴 KDP2-only 功能)| 中 | 中 | 警告語明示 KDP1 限制 | 中(使用者沒讀完警告)| | 降版中拔 USB | 低 | 高(可能 brick)| persistent banner + 不可關 modal | 低 | | 降版中關 app | 低 | 中 | server graceful shutdown 機制(既有)| 低 | | 升級當降版(誤把新版降到舊版、反向)| 低 | 低 | driver guard:不允許目標版本 >= current | 已消除 | | 跨晶片誤匹配 | 極低 | 高 | driver guard:version 必須在 `ListFirmwareVersions(chip)` 內 | 已消除 | ### 6.2 殘餘風險最大的:使用者不讀警告就降版 **對策**: - 二次確認字串「DOWNGRADE」(不是 「OK」/「Yes」、強制使用者打字) - 視覺破壞性 button color(紅色) - 「進行中」期間 banner persist(不可關) 但**無法完全消除**——一般使用者場景下、總有人會無視警告。 ### 6.3 Plan B:給技術支援用的救磚 SOP 如果使用者真的把 dongle 弄 brick、需要: - DFUT.exe(Windows-only Qt 工具、可從 Kneron 拿) - 燒回 KDP2 standard firmware **不打包 DFUT.exe 進 visionA-local**(因為跨平台限制 + 30MB 額外大小 + 安全性考量)、但**提供 SOP 文件**(內部 wiki / docs/troubleshooting/brick-recovery.md)讓技術支援能幫忙。 --- ## 7. 法律 / 合規 考量(待釐清) 承前一份 R-FW-5:「打包 Kneron 官方 firmware 是否合法」。 **B2 階段新增變數**: - 多版本 bundle 含舊版 firmware(v2.1.0、KDP1)→ 法律問題加重? - 暴露給一般使用者降版 → Kneron 是否允許?(KDP1 是被棄用版本、Kneron 不一定希望使用者降回去) **建議**: - B 階段啟動前、與 Kneron 取得 firmware re-distribution **明確書面授權** - 授權內容必須包含:current + 舊版(v2.1.0 等)+ KDP1 - 如果 Kneron 不允許降版到 KDP1、調整 bundle 範圍 --- ## 8. 給 Orchestrator 的決策點清單 1. **使用者已決策手動降版面向一般使用者** → 本檔 §5 design 需求成立 2. **使用者已決策 FW 內嵌進安裝包、+5MB 接受** → 本檔 §4.3「保守」策略對齊 3. **使用者已決策不做線上更新通道** → 不實作 OTA、所有 firmware bundle 4. **待使用者裁決**: - 多版本目錄結構選 A/B/C?(建議 C、§3.2) - bundle 哪些舊版?(建議「保守」策略、§4.3) - KneronPLUS wheel 升級 vs 不升級?(M9-6 驗證後決定) - 與 Kneron 取得 firmware redistribution 授權的時程? --- ## 9. 與其他研究檔的關係 | 連結 | 引用 | |------|------| | `30-integration-plan.md` §6 階段 B 評估提示 | 本檔詳化 B2 階段細節 | | `40-b-phase-kl630-kl730-extension.md` §8 多版本 firmware 並存 | 本檔 §3 詳化儲存結構 | | `40-b-phase-kl630-kl730-extension.md` R-FW-11/R-FW-12 | 本檔 §5/§6 提供緩解措施 | | `41-tar-firmware-handling.md` §3 大小估算 | 本檔 §4 整合計算 | --- ## 10. 工時影響補充 B 階段拆三層之後、本檔內容對應 M9-11 + M9-12 兩個 milestone: | Milestone | 本檔 § | 工時 | |-----------|--------|------| | M9-11 多版本後端 | §2.1 ~ §2.4 + §3 + §4 | 1.5 人天 | | M9-12 降版 UI | §5 + §6 + §7 | Frontend 1 人天 + Design 1 人天 = 2 人天 | | **合計** | | **3.5 人天** | (已含在 §40 B 階段 M9-6 ~ M9-13 工時表內、本檔不重複加總)