# 模型上傳流程 — visionA Cloud
> 雲端版新增流程。使用者上傳 Kneron 編譯後的 `.nef` 模型檔到雲端 object storage,之後才能選模型燒錄到遠端裝置。
>
> **技術背景**(給 Design 協作者):模型檔可能達 100MB,若走 tunnel / API server 會拖慢服務。Architect 決策走 **presigned PUT**:前端向後端要一組有期限的 PUT URL,前端直接把檔案 PUT 到 object storage(S3 / R2 / GCS),不經過 API server 也不經過 local agent tunnel。進度 / 失敗偵測靠瀏覽器 `XMLHttpRequest.upload.onprogress`。
>
> 對應頁面:`/models`(入口) + `UploadModelDialog`(模態框,或走獨立 `/models/upload` 頁,見 6 節決策)
>
> 配套文字版 wireframe:`wireframes/wf-model-upload.md`(本次一併建立概念,實作期補圖)
---
## 1. User Story
> **作為** 一個 Kneron 開發者,
> **我想要** 把本地編好的 `.nef` 模型檔上傳到雲端,
> **這樣** 我就能在任何裝置、任何地方把這個模型燒錄到 Kneron 硬體上。
**成功條件:**
- 100MB 雛形上限內的檔案可在 3G 以上網速下 60 秒內完成
- 上傳失敗能明確告訴使用者原因(網路、過期、大小、格式)並可**續傳 / 重試**
- 上傳中不阻塞其他頁面操作(背景上傳)
---
## 2. 範圍與限制(Phase 0 雛形)
| 項目 | Phase 0 | Phase 1+ |
|------|---------|---------|
| 單檔最大 | **100 MB**(前端硬限) | 2 GB + 分段上傳 |
| 副檔名 | **`.nef`** 限定(前端驗證) | `.nef` / `.onnx` / 其他 |
| 同時上傳數 | 1(雛形不支援佇列) | N 個並行 |
| 中途暫停 | 不支援 | 支援 |
| 續傳 | 失敗後全檔重傳 | 分段續傳 |
| 取消 | 支援(abort XHR) | 同 |
| 背景上傳 | 不支援(離開頁面 = 中斷)| 支援(Service Worker) |
| 病毒掃描進度 | 不顯示(後端非同步處理) | 顯示掃描狀態 |
---
## 3. 完整流程
```
使用者在 /models 按「上傳模型」
↓
開啟 UploadModelDialog
↓
┌────────────────────────┐
│ 步驟 A · 選檔 + 填 meta │
│ - 檔案 (.nef) │
│ - 名稱、版本、備註 │
└────────────────────────┘
↓ 按「開始上傳」
↓
前端驗證:
- 副檔名 .nef
- 大小 ≤ 100 MB
- 必填欄位
驗證失敗 → 顯示 error,不發 API
↓
POST /api/models/upload-url
body: { filename, size, contentType, metadata }
→ 後端產 presigned PUT URL(TTL 15 分鐘)
↓
前端 PUT file 直接到 storage URL(不經 API server)
- XHR.upload.onprogress → 更新進度條
- 可 abort
↓
成功 (HTTP 200) 失敗
↓ ↓
POST /api/models/confirm 顯示錯誤原因
body: { uploadId, etag } → 重試 / 重新取 URL
→ 後端驗證 checksum、寫入 DB
↓
Toast「✓ 模型 {name} 已上傳」
關閉 Dialog,回到 /models 列表(新模型顯示在頂部)
↓
後端非同步掃毒 / 解析 metadata → 狀態更新(WebSocket push)
```
---
## 4. UI 設計
### 4.1 入口(`/models` 頁面)
```
┌────────────────────────────────────────────────┐
│ 模型庫 │
│ 管理雲端上的 Kneron 模型 │
│ │
│ [📤 上傳模型] │
├────────────────────────────────────────────────┤
│ ModelCard · ModelCard · ModelCard ... │
└────────────────────────────────────────────────┘
```
「上傳模型」按鈕:
- `variant=default size=default`
- 右上角(既有版型)
- 圖示:`Upload` (Lucide)
### 4.2 UploadModelDialog — 選檔階段
```
┌─────────────────────────────────────────────────┐
│ 上傳模型 [✕] │
├─────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ │ │
│ │ 📁 拖曳 .nef 檔到此處 │ │
│ │ │ │
│ │ 或 [選擇檔案] │ │
│ │ │ │
│ │ 支援格式:.nef · 最大 100 MB │ │
│ │ │ │
│ └───────────────────────────────────────────┘ │
│ (bg-muted/50 border-2 border-dashed, h-48) │
│ │
│ 已選檔案: │
│ ┌───────────────────────────────────────────┐ │
│ │ 📄 yolov5s_kl520.nef │ │
│ │ 47.3 MB · 修改於 2026-04-21 │ │
│ │ [✕ 移除] │ │
│ └───────────────────────────────────────────┘ │
│ (選好後才出現;hover 淡 bg-accent) │
│ │
│ 模型名稱 * │
│ ┌───────────────────────────────────────────┐ │
│ │ YOLOv5s KL520 │ │
│ └───────────────────────────────────────────┘ │
│ (預設帶入檔名去副檔名,可編輯) │
│ │
│ 版本 │
│ ┌───────────────────────────────────────────┐ │
│ │ v1.0 │ │
│ └───────────────────────────────────────────┘ │
│ │
│ 備註(選填) │
│ ┌───────────────────────────────────────────┐ │
│ │ 針對戶外停車場場景訓練 │ │
│ └───────────────────────────────────────────┘ │
│ (Textarea, rows=3) │
│ │
│ [取消] [開始上傳] │
└─────────────────────────────────────────────────┘
```
**規格:**
- Dialog `max-w-lg`
- Drop zone:`h-48 border-2 border-dashed rounded-lg bg-muted/50`,hover / drag over 時 `border-primary bg-primary/5`
- ``
- 「開始上傳」:檔案未選 / 必填未填 → disabled
### 4.3 UploadModelDialog — 上傳中
```
┌─────────────────────────────────────────────────┐
│ 上傳中 [取消] │
├─────────────────────────────────────────────────┤
│ │
│ 📄 yolov5s_kl520.nef │
│ │
│ ████████████████████░░░░░░░░░░ 62% │
│ (Progress 元件, h-2, bg-primary) │
│ │
│ 28.5 MB / 47.3 MB · 3.4 MB/s · 剩餘約 6 秒 │
│ (text-sm text-muted-foreground) │
│ │
│ ⓘ 請勿關閉此視窗或導航到其他頁面 │
│ │
└─────────────────────────────────────────────────┘
```
**規格:**
- Progress bar:既有 `Progress` 元件
- 百分比:`XHR.upload.onprogress` 算 `loaded / total`
- 速度:滑動視窗(最近 3 秒的 `loaded` 差 / 時間差)
- 剩餘時間:`(total - loaded) / speed`;< 5 秒顯示「即將完成」
- Dialog `onOpenChange` 被阻擋:正在上傳時 ESC / 點外面**不**關 dialog(防誤觸);使用者只能按「取消」
- 「取消」:AlertDialog「確定要取消上傳?已傳的資料會作廢。」→ 確認後 `xhr.abort()`
### 4.4 UploadModelDialog — 成功
```
┌─────────────────────────────────────────────────┐
│ │
│ ✓ │
│ (CheckCircle2, green, h-16) │
│ │
│ 上傳完成 │
│ │
│ yolov5s_kl520.nef (47.3 MB) │
│ │
│ 模型即將進入安全掃描,完成後可燒錄 │
│ │
│ [完成] │
└─────────────────────────────────────────────────┘
```
- 按「完成」關 Dialog,toast「✓ 模型 YOLOv5s KL520 已上傳」
- 列表頂部插入新模型,狀態 badge「掃描中」(Phase 1 精緻化)
### 4.5 UploadModelDialog — 錯誤狀態
不同錯誤顯示不同文案與行動:
| 錯誤 | 文案 | CTA |
|------|------|-----|
| 副檔名錯誤(前端擋)| 「只支援 .nef 檔案,你選的是 .onnx」| [重新選檔] |
| 超過大小(前端擋)| 「檔案太大({size} MB),最大允許 100 MB」| [重新選檔] |
| 取不到 presigned URL | 「伺服器忙碌,請稍後再試」| [重試] [取消] |
| Presigned URL 過期(PUT 403)| 「上傳授權已過期,重新取得中...」→ 自動重拿 URL 重傳(僅 1 次) | 自動 |
| 網路中斷 | 「網路中斷,上傳已暫停」| [繼續上傳](雛形=從頭重傳) [取消] |
| Storage 回 5xx | 「儲存空間暫時無法回應,請稍後再試」| [重試] [取消] |
| Confirm API 失敗 | 「上傳完成,但伺服器確認失敗,請重新上傳」| [重新上傳] |
| 取消(使用者觸發)| 不顯示錯誤 | 關 Dialog |
錯誤視覺:
```
┌─────────────────────────────────────────────────┐
│ ⚠ 上傳失敗 │
│ │
│ yolov5s_kl520.nef │
│ │
│ 網路中斷,上傳已暫停 │
│ │
│ 已上傳 28.5 MB / 47.3 MB(62%) │
│ │
│ [取消] [重試] │
└─────────────────────────────────────────────────┘
```
---
## 5. 互動細節
### 5.1 拖曳上傳
- Drop zone 接受 `.nef`,其他格式:drop 後立刻顯示錯誤
- 整個視窗偵測 `dragenter` 顯示 drop zone 放大提示(`ring-2 ring-primary`)
- `dragleave` / drop 後移除 highlight
### 5.2 離開頁面的防護
- 上傳中 `window.onbeforeunload` 提示「上傳進行中,確定要離開嗎?」
- 使用者同意 → abort XHR,資料作廢
### 5.3 重複檔名
- 若同名模型已存在:Dialog 送出時顯示確認「已存在同名模型,要新增版本還是覆蓋?」
- **新增版本**:送出時把版本欄位自動 `+1`(Phase 0 簡化為使用者自己改版本欄)
- **覆蓋**:後端支援後再做(Phase 1)
### 5.4 模型卡片上的新狀態
`ModelCard` 新增狀態 badge(Phase 0 簡單版,Phase 1 強化):
| 狀態 | Badge | 可操作 |
|------|-------|--------|
| uploading(僅上傳期間)| 🔵 上傳中 | 不顯示於列表(Dialog 內)|
| scanning | 🟡 掃描中 | 不可燒錄 |
| ready | 🟢 可用 | 可燒錄 |
| rejected | 🔴 檢測失敗 | 可刪除、不可燒錄 |
---
## 6. 流程 vs 頁面 — Dialog vs Page 決策
**採用:Dialog**(在 `/models` 觸發)
理由:
- 雛形只支援單檔上傳,Dialog 夠用
- 保留使用者所在頁面上下文,上傳完可以立刻看到列表
- 獨立頁面 `/models/upload` 留到 Phase 1 支援多檔 / 佇列時再做
URL 不變(不用 router);Dialog 的開關用 Zustand store 狀態管理(`uploadStore`),方便未來移到全域 FAB。
---
## 7. API 契約(給 Backend / Architect)
### 7.1 `POST /api/models/upload-url`
```json
Request:
{
"filename": "yolov5s_kl520.nef",
"size": 49573888,
"contentType": "application/octet-stream",
"metadata": {
"name": "YOLOv5s KL520",
"version": "v1.0",
"notes": "針對戶外停車場場景訓練"
}
}
Response 200:
{
"uploadId": "upl_01HXXXX",
"putUrl": "https://storage.visiona.ai/models/upl_01HXXXX?X-Amz-Signature=...",
"expiresAt": "2026-04-21T14:45:00Z",
"headers": {
"Content-Type": "application/octet-stream",
"x-amz-meta-model-name": "YOLOv5s KL520"
}
}
```
### 7.2 前端直送 storage
```
PUT {putUrl}
Content-Type: application/octet-stream
body: (file binary)
```
### 7.3 `POST /api/models/confirm`
```json
Request:
{
"uploadId": "upl_01HXXXX",
"etag": ""
}
Response 200:
{
"model": {
"id": "mdl_...",
"name": "YOLOv5s KL520",
"status": "scanning",
...
}
}
```
---
## 8. 無障礙
- Dialog:既有 shadcn 已處理焦點陷阱與 ESC(上傳中 ESC 被攔截)
- Drop zone:`role="button" tabIndex={0}`,Enter / Space 開啟檔案選擇器
- Progress:`role="progressbar" aria-valuenow aria-valuemin aria-valuemax`
- 錯誤訊息:`role="alert"`
- 成功:`role="status" aria-live="polite"`
- 不只靠顏色:Progress 旁顯示百分比文字,錯誤有圖示
---
## 9. i18n key
```
models.upload.button → 上傳模型
models.upload.dialog.title → 上傳模型
models.upload.dropzone.label → 拖曳 .nef 檔到此處
models.upload.dropzone.or → 或
models.upload.dropzone.browse → 選擇檔案
models.upload.dropzone.hint → 支援格式:.nef · 最大 100 MB
models.upload.selectedFile → 已選檔案
models.upload.field.name → 模型名稱
models.upload.field.version → 版本
models.upload.field.notes → 備註(選填)
models.upload.action.remove → 移除
models.upload.action.cancel → 取消
models.upload.action.start → 開始上傳
models.upload.uploading.title → 上傳中
models.upload.uploading.hint → 請勿關閉此視窗或導航到其他頁面
models.upload.uploading.stats → {uploaded} / {total} · {speed} · 剩餘約 {eta}
models.upload.uploading.almostDone → 即將完成
models.upload.cancelConfirm.title → 確定要取消上傳?
models.upload.cancelConfirm.desc → 已傳的資料會作廢
models.upload.success.title → 上傳完成
models.upload.success.scanHint → 模型即將進入安全掃描,完成後可燒錄
models.upload.error.invalidType → 只支援 .nef 檔案,你選的是 {type}
models.upload.error.tooLarge → 檔案太大({size}),最大允許 100 MB
models.upload.error.requiredField → {field} 為必填
models.upload.error.urlFailed → 伺服器忙碌,請稍後再試
models.upload.error.urlExpired → 上傳授權已過期,重新取得中...
models.upload.error.networkLost → 網路中斷,上傳已暫停
models.upload.error.storage5xx → 儲存空間暫時無法回應,請稍後再試
models.upload.error.confirmFailed → 上傳完成,但伺服器確認失敗,請重新上傳
models.upload.error.retry → 重試
models.upload.error.resume → 繼續上傳
models.upload.toast.uploaded → ✓ 模型 {name} 已上傳
models.upload.leaveWarning → 上傳進行中,確定要離開嗎?
```
---
## 10. 響應式
| 斷點 | 調整 |
|------|------|
| Mobile (< 640px) | Dialog 改 `max-w-full h-full rounded-none`(近似全螢幕);Drop zone `h-36` |
| Tablet / Desktop | `max-w-lg` 居中 |
---
## 11. TODO(Phase 1+)
| 項目 | 時機 |
|------|------|
| 多檔佇列上傳 | Phase 1 |
| 分段上傳(resumable, > 100MB)| Phase 1 |
| 背景上傳(Service Worker 持續)| Phase 2 |
| 獨立 `/models/upload` 頁 | Phase 1(有佇列時)|
| 上傳歷史 / 失敗重試列表 | Phase 2 |
| 拖曳排序上傳順序 | Phase 2 |
| 病毒掃描詳細進度 | Phase 1 |