Spaces:
Sleeping
Sleeping
Phase 8-E:nav-col 桌面收合 icon bar + 手機漢堡抽屜
Browse filesnav-col 收合成窄 icon bar(桌面,60px,localStorage 記住狀態)與
off-canvas 抽屜(手機 ≤900px,漢堡鈕開/X、背景遮罩、Esc 都能關)。
提前補上 Heroicons 風格圖示(plus/home/trending-up/document,assets/
icons/*.svg + gr.Button(icon=);統計比較分析用 CSS ::before 掛
chart-bar,Accordion 沒有 icon 參數)——因為收合後純文字按鈕沒有圖示
會看不懂,跟千卉確認後決定把原訂「下一輪視覺打磨」的圖示需求提前
到這次一起做,範圍限縮在 nav-col 這批新按鈕。
gr.Button(icon=) 這版 Gradio 是渲染成 <img>,不是行內 svg,深色模式
用 filter 反相+去彩處理(img 不吃 currentColor)。收合狀態下重置
按鈕若被點擊,JS 會先自動展開 nav-col 再顯示確認彈窗,避免彈窗擠在
窄欄裡;nav-col 原本 gr.Column(min_width=200) 的 inline min-width 需要
額外用 !important 覆寫才能真正收窄(min-width 優先於 max-width)。
已用 Playwright 驗證 375/768/900/1024/1440 五個斷點無跑版無橫向捲動、
淺色/深色模式圖示皆正常、桌面收合展開/手機抽屜開關四種關閉路徑
(X、背景、Esc、點導覽項目自動關)、重置流程收合狀態下正確自動展開。
- app_agent.py +298 -14
- assets/icons/document.svg +1 -0
- assets/icons/home.svg +1 -0
- assets/icons/plus.svg +1 -0
- assets/icons/trending-up.svg +1 -0
app_agent.py
CHANGED
|
@@ -2473,6 +2473,146 @@ div[role="log"][aria-label="chatbot conversation"],
|
|
| 2473 |
.homepage-footnote { margin-top: 2rem; text-align: center; font-size: .82rem; color: var(--c-text-subtle); }
|
| 2474 |
@media (prefers-reduced-motion: reduce) { .scenario-card { transition: none; } }
|
| 2475 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2476 |
/* ── 6. UPLOAD AREA(醒目卡片)─────────────────────────────────── */
|
| 2477 |
/* 注意:max-width + margin auto 是舊版單欄置中版面留下的樣式,現在
|
| 2478 |
upload-zone 是右側 action-col 裡的元件,要跟同一欄的 scenario-buttons
|
|
@@ -2894,12 +3034,13 @@ a:hover { color: var(--c-link-hover); }
|
|
| 2894 |
|
| 2895 |
/* ── 17. RESPONSIVE ────────────────────────────────────────────── */
|
| 2896 |
/* 三欄式工作區(nav-col/chat-col/canvas-col)僅限桌面寬度;手機或窄視窗
|
| 2897 |
-
(≤900px)改回上下堆疊,
|
| 2898 |
-
巢狀捲軸
|
| 2899 |
-
|
|
|
|
|
|
|
| 2900 |
@media (max-width: 900px) {
|
| 2901 |
.main-layout-row { flex-direction: column !important; }
|
| 2902 |
-
.main-layout-row .nav-col,
|
| 2903 |
.main-layout-row .chat-col,
|
| 2904 |
.main-layout-row .canvas-col {
|
| 2905 |
width: 100% !important;
|
|
@@ -3066,15 +3207,17 @@ _APP_HEAD_SCRIPT = """
|
|
| 3066 |
}
|
| 3067 |
document.addEventListener('click', function (e) {
|
| 3068 |
if (e.target.closest('#nav_top_btn')) {
|
|
|
|
| 3069 |
var scrollBox = document.querySelector('.canvas-scroll');
|
| 3070 |
if (scrollBox) scrollBox.scrollTo({ top: 0, behavior: 'smooth' });
|
| 3071 |
return;
|
| 3072 |
}
|
| 3073 |
-
if (e.target.closest('#nav_idx_btn')) { scrollToId('canvas_idx'); return; }
|
| 3074 |
-
if (e.target.closest('#nav_report_btn')) { scrollToId('canvas_report'); return; }
|
| 3075 |
-
if (e.target.closest('#nav_stat_accordion .label-wrap')) { scrollToId('canvas_stat'); return; }
|
| 3076 |
for (var i = 0; i < 4; i++) {
|
| 3077 |
if (e.target.closest('#nav_stat_sub_' + i)) {
|
|
|
|
| 3078 |
scrollToId('canvas_stat');
|
| 3079 |
/* 注意:這版 Gradio 的巢狀分頁 DOM 結構是
|
| 3080 |
.tabs > .tab-wrapper > .tab-container(兩個,
|
|
@@ -3096,6 +3239,87 @@ _APP_HEAD_SCRIPT = """
|
|
| 3096 |
}
|
| 3097 |
bindResultNav();
|
| 3098 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3099 |
/* 持續監聽 DOM 變化(每次訊息更新後重設) */
|
| 3100 |
var mo = new MutationObserver(function(mutations) {
|
| 3101 |
var needsFix = mutations.some(function(m) {
|
|
@@ -3141,9 +3365,30 @@ _APP_HEAD_SCRIPT = """
|
|
| 3141 |
(見陷阱:內部捲動 + 頁面捲動疊在一起看起來像兩條捲軸),所以不再
|
| 3142 |
需要 el.scrollTop 這種內部捲到底部的邏輯,避免又跟使用者手動捲動
|
| 3143 |
搶主導權(同樣的教訓見對話視窗捲不到底那次的修正)。 */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3144 |
setInterval(function () {
|
| 3145 |
_watchZone('upload_zone');
|
| 3146 |
_watchZone('metadata_zone');
|
|
|
|
|
|
|
| 3147 |
}, 400);
|
| 3148 |
}
|
| 3149 |
if (document.readyState === 'loading') {
|
|
@@ -3234,11 +3479,41 @@ def create_app():
|
|
| 3234 |
sc_btn_4 = gr.Button("4 不同時期重譯本的歷時比較", variant="secondary",
|
| 3235 |
elem_id="sc_btn_4", elem_classes=["sc-bridge-btn"])
|
| 3236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3237 |
# Phase 8-B:三欄式工作區——nav-col(窄,導覽/重置)+chat-col
|
| 3238 |
# (對話視窗+輸入框,獨立捲動)+canvas-col(步驟列+上傳/表單/
|
| 3239 |
# 執行紀錄,獨立捲動)。手機寬度見 CSS 的 media query 改上下堆疊。
|
| 3240 |
with gr.Row(elem_classes=["main-layout-row"], visible=False) as workspace_row:
|
| 3241 |
with gr.Column(scale=2, min_width=200, elem_classes=["nav-col"]) as nav_col:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3242 |
# 研究情境是封閉式的固定四選一,改用可點擊按鈕取代打字輸入
|
| 3243 |
# 數字再解析,選定後即隱藏,對話視窗回歸純輸出角色
|
| 3244 |
# Phase 8:情境選擇按鈕已搬到 homepage_col(見上方,理由是
|
|
@@ -3252,7 +3527,10 @@ def create_app():
|
|
| 3252 |
# 「開始新分析」與其確認彈窗:換情境/重新開始都走這一套共用
|
| 3253 |
# 機制。情境是封閉式按鈕選擇,不透過對話框打字,所以「換
|
| 3254 |
# 情境」也統一走這顆按鈕,不恢復文字解析路徑。
|
| 3255 |
-
reset_btn = gr.Button(
|
|
|
|
|
|
|
|
|
|
| 3256 |
with gr.Column(visible=False, elem_classes=["reset-confirm"]) as reset_confirm_row:
|
| 3257 |
gr.Markdown("確定要開始新分析嗎?目前進度將會遺失。")
|
| 3258 |
with gr.Row(elem_classes=["upload-actions"]):
|
|
@@ -3268,10 +3546,14 @@ def create_app():
|
|
| 3268 |
# 下一輪視覺打磨(Heroicons 圖示)一起補,這次先求導覽功能
|
| 3269 |
# 正確可用。
|
| 3270 |
with gr.Column(visible=False, elem_id="result_nav_col", elem_classes=["result-nav"]) as result_nav_col:
|
| 3271 |
-
nav_top_btn = gr.Button(
|
| 3272 |
-
|
| 3273 |
-
|
| 3274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3275 |
with gr.Accordion("統計比較分析", open=False, elem_id="nav_stat_accordion",
|
| 3276 |
elem_classes=["result-nav-accordion"]):
|
| 3277 |
nav_stat_sub_0 = gr.Button("敘述統計", elem_id="nav_stat_sub_0",
|
|
@@ -3282,8 +3564,10 @@ def create_app():
|
|
| 3282 |
elem_classes=["result-nav-subbtn"], variant="secondary")
|
| 3283 |
nav_stat_sub_3 = gr.Button("非監督式學習", elem_id="nav_stat_sub_3",
|
| 3284 |
elem_classes=["result-nav-subbtn"], variant="secondary")
|
| 3285 |
-
nav_report_btn = gr.Button(
|
| 3286 |
-
|
|
|
|
|
|
|
| 3287 |
|
| 3288 |
with gr.Column(scale=4, min_width=300, elem_classes=["chat-col"]):
|
| 3289 |
chatbot = gr.Chatbot(
|
|
|
|
| 2473 |
.homepage-footnote { margin-top: 2rem; text-align: center; font-size: .82rem; color: var(--c-text-subtle); }
|
| 2474 |
@media (prefers-reduced-motion: reduce) { .scenario-card { transition: none; } }
|
| 2475 |
|
| 2476 |
+
/* ── 5e. NAV-COL 收合/手機漢堡抽屜(Phase 8-E)────────────────── */
|
| 2477 |
+
/* gr.Button(icon=...) 這版 Gradio 實測是渲染成 <img src="...">,不是行內
|
| 2478 |
+
<svg>,CSS currentColor 對它沒有作用(img 不會繼承文字顏色)。因此
|
| 2479 |
+
assets/icons/*.svg 檔案裡直接把 stroke 寫死成淺色模式的 --c-text-muted
|
| 2480 |
+
(#475569),深色模式改用 filter 把整張圖反相+去彩,逼近深色模式的
|
| 2481 |
+
--c-text-muted (#94a3b8) 色調,這是唯一能讓「檔案路徑圖示」跟著主題
|
| 2482 |
+
變色的辦法(不用另外準備一份深色版 svg 檔案)。 */
|
| 2483 |
+
img.button-icon { opacity: .82; }
|
| 2484 |
+
.dark img.button-icon {
|
| 2485 |
+
filter: grayscale(1) invert(1) brightness(1.55) contrast(.85);
|
| 2486 |
+
}
|
| 2487 |
+
/* nav-col 收合/手機抽屜控制鈕:桌面收合鈕與手機關閉鈕互斥顯示,
|
| 2488 |
+
預設(>900px)只顯示收合鈕;@media (max-width:900px) 那組規則會反過來
|
| 2489 |
+
(見下方)。這兩顆是純 gr.HTML() 刻的按鈕,行內 SVG 用 _icon(),
|
| 2490 |
+
currentColor 正常運作,不受上面 img.button-icon 的限制。 */
|
| 2491 |
+
.nav-collapse-row { display: flex; justify-content: flex-end; margin-bottom: .5rem; }
|
| 2492 |
+
.nav-drawer-close-row { display: none; justify-content: flex-end; margin-bottom: .5rem; }
|
| 2493 |
+
.nav-collapse-toggle, .nav-drawer-close, .mobile-nav-toggle {
|
| 2494 |
+
all: unset; box-sizing: border-box; cursor: pointer;
|
| 2495 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 2496 |
+
width: 32px; height: 32px; border-radius: 8px;
|
| 2497 |
+
color: var(--c-text-muted);
|
| 2498 |
+
transition: background-color 150ms ease-out, color 150ms ease-out;
|
| 2499 |
+
}
|
| 2500 |
+
.nav-collapse-toggle:hover, .nav-drawer-close:hover, .mobile-nav-toggle:hover {
|
| 2501 |
+
background: rgba(var(--c-accent-rgb),.08); color: var(--c-text);
|
| 2502 |
+
}
|
| 2503 |
+
.nav-collapse-toggle:focus-visible, .nav-drawer-close:focus-visible, .mobile-nav-toggle:focus-visible {
|
| 2504 |
+
outline: 2px solid var(--c-accent); outline-offset: 2px;
|
| 2505 |
+
}
|
| 2506 |
+
.nav-collapse-toggle svg { transition: transform 200ms ease-out; }
|
| 2507 |
+
.nav-col.collapsed .nav-collapse-toggle svg { transform: rotate(180deg); }
|
| 2508 |
+
@media (prefers-reduced-motion: reduce) {
|
| 2509 |
+
.nav-collapse-toggle svg { transition: none; }
|
| 2510 |
+
.nav-col { transition: none !important; }
|
| 2511 |
+
}
|
| 2512 |
+
|
| 2513 |
+
/* 桌面收合成窄 icon bar:文字用 font-size:0 隱藏(不是 display:none,
|
| 2514 |
+
避免影響按鈕的 accessible name/可點擊區域),圖示尺寸另外鎖死不受
|
| 2515 |
+
font-size 影響。統計比較分析的 4 個子按鈕是純文字、沒有各自圖示,
|
| 2516 |
+
收合時整組 Accordion 直接隱藏(窄版 icon bar 塞不下二層選單);重置
|
| 2517 |
+
按鈕若在收合狀態被點擊,JS 會先自動展開 nav-col 再讓確認彈窗顯示
|
| 2518 |
+
(見 _APP_HEAD_SCRIPT setNavCollapsed 呼叫處),避免確認彈窗擠在
|
| 2519 |
+
60px 窄欄裡。這整組「收合外觀」規則只在桌面寬度生效(min-width:901px)
|
| 2520 |
+
——手機沒有「窄 icon bar」這個中間態,nav-col 在手機是 off-canvas
|
| 2521 |
+
抽屜,要嘛整個滑出來、要嘛整個收在畫面外,即使 localStorage 殘留
|
| 2522 |
+
collapsed=1 也不該套用這組窄化樣式(見下方 900px 媒體查詢改用
|
| 2523 |
+
position:fixed 抽屜,兩者規則互斥,不需要互相 revert)。 */
|
| 2524 |
+
.nav-col { transition: flex-basis 200ms ease-out, max-width 200ms ease-out; }
|
| 2525 |
+
@media (min-width: 901px) {
|
| 2526 |
+
/* gr.Column(min_width=200) 會在元件上寫死 inline style="min-width:200px",
|
| 2527 |
+
這個 inline min-width 會蓋掉外部 stylesheet 的 max-width(flexbox
|
| 2528 |
+
規範:min-width 與 max-width 衝突時 min-width 優先),光設
|
| 2529 |
+
max-width:60px 收合欄寬不會有效果,撐在 200px 不動——必須額外用
|
| 2530 |
+
!important 覆寫 min-width 本身(stylesheet 的 !important 蓋得過
|
| 2531 |
+
沒加 !important 的 inline style,這點跟蓋不過 inline !important
|
| 2532 |
+
不一樣)。 */
|
| 2533 |
+
.nav-col.collapsed { flex: 0 0 60px !important; max-width: 60px !important; min-width: 60px !important; }
|
| 2534 |
+
.nav-col.collapsed .nav-collapse-row { justify-content: center; }
|
| 2535 |
+
.nav-col.collapsed .scenario-buttons,
|
| 2536 |
+
.nav-col.collapsed .result-nav-accordion,
|
| 2537 |
+
.nav-col.collapsed .reset-confirm { display: none !important; }
|
| 2538 |
+
.nav-col.collapsed .reset-btn,
|
| 2539 |
+
.nav-col.collapsed .result-nav-btn {
|
| 2540 |
+
justify-content: center !important;
|
| 2541 |
+
padding: .6rem !important;
|
| 2542 |
+
font-size: 0 !important;
|
| 2543 |
+
}
|
| 2544 |
+
.nav-col.collapsed .button-icon { width: 20px !important; height: 20px !important; }
|
| 2545 |
+
.nav-col.collapsed .result-nav { padding-top: .8rem !important; }
|
| 2546 |
+
.nav-col.collapsed #nav_stat_accordion { display: none !important; }
|
| 2547 |
+
}
|
| 2548 |
+
|
| 2549 |
+
/* 統計比較分析 Accordion 沒有 icon= 參數,改用 CSS ::before 掛圖示
|
| 2550 |
+
(data-URI,同一份 chart-bar 線稿,��值同步 assets/icons/*.svg 的
|
| 2551 |
+
#475569/深色模式同樣靠 filter 反相,寫法比照上面 img.button-icon)。 */
|
| 2552 |
+
#nav_stat_accordion .label-wrap {
|
| 2553 |
+
display: flex !important; align-items: center !important; gap: .5rem !important;
|
| 2554 |
+
}
|
| 2555 |
+
#nav_stat_accordion .label-wrap::before {
|
| 2556 |
+
content: "";
|
| 2557 |
+
width: 18px; height: 18px; flex: none;
|
| 2558 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23475569' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='4' y1='20' x2='20' y2='20'/%3E%3Crect x='5' y='12' width='3' height='8'/%3E%3Crect x='10.5' y='7' width='3' height='13'/%3E%3Crect x='16' y='14' width='3' height='6'/%3E%3C/svg%3E");
|
| 2559 |
+
background-size: contain; background-repeat: no-repeat;
|
| 2560 |
+
opacity: .82;
|
| 2561 |
+
}
|
| 2562 |
+
.dark #nav_stat_accordion .label-wrap::before {
|
| 2563 |
+
filter: grayscale(1) invert(1) brightness(1.55) contrast(.85);
|
| 2564 |
+
}
|
| 2565 |
+
|
| 2566 |
+
/* 手機漢堡開關(mobile_nav_toggle)與抽屜遮罩(nav_backdrop):兩者都是
|
| 2567 |
+
放在 workspace_row 外層、一開始就 visible=True 的獨立元件(理由見
|
| 2568 |
+
create_app() 該處註解),CSS 預設整個不顯示,只在手機寬度且 nav-col
|
| 2569 |
+
已掛載(情境已選定)時,由 _APP_HEAD_SCRIPT 的 _syncMobileHamburger()
|
| 2570 |
+
切換 display,避免首頁或桌面寬度下平白多一顆浮動按鈕。 */
|
| 2571 |
+
.mobile-nav-toggle {
|
| 2572 |
+
display: none;
|
| 2573 |
+
position: fixed; top: 14px; left: 14px; z-index: 45;
|
| 2574 |
+
background: var(--c-surface) !important;
|
| 2575 |
+
border: 1px solid var(--c-border) !important;
|
| 2576 |
+
box-shadow: 0 4px 16px rgba(15,23,42,.18);
|
| 2577 |
+
}
|
| 2578 |
+
.nav-backdrop {
|
| 2579 |
+
display: none;
|
| 2580 |
+
position: fixed; inset: 0; z-index: 30;
|
| 2581 |
+
background: rgba(2,6,23,.5);
|
| 2582 |
+
opacity: 0; pointer-events: none;
|
| 2583 |
+
transition: opacity 200ms ease-out;
|
| 2584 |
+
}
|
| 2585 |
+
.nav-backdrop.visible { opacity: 1; pointer-events: auto; }
|
| 2586 |
+
@media (prefers-reduced-motion: reduce) { .nav-backdrop { transition: none; } }
|
| 2587 |
+
|
| 2588 |
+
@media (max-width: 900px) {
|
| 2589 |
+
/* 手機寬度:nav-col 不再跟著 chat-col/canvas-col 上下堆疊(那條規則
|
| 2590 |
+
見「17. RESPONSIVE」,已把 nav-col 排除),改成 off-canvas 抽屜,
|
| 2591 |
+
預設收在畫面左側外面,漢堡鈕/背景遮罩點擊才滑入。 */
|
| 2592 |
+
.nav-col {
|
| 2593 |
+
position: fixed !important;
|
| 2594 |
+
top: 0; left: 0; bottom: 0;
|
| 2595 |
+
width: 78vw !important; max-width: 300px !important;
|
| 2596 |
+
flex: none !important; height: 100vh !important;
|
| 2597 |
+
overflow-y: auto !important;
|
| 2598 |
+
background: var(--c-surface) !important;
|
| 2599 |
+
box-shadow: 4px 0 24px rgba(15,23,42,.28) !important;
|
| 2600 |
+
z-index: 40;
|
| 2601 |
+
transform: translateX(-100%);
|
| 2602 |
+
transition: transform 220ms ease-out;
|
| 2603 |
+
padding: 1.1rem 1rem !important;
|
| 2604 |
+
}
|
| 2605 |
+
.nav-col.mobile-open { transform: translateX(0) !important; }
|
| 2606 |
+
/* 桌面收合是縮寬窄 icon bar 那組規則整段包在 @media(min-width:901px)
|
| 2607 |
+
裡(見「5e」上方),手機寬度完全不會命中,這裡不需要另外
|
| 2608 |
+
revert——即使 localStorage 殘留 collapsed=1,.nav-col.collapsed
|
| 2609 |
+
在手機寬度就是普通展開的抽屜外觀,不會被窄化。 */
|
| 2610 |
+
.nav-collapse-row { display: none !important; }
|
| 2611 |
+
.nav-drawer-close-row { display: flex !important; }
|
| 2612 |
+
.mobile-nav-toggle { display: inline-flex; }
|
| 2613 |
+
.nav-backdrop { display: block; }
|
| 2614 |
+
}
|
| 2615 |
+
|
| 2616 |
/* ── 6. UPLOAD AREA(醒目卡片)─────────────────────────────────── */
|
| 2617 |
/* 注意:max-width + margin auto 是舊版單欄置中版面留下的樣式,現在
|
| 2618 |
upload-zone 是右側 action-col 裡的元件,要跟同一欄的 scenario-buttons
|
|
|
|
| 3034 |
|
| 3035 |
/* ── 17. RESPONSIVE ────────────────────────────────────────────── */
|
| 3036 |
/* 三欄式工作區(nav-col/chat-col/canvas-col)僅限桌面寬度;手機或窄視窗
|
| 3037 |
+
(≤900px)chat-col/canvas-col 改回上下堆疊,不再各自獨立捲動(避免
|
| 3038 |
+
小螢幕出現多層巢狀捲軸),改用頁面本身捲動。nav-col 這裡刻意不列
|
| 3039 |
+
進來:Phase 8-E 把它改成手機 off-canvas 抽屜(position:fixed,見
|
| 3040 |
+
「5e. NAV-COL 收合/手機漢堡抽屜」區塊),跟著上下堆疊會跟抽屜的
|
| 3041 |
+
fixed 定位互相打架。 */
|
| 3042 |
@media (max-width: 900px) {
|
| 3043 |
.main-layout-row { flex-direction: column !important; }
|
|
|
|
| 3044 |
.main-layout-row .chat-col,
|
| 3045 |
.main-layout-row .canvas-col {
|
| 3046 |
width: 100% !important;
|
|
|
|
| 3207 |
}
|
| 3208 |
document.addEventListener('click', function (e) {
|
| 3209 |
if (e.target.closest('#nav_top_btn')) {
|
| 3210 |
+
closeMobileDrawerIfOpen();
|
| 3211 |
var scrollBox = document.querySelector('.canvas-scroll');
|
| 3212 |
if (scrollBox) scrollBox.scrollTo({ top: 0, behavior: 'smooth' });
|
| 3213 |
return;
|
| 3214 |
}
|
| 3215 |
+
if (e.target.closest('#nav_idx_btn')) { closeMobileDrawerIfOpen(); scrollToId('canvas_idx'); return; }
|
| 3216 |
+
if (e.target.closest('#nav_report_btn')) { closeMobileDrawerIfOpen(); scrollToId('canvas_report'); return; }
|
| 3217 |
+
if (e.target.closest('#nav_stat_accordion .label-wrap')) { closeMobileDrawerIfOpen(); scrollToId('canvas_stat'); return; }
|
| 3218 |
for (var i = 0; i < 4; i++) {
|
| 3219 |
if (e.target.closest('#nav_stat_sub_' + i)) {
|
| 3220 |
+
closeMobileDrawerIfOpen();
|
| 3221 |
scrollToId('canvas_stat');
|
| 3222 |
/* 注意:這版 Gradio 的巢狀分頁 DOM 結構是
|
| 3223 |
.tabs > .tab-wrapper > .tab-container(兩個,
|
|
|
|
| 3239 |
}
|
| 3240 |
bindResultNav();
|
| 3241 |
|
| 3242 |
+
/* Phase 8-E:桌面收合 icon bar/手機漢堡抽屜。跟 bindResultNav()
|
| 3243 |
+
同一套「事件代理綁在 document」手法,理由相同——nav-col 建構時
|
| 3244 |
+
在 visible=False 的 workspace_row 裡,情境選定前不存在,不能用
|
| 3245 |
+
setTimeout 抓固定時間點的元素。 */
|
| 3246 |
+
function setNavCollapsed(collapsed) {
|
| 3247 |
+
var navCol = document.querySelector('.nav-col');
|
| 3248 |
+
if (!navCol) return;
|
| 3249 |
+
navCol.classList.toggle('collapsed', collapsed);
|
| 3250 |
+
var toggleBtn = document.getElementById('nav_collapse_toggle');
|
| 3251 |
+
if (toggleBtn) {
|
| 3252 |
+
toggleBtn.setAttribute('aria-label', collapsed ? '展開導覽列' : '收合導覽列');
|
| 3253 |
+
toggleBtn.setAttribute('aria-expanded', String(!collapsed));
|
| 3254 |
+
}
|
| 3255 |
+
/* 收合後按鈕文字用 font-size:0 隱藏(見 CSS),accessible name
|
| 3256 |
+
會跟著消失,改用 aria-label 補回原始文字,展開時移除即可
|
| 3257 |
+
讓瀏覽器繼續用按鈕原生文字內容當 accessible name。 */
|
| 3258 |
+
['reset_btn', 'nav_top_btn', 'nav_idx_btn', 'nav_report_btn'].forEach(function (id) {
|
| 3259 |
+
/* elem_id 在 gr.Button 上這版 Gradio 是直接寫在 <button>
|
| 3260 |
+
標籤本身,不是外層 wrapper div(跟 result_nav_col 那組
|
| 3261 |
+
純 gr.HTML() 按鈕、或 nav_top_btn 用 elem_id 做 CSS
|
| 3262 |
+
選擇器點擊代理時假設的「wrapper 帶 id」不是同一種
|
| 3263 |
+
結構)——用 devtools 量過 outerHTML 才發現,兩者都要
|
| 3264 |
+
兼容。 */
|
| 3265 |
+
var el = document.getElementById(id);
|
| 3266 |
+
var btn = el && (el.tagName === 'BUTTON' ? el : el.querySelector('button'));
|
| 3267 |
+
if (!btn) return;
|
| 3268 |
+
if (collapsed) {
|
| 3269 |
+
if (!btn.dataset.navLabel) btn.dataset.navLabel = btn.textContent.trim();
|
| 3270 |
+
btn.setAttribute('aria-label', btn.dataset.navLabel);
|
| 3271 |
+
} else {
|
| 3272 |
+
btn.removeAttribute('aria-label');
|
| 3273 |
+
}
|
| 3274 |
+
});
|
| 3275 |
+
try { localStorage.setItem('spectra_nav_collapsed', collapsed ? '1' : '0'); } catch (err) {}
|
| 3276 |
+
}
|
| 3277 |
+
function setMobileDrawer(open) {
|
| 3278 |
+
var navCol = document.querySelector('.nav-col');
|
| 3279 |
+
var backdrop = document.getElementById('nav_backdrop');
|
| 3280 |
+
var toggle = document.getElementById('mobile_nav_toggle');
|
| 3281 |
+
if (navCol) navCol.classList.toggle('mobile-open', open);
|
| 3282 |
+
if (backdrop) backdrop.classList.toggle('visible', open);
|
| 3283 |
+
if (toggle) toggle.setAttribute('aria-expanded', String(open));
|
| 3284 |
+
}
|
| 3285 |
+
function closeMobileDrawerIfOpen() {
|
| 3286 |
+
var navCol = document.querySelector('.nav-col');
|
| 3287 |
+
if (navCol && navCol.classList.contains('mobile-open')) setMobileDrawer(false);
|
| 3288 |
+
}
|
| 3289 |
+
function bindNavControls() {
|
| 3290 |
+
if (window._navControlsBound) return;
|
| 3291 |
+
window._navControlsBound = true;
|
| 3292 |
+
document.addEventListener('click', function (e) {
|
| 3293 |
+
if (e.target.closest('#nav_collapse_toggle')) {
|
| 3294 |
+
var navCol = document.querySelector('.nav-col');
|
| 3295 |
+
setNavCollapsed(!(navCol && navCol.classList.contains('collapsed')));
|
| 3296 |
+
return;
|
| 3297 |
+
}
|
| 3298 |
+
if (e.target.closest('#mobile_nav_toggle')) {
|
| 3299 |
+
var navColEl = document.querySelector('.nav-col');
|
| 3300 |
+
setMobileDrawer(!(navColEl && navColEl.classList.contains('mobile-open')));
|
| 3301 |
+
return;
|
| 3302 |
+
}
|
| 3303 |
+
if (e.target.closest('#nav_drawer_close') || e.target.closest('#nav_backdrop')) {
|
| 3304 |
+
setMobileDrawer(false);
|
| 3305 |
+
return;
|
| 3306 |
+
}
|
| 3307 |
+
if (e.target.closest('#reset_btn')) {
|
| 3308 |
+
/* 收合狀態下點重置,先展開 nav-col 再讓 Gradio 原生
|
| 3309 |
+
.click() 觸發確認彈窗,避免彈窗擠在 60px 窄欄裡;
|
| 3310 |
+
這裡不 preventDefault/stopPropagation,事件照常
|
| 3311 |
+
冒泡給 Gradio。 */
|
| 3312 |
+
var navCol2 = document.querySelector('.nav-col');
|
| 3313 |
+
if (navCol2 && navCol2.classList.contains('collapsed')) setNavCollapsed(false);
|
| 3314 |
+
closeMobileDrawerIfOpen();
|
| 3315 |
+
}
|
| 3316 |
+
});
|
| 3317 |
+
document.addEventListener('keydown', function (e) {
|
| 3318 |
+
if (e.key === 'Escape') closeMobileDrawerIfOpen();
|
| 3319 |
+
});
|
| 3320 |
+
}
|
| 3321 |
+
bindNavControls();
|
| 3322 |
+
|
| 3323 |
/* 持續監聽 DOM 變化(每次訊息更新後重設) */
|
| 3324 |
var mo = new MutationObserver(function(mutations) {
|
| 3325 |
var needsFix = mutations.some(function(m) {
|
|
|
|
| 3365 |
(見陷阱:內部捲動 + 頁面捲動疊在一起看起來像兩條捲軸),所以不再
|
| 3366 |
需要 el.scrollTop 這種內部捲到底部的邏輯,避免又跟使用者手動捲動
|
| 3367 |
搶主導權(同樣的教訓見對話視窗捲不到底那次的修正)。 */
|
| 3368 |
+
/* Phase 8-E:mobile_nav_toggle(手機漢堡鈕)是放在 workspace_row
|
| 3369 |
+
外層、頁面一開始就存在的元件,但它只有在 nav-col 真正掛載
|
| 3370 |
+
(情境已選定)時才該顯示;同理,桌面收合狀態要在 nav-col 剛
|
| 3371 |
+
掛載那一刻套用 localStorage 記住的值,早一步做(例如 DOMContentLoaded
|
| 3372 |
+
當下)nav-col 根本還不存在,套不上。都改成搭這裡既有的
|
| 3373 |
+
setInterval 一起輪詢,不額外開新計時器。 */
|
| 3374 |
+
function _syncMobileHamburger() {
|
| 3375 |
+
var hamburger = document.getElementById('mobile_nav_toggle');
|
| 3376 |
+
if (!hamburger) return;
|
| 3377 |
+
hamburger.style.display = document.querySelector('.nav-col') ? '' : 'none';
|
| 3378 |
+
}
|
| 3379 |
+
function _initNavCollapseIfNeeded() {
|
| 3380 |
+
var navCol = document.querySelector('.nav-col');
|
| 3381 |
+
if (!navCol || navCol.dataset.navInit) return;
|
| 3382 |
+
navCol.dataset.navInit = '1';
|
| 3383 |
+
var saved;
|
| 3384 |
+
try { saved = localStorage.getItem('spectra_nav_collapsed'); } catch (err) { saved = null; }
|
| 3385 |
+
if (saved === '1') setNavCollapsed(true);
|
| 3386 |
+
}
|
| 3387 |
setInterval(function () {
|
| 3388 |
_watchZone('upload_zone');
|
| 3389 |
_watchZone('metadata_zone');
|
| 3390 |
+
_syncMobileHamburger();
|
| 3391 |
+
_initNavCollapseIfNeeded();
|
| 3392 |
}, 400);
|
| 3393 |
}
|
| 3394 |
if (document.readyState === 'loading') {
|
|
|
|
| 3479 |
sc_btn_4 = gr.Button("4 不同時期重譯本的歷時比較", variant="secondary",
|
| 3480 |
elem_id="sc_btn_4", elem_classes=["sc-bridge-btn"])
|
| 3481 |
|
| 3482 |
+
# Phase 8-E:手機漢堡開關+抽屜背景遮罩。跟 sc_btn_1..4 同一類坑:
|
| 3483 |
+
# 建構時 visible=False 的容器整段不掛載進 DOM,而這兩個元件要在
|
| 3484 |
+
# nav-col(在 workspace_row 裡,情境選定前不存在)尚未掛載前就能
|
| 3485 |
+
# 被點到,所以放在最外層、一開始就 visible=True 的位置;CSS 預設
|
| 3486 |
+
# display:none,只在手機寬度且 workspace 啟用時由 JS 顯示(見
|
| 3487 |
+
# _APP_HEAD_SCRIPT 的 _syncMobileHamburger()),桌面寬度/首頁
|
| 3488 |
+
# 狀態下完全不佔版面也不可點。
|
| 3489 |
+
mobile_nav_toggle_html = gr.HTML(
|
| 3490 |
+
f'<button type="button" id="mobile_nav_toggle" class="mobile-nav-toggle" '
|
| 3491 |
+
f'aria-label="開啟導覽選單" aria-expanded="false">{_icon("bars-3", 22)}</button>'
|
| 3492 |
+
)
|
| 3493 |
+
nav_backdrop_html = gr.HTML('<div id="nav_backdrop" class="nav-backdrop"></div>')
|
| 3494 |
+
|
| 3495 |
# Phase 8-B:三欄式工作區——nav-col(窄,導覽/重置)+chat-col
|
| 3496 |
# (對話視窗+輸入框,獨立捲動)+canvas-col(步驟列+上傳/表單/
|
| 3497 |
# 執行紀錄,獨立捲動)。手機寬度見 CSS 的 media query 改上下堆疊。
|
| 3498 |
with gr.Row(elem_classes=["main-layout-row"], visible=False) as workspace_row:
|
| 3499 |
with gr.Column(scale=2, min_width=200, elem_classes=["nav-col"]) as nav_col:
|
| 3500 |
+
# Phase 8-E:收合/抽屜控制列。兩顆按鈕互斥顯示(CSS
|
| 3501 |
+
# media query 切換,見 5e 區塊):桌面寬度顯示收合鈕
|
| 3502 |
+
# (nav_collapse_toggle,縮成 icon bar);手機寬度顯示
|
| 3503 |
+
# 關閉鈕(nav_drawer_close,收起抽屜)。純畫面操作,
|
| 3504 |
+
# 不���動任何後端狀態,沿用 result_nav_col 同一套「事件
|
| 3505 |
+
# 代理綁在 document」手法(見 _APP_HEAD_SCRIPT)。
|
| 3506 |
+
gr.HTML(
|
| 3507 |
+
'<div class="nav-collapse-row">'
|
| 3508 |
+
f'<button type="button" id="nav_collapse_toggle" class="nav-collapse-toggle" '
|
| 3509 |
+
f'aria-label="收合導覽列" aria-expanded="true">{_icon("chevron-double-left", 18)}</button>'
|
| 3510 |
+
'</div>'
|
| 3511 |
+
'<div class="nav-drawer-close-row">'
|
| 3512 |
+
f'<button type="button" id="nav_drawer_close" class="nav-drawer-close" '
|
| 3513 |
+
f'aria-label="關閉導覽選單">{_icon("x", 18)}</button>'
|
| 3514 |
+
'</div>'
|
| 3515 |
+
)
|
| 3516 |
+
|
| 3517 |
# 研究情境是封閉式的固定四選一,改用可點擊按鈕取代打字輸入
|
| 3518 |
# 數字再解析,選定後即隱藏,對話視窗回歸純輸出角色
|
| 3519 |
# Phase 8:情境選擇按鈕已搬到 homepage_col(見上方,理由是
|
|
|
|
| 3527 |
# 「開始新分析」與其確認彈窗:換情境/重新開始都走這一套共用
|
| 3528 |
# 機制。情境是封閉式按鈕選擇,不透過對話框打字,所以「換
|
| 3529 |
# 情境」也統一走這顆按鈕,不恢復文字解析路徑。
|
| 3530 |
+
reset_btn = gr.Button(
|
| 3531 |
+
"開始新分析", icon=str(BASE_DIR / "assets" / "icons" / "plus.svg"),
|
| 3532 |
+
elem_id="reset_btn", elem_classes=["reset-btn"], variant="secondary",
|
| 3533 |
+
)
|
| 3534 |
with gr.Column(visible=False, elem_classes=["reset-confirm"]) as reset_confirm_row:
|
| 3535 |
gr.Markdown("確定要開始新分析嗎?目前進度將會遺失。")
|
| 3536 |
with gr.Row(elem_classes=["upload-actions"]):
|
|
|
|
| 3546 |
# 下一輪視覺打磨(Heroicons 圖示)一起補,這次先求導覽功能
|
| 3547 |
# 正確可用。
|
| 3548 |
with gr.Column(visible=False, elem_id="result_nav_col", elem_classes=["result-nav"]) as result_nav_col:
|
| 3549 |
+
nav_top_btn = gr.Button(
|
| 3550 |
+
"回到上傳/表單", icon=str(BASE_DIR / "assets" / "icons" / "home.svg"),
|
| 3551 |
+
elem_id="nav_top_btn", elem_classes=["result-nav-btn"], variant="secondary",
|
| 3552 |
+
)
|
| 3553 |
+
nav_idx_btn = gr.Button(
|
| 3554 |
+
"語言指標分析", icon=str(BASE_DIR / "assets" / "icons" / "trending-up.svg"),
|
| 3555 |
+
elem_id="nav_idx_btn", elem_classes=["result-nav-btn"], variant="secondary",
|
| 3556 |
+
)
|
| 3557 |
with gr.Accordion("統計比較分析", open=False, elem_id="nav_stat_accordion",
|
| 3558 |
elem_classes=["result-nav-accordion"]):
|
| 3559 |
nav_stat_sub_0 = gr.Button("敘述統計", elem_id="nav_stat_sub_0",
|
|
|
|
| 3564 |
elem_classes=["result-nav-subbtn"], variant="secondary")
|
| 3565 |
nav_stat_sub_3 = gr.Button("非監督式學習", elem_id="nav_stat_sub_3",
|
| 3566 |
elem_classes=["result-nav-subbtn"], variant="secondary")
|
| 3567 |
+
nav_report_btn = gr.Button(
|
| 3568 |
+
"LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"),
|
| 3569 |
+
elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary",
|
| 3570 |
+
)
|
| 3571 |
|
| 3572 |
with gr.Column(scale=4, min_width=300, elem_classes=["chat-col"]):
|
| 3573 |
chatbot = gr.Chatbot(
|
assets/icons/document.svg
ADDED
|
|
assets/icons/home.svg
ADDED
|
|
assets/icons/plus.svg
ADDED
|
|
assets/icons/trending-up.svg
ADDED
|
|