un2266 Claude Opus 4.8 commited on
Commit
ab5ce10
·
1 Parent(s): 5668fa5

新增整包下載:導覽列四顆下載捷徑 + beforeunload 防手滑 + 修復重置後情境卡片失效

Browse files

整包下載(需求文件缺口 1,原訂只做假按鈕,改為直接實作完整後端):
- 新增 _ZIP_GROUPS/_make_result_zips(),把產出分成指標數據/統計圖表/
研究報告/完整分析結果四包 zip
- 呼叫點放在 _populate_tabs 而非運算流程:結果頁提供下載的是改過名的
複本(TransPrism_LLM Report_*.docx)且刻意排除原始 .txt,重新命名就
發生在 _populate_tabs 裡,在那裡打包才能保證 zip 內容與使用者在頁面上
看到的檔案一致,也不用把「哪些檔案是給使用者的」寫兩遍
- 內容為空的那包不產生,對應按鈕就不出現(LLM 報告失敗時「下載研究報告」
不會亮出來);只有一組有內容時不另外產生整包,避免兩顆按鈕內容一樣
- nav-col 底部新增四顆 gr.DownloadButton,接在 _TAB_OUTPUTS 尾端 26-29
- _populate_tabs 開頭明確把這四顆設回隱藏:其他結果元件都住在 canvas_*_col
裡,重置時整個 Column 被藏起來看不到殘值;這四顆住在 result_nav_col,
而它會在下次選定情境時重新顯示,不歸零的話上一輪的 zip 會跟著冒出來
- state 的 zip_path(從專案第一天就只被初始化成 None)改成 zip_paths 字典
- 新增 download.svg(照既有規格:24x24、stroke #475569、stroke-width 1.75)

防手滑:
- 工作區顯示期間掛上 beforeunload,回到首頁自動解除
- 兩個瀏覽器層面的限制寫進註解:需先與頁面互動確認框才會出現、提示文字
無法自訂

順帶修復測試中發現的既有 bug(已用 git stash 在原版程式碼上重現確認,非本次
改動造成):按「開始新分析」回到首頁後,情境卡片點了完全沒反應,必須重新
整理頁面才能再選,等於 Bug1「選錯情境無法變更」的修復只有一半。根因是
homepage_col 設成 visible=False 時 Svelte 會銷毀整個子樹而非加 display:none,
重新顯示時是全新節點,原本掛在 #homepage_scenario_grid 上的 listener 隨舊
節點消失,而綁定只在開頁後 3 秒內跑過四次、之後不再重試。改用 document
事件代理,跟 bindResultNav() 同一套解法。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (2) hide show
  1. app_agent.py +146 -14
  2. assets/icons/download.svg +1 -0
app_agent.py CHANGED
@@ -1368,7 +1368,9 @@ def make_initial_state(session_id: str) -> dict:
1368
  "invalid_info": {},
1369
  "cleaned_files": [],
1370
  "output_files": [],
1371
- "zip_path": None,
 
 
1372
  "user_metadata": "",
1373
  "user_focus": "",
1374
  "analysis_result": None,
@@ -1594,6 +1596,56 @@ def run_format_check(state: dict):
1594
  return msg, state, True
1595
 
1596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1597
  def run_computation_pipeline_streaming(state: dict):
1598
  """
1599
  執行完整計算流程(指標計算 → 統計分析 → LLM 報告),
@@ -3436,19 +3488,23 @@ _APP_HEAD_SCRIPT = """
3436
  gr.HTML() 刻的純展示內容,點擊時模擬點擊對應的隱藏版原生按鈕
3437
  (sc_btn_1..4,見 create_app() 的 .sc-bridge-btn),Python 端
3438
  on_scenario_pick 的 .click() 綁定完全不用改 */
3439
- function bindScenarioCards() {
3440
- var grid = document.getElementById('homepage_scenario_grid');
3441
- if (!grid || grid._bound) return;
3442
- grid._bound = true;
3443
- grid.addEventListener('click', function (e) {
3444
- var card = e.target.closest('.scenario-card');
3445
- if (!card) return;
3446
- var n = card.getAttribute('data-scenario');
3447
- var btn = document.getElementById('sc_btn_' + n);
3448
- if (btn) btn.click();
3449
- });
3450
- }
3451
- [300, 800, 1500, 3000].forEach(function(t) { setTimeout(bindScenarioCards, t); });
 
 
 
 
3452
 
3453
  /* 2026-07-23:頁面切換模型。nav-col 的每個項目現在是真正的
3454
  「頁面」——結果類項目(語言指標分析/統計比較分析/LLM輔助分析/
@@ -3747,6 +3803,29 @@ _APP_HEAD_SCRIPT = """
3747
  var page = mainRow && mainRow.getAttribute('data-page');
3748
  if (page) applyActivePage(page);
3749
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3750
  setInterval(function () {
3751
  _watchZone('upload_zone');
3752
  _watchZone('metadata_zone');
@@ -3754,6 +3833,7 @@ _APP_HEAD_SCRIPT = """
3754
  _initNavCollapseIfNeeded();
3755
  _initActivePageIfNeeded();
3756
  _reapplyActivePage();
 
3757
  }, 400);
3758
  }
3759
  if (document.readyState === 'loading') {
@@ -3976,6 +4056,35 @@ def create_app():
3976
  "LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"),
3977
  elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary",
3978
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3979
  # 2026-07-23:技術執行紀錄從手風琴轉正成獨立頁面(原本
3980
  # exec_log_accordion 在 canvas-col 底部,現在跟其他三個
3981
  # 結果頁同一種待遇)。用分隔線隔開,視覺上表達「低優先度
@@ -4209,6 +4318,8 @@ def create_app():
4209
  tab_imp_file, tab_cluster_file, # 23-24 新增下載按鈕(僅能往尾端加,
4210
  # 不可插入既有 index 中間,見程式工作守則)
4211
  canvas_exec_col, # 25 技術執行紀錄轉正成獨立頁面(2026-07-23)
 
 
4212
  ]
4213
  _N_TAB = len(_TAB_OUTPUTS)
4214
 
@@ -4226,6 +4337,13 @@ def create_app():
4226
  if state is None:
4227
  return R
4228
 
 
 
 
 
 
 
 
4229
  output_dir = Path(state["session_dir"]) / "output"
4230
  uploaded = state.get("uploaded_files", [])
4231
 
@@ -4354,6 +4472,20 @@ def create_app():
4354
  if dl_rpt:
4355
  R[19] = gr.update(value=dl_rpt, visible=True)
4356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4357
  # ── 顯示各分頁 Tab(index 20-22)──────────────────────────────
4358
  any_tab_ready = False
4359
  if csv_path.exists():
 
1368
  "invalid_info": {},
1369
  "cleaned_files": [],
1370
  "output_files": [],
1371
+ # 2026-07-24:原本是 zip_path(單數),從專案第一天就只被初始化成 None、
1372
+ # 從沒被賦值過。改成 dict 存四包 zip 的路徑,key 見 _ZIP_GROUPS。
1373
+ "zip_paths": {},
1374
  "user_metadata": "",
1375
  "user_focus": "",
1376
  "analysis_result": None,
 
1596
  return msg, state, True
1597
 
1598
 
1599
+ # ── 產出打包(zip)────────────────────────────────────────────────────────────
1600
+ # 使用者要把成果全部帶走,原本得點進三個結果頁各按各的下載鈕(共 15 個檔案)。
1601
+ # 這裡把產出分成四包,接到左側導覽列底部的下載捷徑。
1602
+ # key 同時是 zip 檔名(使用者下載後看到的名字),順序即導覽列上的顯示順序。
1603
+ #
1604
+ # 呼叫點刻意放在 _populate_tabs(結果整理)而非運算流程:結果頁提供下載的是
1605
+ # 改過名的複本(TransPrism_LLM Report_*.docx)且刻意排除原始 .txt,這些重新
1606
+ # 命名就發生在 _populate_tabs 裡。在那裡打包才能保證 zip 內容跟使用者在頁面上
1607
+ # 看到的檔案完全一致,也不用把「哪些檔案是給使用者的」這件事寫兩遍。
1608
+ _ZIP_GROUPS = [
1609
+ ("idx", "指標數據"),
1610
+ ("stat", "統計圖表"),
1611
+ ("report", "研究報告"),
1612
+ ("all", "完整分析結果"),
1613
+ ]
1614
+
1615
+
1616
+ def _make_result_zips(output_dir: Path, grouped: dict) -> dict:
1617
+ """把各組產出打包成 zip,回傳 {group_key: zip 路徑字串}。
1618
+
1619
+ grouped:{group_key: [檔案路徑, ...]},不含 "all"(由本函式自己合併)。
1620
+ 只打包真實存在的檔案;某組完全沒有檔案就不產生該包(對應的下載捷徑
1621
+ 也就不會出現),例如 LLM 報告失敗時「研究報告」那包不該憑空生出來。
1622
+ """
1623
+ targets = {k: [f for f in v if Path(f).exists()] for k, v in grouped.items()}
1624
+
1625
+ # 整包 = 各組去重合併。只有一組有東西時,整包跟那一組內容完全一樣,
1626
+ # 多給一顆按鈕只會讓人猶豫該點哪個,這種情況不另外產生整包。
1627
+ if sum(1 for v in targets.values() if v) >= 2:
1628
+ seen = set()
1629
+ targets["all"] = [f for v in targets.values() for f in v
1630
+ if not (f in seen or seen.add(f))]
1631
+
1632
+ zips = {}
1633
+ for key, label in _ZIP_GROUPS:
1634
+ files = targets.get(key) or []
1635
+ if not files:
1636
+ continue
1637
+ zip_path = output_dir / f"{label}.zip"
1638
+ try:
1639
+ with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zf:
1640
+ for f in files:
1641
+ zf.write(str(f), arcname=Path(f).name)
1642
+ zips[key] = str(zip_path)
1643
+ except Exception:
1644
+ # 打包失敗不該拖垮整個分析流程——各結果頁的單項下載仍然可用
1645
+ traceback.print_exc()
1646
+ return zips
1647
+
1648
+
1649
  def run_computation_pipeline_streaming(state: dict):
1650
  """
1651
  執行完整計算流程(指標計算 → 統計分析 → LLM 報告),
 
3488
  gr.HTML() 刻的純展示內容,點擊時模擬點擊對應的隱藏版原生按鈕
3489
  (sc_btn_1..4,見 create_app() 的 .sc-bridge-btn),Python 端
3490
  on_scenario_pick 的 .click() 綁定完全不用改 */
3491
+ /* 2026-07-24 修正:原本是把 listener 掛在 #homepage_scenario_grid
3492
+ 上、用 grid._bound 防重複,並在載入後 300/800/1500/3000ms 各綁
3493
+ 一次。實測發現按「開始新分析」回到首頁後,情境卡片點了完全沒
3494
+ 反應,必須重新整理頁面才能再選——因為 homepage_col 被設成
3495
+ visible=False 時,Svelte 是把整個子樹「銷毀」而不是加
3496
+ display:none(已用 devtools 確認:進工作區後 grid 節點直接從
3497
+ DOM 消失),重新顯示時是全新節點,_bound 留在已被丟棄的舊節點
3498
+ 上,而綁定只在開頁後那 3 秒內跑過,之後不會再重試。這讓 Bug1
3499
+ 「選錯情境無法變更」的修復等於只有一半。
3500
+ 改用事件代理掛在 document 上,跟 bindResultNav() 同一套解法:
3501
+ 不管卡片節點被銷毀重建幾次都不用重綁。 */
3502
+ document.addEventListener('click', function (e) {
3503
+ var card = e.target.closest && e.target.closest('.scenario-card');
3504
+ if (!card) return;
3505
+ var btn = document.getElementById('sc_btn_' + card.getAttribute('data-scenario'));
3506
+ if (btn) btn.click();
3507
+ });
3508
 
3509
  /* 2026-07-23:頁面切換模型。nav-col 的每個項目現在是真正的
3510
  「頁面」——結果類項目(語言指標分析/統計比較分析/LLM輔助分析/
 
3803
  var page = mainRow && mainRow.getAttribute('data-page');
3804
  if (page) applyActivePage(page);
3805
  }
3806
+ var _unloadGuardOn = false;
3807
+ function _onBeforeUnload(e) {
3808
+ e.preventDefault();
3809
+ e.returnValue = ''; /* 舊版瀏覽器需要,值本身不會被顯示 */
3810
+ return '';
3811
+ }
3812
+ function _syncUnloadGuard() {
3813
+ /* 防手滑:目前完全沒有存檔機制(.spectra 存檔/讀檔是後續工作),
3814
+ 分析跑到一半或跑完還沒下載時關掉分頁/按重新整理,整段工作直接
3815
+ 歸零。這裡在工作區顯示期間掛上 beforeunload,讓瀏覽器跳原生
3816
+ 確認框。判斷依據是工作區有沒有真的顯示在畫面上——還停在首頁
3817
+ 沒選情境時不攔,避免只是進來看看的人也被問一次;按「開始新
3818
+ 分析」重置回首頁後也會自動解除。
3819
+ 兩個瀏覽器層面的限制(不是實作沒做好):①使用者必須先與頁面
3820
+ 互動過,這個確認框才會出現,這是各家瀏覽器防濫用的共同規範
3821
+ ②提示文字一律由瀏覽器決定,無法自訂成「您的分析尚未下載」。 */
3822
+ var row = document.querySelector('.main-layout-row');
3823
+ var active = !!(row && row.offsetParent !== null);
3824
+ if (active === _unloadGuardOn) return;
3825
+ _unloadGuardOn = active;
3826
+ if (active) window.addEventListener('beforeunload', _onBeforeUnload);
3827
+ else window.removeEventListener('beforeunload', _onBeforeUnload);
3828
+ }
3829
  setInterval(function () {
3830
  _watchZone('upload_zone');
3831
  _watchZone('metadata_zone');
 
3833
  _initNavCollapseIfNeeded();
3834
  _initActivePageIfNeeded();
3835
  _reapplyActivePage();
3836
+ _syncUnloadGuard();
3837
  }, 400);
3838
  }
3839
  if (document.readyState === 'loading') {
 
4056
  "LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"),
4057
  elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary",
4058
  )
4059
+ # ── 下載捷徑(2026-07-24)────────────────────────────
4060
+ # 上面四項是「去看結果」,這四顆是「把結果帶走」,性質
4061
+ # 不同所以用分隔線隔開。原本使用者要拿到全部產出得點進
4062
+ # 三個結果頁各按各的下載鈕(共 15 個檔案),這裡各打成
4063
+ # 一包 zip。內容為空的那幾包不會產生,對應按鈕就不出現
4064
+ # (例如 LLM 報告失敗時「下載研究報告」不會亮出來),
4065
+ # 可見性由 _populate_tabs 依 zip 是否存在決定。
4066
+ gr.HTML('<div class="result-nav-divider"></div>')
4067
+ nav_dl_idx_btn = gr.DownloadButton(
4068
+ "下載指標數據", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
4069
+ visible=False, elem_id="nav_dl_idx_btn",
4070
+ elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
4071
+ )
4072
+ nav_dl_stat_btn = gr.DownloadButton(
4073
+ "下載統計圖表", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
4074
+ visible=False, elem_id="nav_dl_stat_btn",
4075
+ elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
4076
+ )
4077
+ nav_dl_report_btn = gr.DownloadButton(
4078
+ "下載研究報告", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
4079
+ visible=False, elem_id="nav_dl_report_btn",
4080
+ elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
4081
+ )
4082
+ nav_dl_all_btn = gr.DownloadButton(
4083
+ "下載完整報告", icon=str(BASE_DIR / "assets" / "icons" / "download.svg"),
4084
+ visible=False, elem_id="nav_dl_all_btn",
4085
+ elem_classes=["result-nav-btn", "result-nav-dl"], variant="secondary",
4086
+ )
4087
+
4088
  # 2026-07-23:技術執行紀錄從手風琴轉正成獨立頁面(原本
4089
  # exec_log_accordion 在 canvas-col 底部,現在跟其他三個
4090
  # 結果頁同一種待遇)。用分隔線隔開,視覺上表達「低優先度
 
4318
  tab_imp_file, tab_cluster_file, # 23-24 新增下載按鈕(僅能往尾端加,
4319
  # 不可插入既有 index 中間,見程式工作守則)
4320
  canvas_exec_col, # 25 技術執行紀錄轉正成獨立頁面(2026-07-23)
4321
+ nav_dl_idx_btn, nav_dl_stat_btn, # 26-27 導覽列下載捷徑(2026-07-24)
4322
+ nav_dl_report_btn, nav_dl_all_btn, # 28-29
4323
  ]
4324
  _N_TAB = len(_TAB_OUTPUTS)
4325
 
 
4337
  if state is None:
4338
  return R
4339
 
4340
+ # 下載捷徑(26-29)是少數必須「明確設回隱藏」的輸出:其他結果元件
4341
+ # 都住在 canvas_*_col 裡,重置時整個 Column 被藏起來,殘值看不到;
4342
+ # 這四顆住在 result_nav_col,而 result_nav_col 會在下次選定情境時
4343
+ # 重新顯示,不主動歸零的話上一輪的 zip 會跟著冒出來。
4344
+ for _i in (26, 27, 28, 29):
4345
+ R[_i] = gr.update(visible=False, value=None)
4346
+
4347
  output_dir = Path(state["session_dir"]) / "output"
4348
  uploaded = state.get("uploaded_files", [])
4349
 
 
4472
  if dl_rpt:
4473
  R[19] = gr.update(value=dl_rpt, visible=True)
4474
 
4475
+ # ── 導覽列下載捷徑:把上面各頁的下載清單打成四包(index 26-29)─
4476
+ # 刻意沿用上面已經整理好的清單變數,不另外去 output_dir 掃檔案:
4477
+ # 掃出來的會含 LLM_Analysis-*.txt 這種只供畫面顯示、不提供下載的
4478
+ # 中間產物,也含前一次打包留下的 .zip 自己。
4479
+ zips = _make_result_zips(output_dir, {
4480
+ "idx": [str(csv_path)] + [str(p) for p in xlsx_list[-1:]],
4481
+ "stat": [str(p) for p in [desc_path, diff_path, box_path]] + imp_dl + cluster_dl,
4482
+ "report": dl_rpt,
4483
+ })
4484
+ state["zip_paths"] = zips
4485
+ for _i, _key in ((26, "idx"), (27, "stat"), (28, "report"), (29, "all")):
4486
+ if zips.get(_key):
4487
+ R[_i] = gr.update(value=zips[_key], visible=True)
4488
+
4489
  # ── 顯示各分頁 Tab(index 20-22)──────────────────────────────
4490
  any_tab_ready = False
4491
  if csv_path.exists():
assets/icons/download.svg ADDED