feat(t210): 首頁改統計,不再逐檔解釋——Evan「9000/101/20 兜不起來」的病

地基(progress.go 的 SyncProgress/ClassifyFailure/BuildFailureBreakdown,
eeb35ba)已經算好「總量」與「失敗分類」,但沒人接到畫面上:首頁仍在用
本輪計數(extractedOK)+逐檔白話翻譯(humanizeFailure),導致 leo 08-08
轉述的病——三個數字互相對不起來,使用者無法判斷「還在跑」還是「壞了」。

接線(collector/direct.go):
- runDirectOnceRoot 現在也回傳這一根資料夾的 SyncProgress/StuckReasons
  (rootProgress),RunDirectOnce 跨帳號跨資料夾 Add() 累加成總量。
- G-6.2 的 SkippedDocCount(讀不了的檔,根本沒進 manifest)併進
  Unreadable/Total——這是 Progress() 算不到的部分,由呼叫端補齊,
  維持不變式 Total == Done+Pending+Stuck+Unreadable。
- 「送不上去」的分類統計=Stuck 的 LastError 原文+Unreadable 重用
  convert.go 既有的 ErrUnsupported,一起餵給 BuildFailureBreakdown。
  分類判斷全程只經過 progress.go 的 ClassifyFailure 一個接縫,
  direct.go/app.go/前端都不認得任何分類名稱字串(留給 t214 之後
  改資料驅動時只動一個檔)。
- SyncStatus 新增 Progress/FailureBreakdown 兩個欄位,兩者都是每輪從
  manifest/掃描結果原地重算的現況快照,不進 CarryForwardActivity——
  斷網或閒置一輪不會被清成 0。

畫面(cmd/arcrun-app/app.go+frontend/src/main.js):
- 移除 humanizeFailure/buildFailures/UIFailures 那套逐檔白話翻譯,
  改用 UIProgress(Total/Done/Pending/CantSync/Groups);前端 cardProgress
  只把後端給的 category/count 陣列原樣印出,不分支、不排序、不認分類名。
- 「送不上去」預設摺疊(<details>),展開只有分類與份數,不逐檔列名、
  不解釋、不給解法;細節導向「開啟使用說明」。
- 保留 buildSkipped 的「讀不了的檔」卡片(那是另一件事),但份數已併入
  Unreadable。
- 移除「總計」卡片裡用本輪計數 extractedOK 的「份已整理」——上方狀態
  時間軸的「上次 N 份」與下方矛盾(1 份 vs 0 份已整理)的病因直接消掉,
  改用 cardProgress 的累計「已送上去」。

驗證:
- go build ./... 與 go test ./...(collector/cmd/arcrun-app/
  cmd/arcrun-tray 三個 module)全綠。
- 新增 collector/progress_wiring_test.go:真跑 RunDirectOnce 湊出
  Done/Pending/Stuck/Unreadable 四種狀態同時存在,驗四數字相加等於
  總數(leo 驗法①);再跑一輪「什麼都沒發生」驗數字不歸零(驗法③)。
- check-render.sh 視覺機械閘綠(lockup 底板/深色模式)。

殘項(誠實標記,未完成):
- 真機驗收(leo 08-08 驗法④:拿 Evan 的情境走一遍)未做,需要 leo 或
  封測者在實機驗證。
- 「開啟使用說明」目前連到既有 docs 首頁,尚無 t210 分類對應的 FAQ 頁
  (tasks.md 已記為相依項)。
- t213 診斷檔尚未消費這組新欄位(tasks.md 記載該任務等本任務讓路)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 12:45:12 +08:00
parent 1efc8123f1
commit b3f63f40fb
6 changed files with 333 additions and 170 deletions
+52 -7
View File
@@ -578,6 +578,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
skippedOther := 0
var skippedOtherNames []string
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
var totalProgress SyncProgress
var stuckReasons []string
accountDetails := map[string]AccountSyncStatus{}
for _, acc := range accounts {
if acc.CypherURL == "" || acc.Namespace == "" {
@@ -627,7 +631,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
multi := len(accCfg.Folders()) > 1
for _, root := range accCfg.Folders() {
r, e, p := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
totalProgress = totalProgress.Add(rp.Progress)
stuckReasons = append(stuckReasons, rp.StuckReasons...)
if multi {
for i := range r {
r[i].Root = root
@@ -718,6 +724,23 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
if len(st.SkippedDocs) > MaxSkippedListed {
st.SkippedDocs = st.SkippedDocs[:MaxSkippedListed] // 總數仍在 SkippedDocCountUI 說「等 N 個」
}
// t210Unreadable 由呼叫端補進來(progress.go 的欄位註解)——G-6.2 的「讀不了的檔」
// 根本沒進過 manifestProgress() 算不到它們。併進 Total 才守得住 leo 08-08 驗法①
// 「四個數字相加等於總數」(不變式:Total == Done+Pending+Stuck+Unreadable)。
st.Progress = totalProgress
st.Progress.Unreadable = st.SkippedDocCount
st.Progress.Total += st.Progress.Unreadable
// 「送不上去」=StuckUnreadable 的分類統計——Stuck 那些的真因已在 stuckReasons
// Unreadable 那些**本來就是**格式不支援(G-6.2 掃描白名單擋下的),直接重用
// convert.go 的 ErrUnsupported 原文過同一個 ClassifyFailure 入口,不在這裡另造
// 分類字串(t214 之後要把分類改成資料驅動,也只需要動 ClassifyFailure 一個接縫)。
failReasons := append([]string{}, stuckReasons...)
for i := 0; i < st.Progress.Unreadable; i++ {
failReasons = append(failReasons, ErrUnsupported.Error())
}
st.FailureBreakdown = BuildFailureBreakdown(failReasons)
// 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀)
if cfg.Extractor != "" {
for _, r := range results {
@@ -863,24 +886,34 @@ func accountsConnected(cfg *DirectConfig) bool {
return false
}
// rootProgress=單一資料夾(一份 manifest)算出來的 t210 統計素材。
//
// 為什麼跟 Progress 綁在一起回傳:兩者都是同一份 Manifest 快照的產物,
// 呼叫端(RunDirectOnce)要跨帳號、跨資料夾把它們累加成總量,缺一不可
// ——Progress 少了 StuckReasons 就湊不出「送不上去」展開後的分類統計。
type rootProgress struct {
Progress SyncProgress // 這一根的 Total/Done/Pending/StuckUnreadable 由呼叫端補,見 progress.go
StuckReasons []string // 已放棄自動重試那些條目的 LastError 原文,交給 ClassifyFailure 分類
}
// qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。
// runNow:整輪 RunDirectOnce 共用的時間點(排序/冷卻判斷一致、好測試)。
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaState, runNow time.Time) ([]DirectResult, int, *TriggerPayload) {
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaState, runNow time.Time) ([]DirectResult, int, *TriggerPayload, rootProgress) {
results := []DirectResult{}
exit := 0
absRoot, err := filepath.Abs(root)
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
}
absManifest, err := filepath.Abs(cfg.manifestPathFor(absRoot))
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
}
cfg.migrateManifestIfNeeded(absRoot, absManifest) // t86b:一次性遷移舊格式帳本
m, err := LoadManifest(absManifest, absRoot)
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
}
// 2026-08-07 task 3Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意,
// 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。
@@ -903,7 +936,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
SkipDirNames: map[string]bool{"system-dev": true},
})
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
}
now := runNow.Unix()
@@ -1167,7 +1200,19 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
exit = 1
}
}
return results, exit, payload
// t210manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新),
// 原地數一次就是對的(同 SkippedDocCount 那套「現況快照」邏輯,不必另外維護計數器)。
rp := rootProgress{Progress: m.Progress()}
for _, e := range m.Entries {
if e != nil && e.FailCount >= MaxFailBeforeSkip {
// LastError 原文交給呼叫端彙總後過 ClassifyFailure——分類判斷只住那一個接縫,
// 這裡不判斷任何識別字,即使 LastError 是空字串也照樣送(ClassifyFailure("") 落「其他」,
// 不會漏算份數)。
rp.StuckReasons = append(rp.StuckReasons, e.LastError)
}
}
return results, exit, payload, rp
}
// runDirect 是 `collector direct` 子命令主體。