Skip to content

SkyLab Frontend — 樣式規範 ​

本文件說明前端樣式架構與撰寫規範,適用於前端所有頁面與元件:新頁面、元件都應遵循此指南,確保視覺與程式碼風格一致。若想自行變更 _variables.scss、_themes.scss 兩個檔案,請事先與前端討論。


目錄結構 ​

src/assets/styles/
├── global.scss       # 全域樣式入口(@use themes、backgrounds、reset;全頁背景層、toast 樣式)
├── _themes.scss      # CSS 自訂屬性(亮色 / 深色主題)
├── _backgrounds.scss # 背景花色,由 body[data-bg] 切換
├── _variables.scss   # SCSS 結構變數(間距、字體、斷點、圓角)
├── _mixins.scss      # 可重用的 SCSS mixin
└── _reset.scss       # CSS Reset

元件 / 頁面的樣式請以 CSS Modules 撰寫,放在元件旁:

src/pages/personal/resources/
├── ResourcesPage.jsx
└── ResourcesPage.module.scss   ← 與元件同名,同目錄

在 SCSS Module 中使用共用變數與 mixin ​

vite.config.js 已透過 css.preprocessorOptions.scss.additionalData 對所有 SCSS 檔全域注入:

scss
@use "@/assets/styles/variables" as *;
@use "@/assets/styles/mixins" as *;

因此 .module.scss 內可直接使用 $spacing-*、$font-size-*、@include flex-center 等,不需要(也不應)在檔案開頭手動再加 @use variables / mixins——手動引入是冗餘的。舊檔殘留的手動引入無害,重構經過時順手移除即可。

注意:全域注入僅涵蓋 variables 與 mixins 兩檔;_themes.scss 的顏色是 CSS 自訂屬性(var(--color-*)),本來就不需引入。


⚠️ 變數使用原則 ​

禁止在元件 SCSS 中自行新增新的 SCSS 變數或 CSS 自訂屬性。

請優先查閱並沿用 _variables.scss(間距、字體、圓角等)與 _themes.scss(顏色)中已定義的變數。若確實找不到對應的變數,應先討論是否有必要加入全域定義,而非在元件內自行宣告。


顏色系統 ​

所有顏色一律使用 _themes.scss 中定義的 CSS 自訂屬性,不可在元件 SCSS 內直接寫死 HEX 色碼。狀態色也一律走 --color-success 等變數(僅下方明列的例外可寫死色碼)。

主要變數 ​

背景 ​

變數用途
--color-bg-base頁面底色
--color-bg-gradient-blue/yellow/green三色暈染背景漸層

表面 ​

變數用途
--color-surface卡片、面板背景
--color-surface-glass毛玻璃效果背景
--color-surface-glass-border毛玻璃邊框
--color-sidebar側邊欄背景

品牌色 ​

變數用途
--color-primary主色(藍紫)
--color-primary-dark深色主色(僅作底色,如 primary 按鈕 hover;深色模式仍為深色,當文字會不可讀)
--color-primary-light淺色主色
--color-primary-on-surface品牌色文字/邊框用;亮暗兩色都達 AA。勿與 --color-text-primary 混淆(語序相反、兩者皆為藍色)

文字 ​

變數用途
--color-text一般文字
--color-text-primary標題、強調文字
--color-text-secondary次要文字
--color-text-muted輔助說明、placeholder
--color-text-on-primary主色背景上的文字(白)

邊框與互動 ​

變數用途
--color-border一般邊框
--color-divider分隔線
--color-hoverHover 背景
--color-row-hover表格列 hover 背景(比 --color-hover 深,避免與表頭同色)
--color-overlayModal 遮罩

陰影 ​

變數用途
--shadow-sm細微陰影
--shadow-md中等陰影(卡片)
--shadow-lg大陰影(Dialog)
--shadow-glass毛玻璃陰影

專用色票各自只給一個元件用:--color-flow-*(拓撲畫布,見「卡片」)、--color-code-*(範例程式碼,見下方例外)、--color-terminal-*(首頁機器卡的終端輸出)、--color-folder-*(快速練習資料夾)、--color-kind-shared(機器來源徽章 MachineKindBadge)。

狀態色 ​

前端使用以下五種語意顏色,黃橙色僅限「待審核 / pending」語意,不作為警示色——警示、錯誤一律紅色,統一走 --color-danger(不另設 --color-warning):

變數亮色值深色值語意使用情境
--color-success#28a745同左🟢 正常運行中、已連接、成功
--color-info#2b4d98#89a5e0🔵 一般進行中、說明、一般標記
--color-pending#d97706#f59e0b🟠 待審核待審核、草稿、排程中、等待處理
--color-danger#dc3545同左🔴 危險錯誤、失敗、危險操作
--color-status-neutral#6b7280#9ca3af—⚫ 未啟用

危險操作的 hover 加深色用 --color-danger-dark(#b91c1c)。

例外:終端機式的內容面固定深色、不隨主題切換——VNC / xterm 畫面底(ConsoleDialog、Classroom 的 #1e1e1e)、任務 log 輸出區(Jobs dialogOutput),以及錯誤頁插圖的白底(NotFoundPage 的雲朵、CrashState 的應用程式視窗、ConnectionLostState 的雲與視窗皆為 #fff,內部色塊也跟 #fff 混色——插圖在亮暗主題是同一張圖,亮色模式靠 drop-shadow 描輪廓)。

例外:範例程式碼區塊(AI API 快速開始的程式碼範例、API 聊天回覆裡的程式碼區塊)固定深底、不隨主題切換,比照 VS Code 深色編輯器;色票統一用 _themes.scss 的 --color-code-*(bg/header/border/hover/text/muted),不要在頁面裡另寫一組深色。

例外:Gateway 頁的類 VSCode 設定檔編輯器(ConfigCodeEditor.module.scss)整組寫死 vs-dark 色票(#1e1e1e、#252526、#007acc 等)與 13px/12px 字級,刻意不隨主題切換——外框需與 Monaco theme="vs-dark" 一致,模擬 VSCode 視窗本身即為獨立配色的容器。

狀態 Badge 的標準寫法 ​

膠囊外形與字級由 @include badge-base; 提供(inline-flex、膠囊圓角、12px/500、不換行),再加上內距與下面其中一組顏色:

scss
.badge_success { background: color-mix(in srgb, var(--color-success) 12%, transparent); color: var(--color-success); }
.badge_info    { background: color-mix(in srgb, var(--color-info)    12%, transparent); color: var(--color-info); }
.badge_pending { background: color-mix(in srgb, var(--color-pending) 12%, transparent); color: var(--color-pending); }
.badge_danger  { background: color-mix(in srgb, var(--color-danger)  12%, transparent); color: var(--color-danger); }
.badge_muted   { background: color-mix(in srgb, var(--color-status-neutral) 12%, transparent); color: var(--color-status-neutral); }

一律用 var(--color-*),不要把狀態色寫死成 HEX——深色模式的 info / pending 亮色值才吃得到。

徽章一律不加外框:只用「狀態色 12% 淡底+狀態色文字」,不要再加 border(實線、虛線都不要)。狀態、類型、範圍這類膠囊標籤都算徽章,非狀態的中性標籤用上面的 badge_muted。疊在畫布等會透出底圖的地方時,淡底改混 var(--color-surface)(color-mix(in srgb, <色> 12%, var(--color-surface)))讓它不透明,仍然不加框。 分頁、SegmentedControl、Stepper 的選中框不在此列。


SCSS 變數(_variables.scss) ​

間距 ​

scss
$spacing-4: 4px   $spacing-8: 8px   $spacing-16: 16px
$spacing-24: 24px  $spacing-32: 32px  $spacing-48: 48px

字體大小 ​

scss
$font-size-10: 10px   $font-size-12: 12px   $font-size-14: 14px   $font-size-16: 16px
$font-size-18: 18px   $font-size-24: 24px   $font-size-28: 28px   $font-size-32: 32px

$font-size-10 僅限資料密集區(密集網格、卡片 meta 列)的次要標籤使用;一般內文、說明文字最小 $font-size-12。

字重 ​

scss
$font-weight-400: 400   $font-weight-500: 500   $font-weight-700: 700

圓角 ​

scss
$radius-8: 8px   $radius-12: 12px   $radius-16: 16px   $radius-pill: 999px

動畫 ​

scss
$transition-base: 0.2s ease   $transition-slow: 0.3s ease

斷點 ​

scss
$breakpoint-sm: 576px   $breakpoint-md: 768px
$breakpoint-lg: 992px   $breakpoint-xl: 1200px
$breakpoint-sidebar: 1024px   // 側邊欄在抽屜與固定欄之間切換的寬度

其他 ​

scss
$font-family-base   $line-height-base: 1.6   $form-control-padding-x: 12px
$header-height: 60px   $sidebar-width: 260px   $sidebar-width-collapsed: 72px
$content-padding: 40px   $container-max-width: 1200px
$z-ai-assistant: 2000   $z-screen-dialog: 2100   // 見 z-index 層級

Mixin(_mixins.scss) ​

Flex 排版 ​

scss
@include flex-center;    // display:flex; align-items:center; justify-content:center
@include flex-between;   // display:flex; align-items:center; justify-content:space-between
@include flex-column;    // display:flex; flex-direction:column

文字截斷 ​

scss
@include text-truncate;     // 單行截斷+省略號
@include text-clamp(3);     // 多行截斷(預設 2 行)

容器 ​

scss
@include container;   // max-width: 1200px; margin-inline: auto; padding-inline: 16px

毛玻璃效果 ​

scss
@include glass-surface;                                // 預設玻璃陰影 var(--shadow-glass)
@include glass-surface($shadow: var(--shadow-sm));    // 換陰影
@include glass-surface($shadow: none);                // 不輸出 box-shadow
@include glass-surface(8px, 1.2);                     // 固定濾鏡參數(不跟隨風格切換,特殊情況才用)

玻璃表面的 backdrop-filter 一律走 var(--glass-backdrop-filter) (sidebar 用 var(--sidebar-backdrop-filter)),讓「液態玻璃」等 風格變體能整體換濾鏡——不要在元件裡寫死 blur(12px) saturate(1.4)。

響應式斷點 ​

scss
@include respond-to(md) {
  // min-width: 768px 時套用
}
@include respond-below(md) {
  // 小於 768px 時套用(max-width: 767.98px)
}

新版面一律 mobile-first,用 respond-to。respond-below 是給既有的 desktop-first 版面用的:反轉成 mobile-first 成本太高時,用它取代直接寫 max-width media query,讓斷點對齊共用級距。


Icon 使用規範 ​

所有 Icon 一律透過 MIcon 元件呼叫,預設為 outlined 風格;需要實心(filled)時傳 filled prop,不要自己換 class。

jsx
import MIcon from "../components/MIcon";

<MIcon name="search" size={16} />          {/* material-icons-outlined(預設) */}
<MIcon name="star" size={16} filled />     {/* material-icons(filled,特別強調時才用) */}
  • 字型是 Material Icons(@material-design-icons/font),不是 Material Symbols。Icon 名稱請到 Material Icons 圖示集 查詢(outlined 與 filled 同名);只有 Material Symbols 才有的名稱會直接顯示成文字
  • 禁止直接使用 <span className="material-icons">、material-icons-outlined 或其他 Icon 庫
  • 禁止使用 SVG inline、emoji、或其他圖示系統混搭

命名規範 ​

CSS Modules 類別名稱 ​

使用 camelCase:

scss
.cardHeader { }
.statusDot  { }
.headerBtn  { }

BEM 風格的子變體 ​

用底線 _ 區隔變體,而非 BEM 的 --:

scss
.badge_success { }
.badge_danger  { }
.dot_connected { }
.dot_error     { }

動畫 / 狀態後綴 ​

後綴用途
Out元素離場動畫(如 .powerMenuOut)
Active主動選中狀態(如 .menuBtnActive)
Disabled禁用樣式(優先用 CSS :disabled 偽類)

頁面版面(Page Layout) ​

頁面根容器(.page)一律滿寬:不設 max-width、不使用 margin: 0 auto 置中,讓內容吃滿 DashboardLayout 的內容區。全站頁面留白因此一致,寬螢幕下不會出現「某些頁置中留白、某些頁滿寬」的落差。

標準寫法:

scss
.page {
  @include flex-column;
  gap: $spacing-24;
  padding: $spacing-8 $spacing-16;
  flex: 1;

  @media (min-width: $breakpoint-sidebar) { padding: 0; }
}

例外:表單型窄頁可限制寬度——輸入欄位拉滿寬螢幕反而難用。這類例外限於「單欄表單」頁面,一般內容頁請維持滿寬。


元件樣式慣例 ​

卡片(Card) ​

scss
.card {
  @include glass-surface;
  border-radius: $radius-16;
  @include flex-column;
  overflow: hidden;
}
  • 可點卡片的 hover 只給淡底(background: var(--color-hover))或陰影/上浮,並保留 transition 淡入淡出;不改邊框色,藍框只留給「選中/目前」狀態(如分頁 active、流程目前步驟)
  • 淡底不要用 linear-gradient 疊層:漸層無法 transition,hover 會瞬間跳色
  • 玻璃卡裡的內容區塊(資訊欄位、備註框、程式碼/日誌/金鑰等 pre、清單列、統計格)一律 background: var(--color-surface)(白)+ 1px solid var(--color-border);不要鋪 --color-hover、--color-bg-base 等主色系淺藍。它們跟著主色走,使用者換了背景色,疊在透出背景的玻璃上就會糊。淺藍只留給 hover/選中狀態、按鈕、徽章、提示框
  • 拓撲畫布(React Flow)不在白底規則內:底色、框線、陰影一律用畫布專用 token background: var(--color-flow-bg)、1px solid var(--color-flow-border)、box-shadow: 0 4px 24px var(--color-flow-shadow),防火牆頁、課程環境編輯器、資源詳情、班級上課環境的唯讀拓撲都同一組

Dialog / Modal ​

新對話框一律用共用的 <Modal>(components/Modal/Modal),不要再手寫遮罩與卡片。它統一處理 portal 到 body、遮罩與進出場動畫、role/aria-modal/aria-labelledby、Esc 與點遮罩關閉(busy 時不關;疊兩層只關最上層)、 開啟時焦點移進對話框並在關閉後還原、Tab 鎖在對話框內、鎖住底下頁面捲動:

jsx
const presence = useDialogPresence(show);
{presence.open && (
  <Modal
    as="form" onSubmit={submit}          // 表單型才傳
    closing={presence.closing} onClose={() => setShow(false)} busy={saving}
    title="重設密碼" description="新密碼只會顯示一次"
    size="sm"                            // sm 400/md 640/lg 1100/xl 1280;錯誤 log 等寬內容用 log(560)
    closeButton                          // 欄位多的表單:標題列帶 ×、內容區自己捲、按鈕列固定在底部
    actions={<><button …>取消</button><button type="submit" …>送出</button></>}
  >
    …欄位…
  </Modal>
)}
  • 精簡卡(預設):確認框、命名框、小表單;closeButton 版:欄位多的表單(如連線對話框),內容只有一兩句的提醒(如工作階段提醒)可加 dividers={false} 拿掉標題列與按鈕列的分隔線;bare:只給外框,標題列與內容自己排
  • 終端機、VNC 這類 AI 讀不到的畫面用 layer="screen"(通常搭 bare):蓋過 AI 助手、不讓位;鍵盤全部交給畫面,Esc 不關、Tab 不鎖(Tab 補全、vim 的 Esc 才能用)。全螢幕要的對話框本體用 ref 拿
  • 導覽用的 data-guide 等屬性直接傳給 Modal(掛在對話框本體),×、按鈕列分別用 closeProps、actionsProps
  • 前端所有對話框都建在 Modal 上,包括 useConfirm、ConnectionDialog 與終端機/VNC/課堂觀看畫面;不要再手寫遮罩
  • 寬度分級(size):確認框/命名框 sm 400px;小型單欄表單 md 640px;一般 lg 1100px;寬版(如 VNC)xl 1280px;錯誤 log 等寬內容 log 560px
  • 高度:Modal 卡片最高 90vh;畫面型對話框(VNC/終端機主控台、課堂觀看)自己把高度固定在 88vh
  • 全螢幕:可以全螢幕的對話框用 :fullscreen 偽類設 max-width: 100%; height: 100%; border-radius: 0(見 ConsoleDialog.module.scss)
  • 遮罩:var(--color-overlay) 加 backdrop-filter: blur(4px)、z-index: 300,portal 到 document.body:祖先有 backdrop-filter/transform 時,position: fixed 會被困在那一層、蓋不滿整個畫面。AI 助手面板開著時,遮罩會讓出面板的寬度(dialog-overlay-avoid-ai),對話框改在剩下的空間置中
  • 卡片陰影:精簡卡用 glass-surface 預設陰影;closeButton 版用 var(--shadow-lg),浮在遮罩上比較突出
  • 危險操作的確認傳 role="alertdialog"(useConfirm 已經這樣做)

確認彈窗(全站統一) ​

危險操作/二選一確認一律用共用的 useConfirm()(components/ConfirmDialog/ConfirmProvider), 不要在頁面內自建本地 ConfirmModal:

jsx
const confirm = useConfirm();
if (!(await confirm({ title, message, confirmText, danger: true }))) return;
// …按下確認後彈窗即關閉,進度用按鈕 disabled + toast 呈現,不在彈窗內轉圈

外觀來自 Modal 的精簡卡:400px 的毛玻璃卡、$spacing-24 內距,標題/內文/按鈕列之間的間距全交給 $spacing-16 的 gap,不用 margin。

  • 標題:$font-size-16 / $font-weight-700,danger 帶紅色 warning 圖示、一般帶琥珀色 help 圖示
  • 內文:$font-size-14 次要色,加 overflow-wrap: anywhere(常插入主機名稱等連續長字串)
  • 進出場:遮罩 fadeIn 0.15s、卡片 slideUp 0.18s,Esc 可關閉

按鈕 ​

按鈕一律使用 _mixins.scss 的按鈕 mixin 組(六款),不要在頁面內重抄整組樣式:

scss
.btnPrimary       { @include btn-primary; }        // 主要動作:每個畫面至多一顆
.btnSecondary     { @include btn-secondary; }      // 並列的一般動作、返回
.btnDanger        { @include btn-danger; }         // 只用在確認彈窗/最終確認步驟
.btnDangerOutline { @include btn-danger-outline; } // 頁面內破壞性動作的「入口」,點了才進 useConfirm
.btnGhost         { @include btn-ghost; }          // 低強調輔助動作:淡底文字鈕(平常即有 --color-hover 淡底,hover 深一階)
.btnGhostDanger   { @include btn-ghost($danger: true); }  // 淡紅底紅字變體(窄空間的刪除)
.iconBtn          { @include btn-icon; }           // 32×32 圖示鈕,JSX 必帶 aria-label
.iconBtnDanger    { @include btn-icon($danger: true); }   // 未 hover 前文字即為紅色
.menuBtn          { @include btn-icon-secondary; } // 表格列的動作選單鈕(白底描邊),icon 一律 more_vert(⋮)
.dialogClose      { @include btn-dialog-close; }   // Dialog 專用右上關閉鈕:同 btn-icon($danger: true),僅未 hover 無底色
  • 共用基底(mixin 內建):高 36px、圓角 $radius-8、字級 14/500、 hover 一律 :not(:disabled)、disabled 一律 opacity: 0.5; cursor: not-allowed
  • 頁面差異(寬度、margin、grid 位置)寫在 @include 之後;要更矮更小請先問是不是真的需要
  • 既有頁面的「修飾類」家族(如表格列的 .actionBtn.actionBtnDanger)維持基類+修飾寫法, 但危險修飾類未 hover 前文字就要是紅色,且 hover 同樣必加 :not(:disabled)
  • 下拉選單裡的危險項(PowerMenu、頁首 ⋯ 選單)屬選單樣式,不在此六款之列

表單(Form) ​

表單欄位一律使用 _mixins.scss 的表單 mixin 組,不要在頁面內另立一套字級與內距:

scss
.field { @include form-field; }                              // 標籤 + 控制項的直式容器
.field input, .field select, .field textarea { @include form-control; }
.fieldInvalid.fieldInvalid { @include form-control-invalid; } // 送出時未填的欄位
jsx
<label className={styles.field}>
  <span>班級名稱</span>
  <input className={invalid ? styles.fieldInvalid : undefined} aria-invalid={invalid} … />
</label>

規則一:控制項高度固定 36px、字級 14px,刻意與 .btnPrimary / .btnSecondary 一致——同一列的欄位與按鈕才會對齊。要更矮更小的表單請先問是不是真的需要, 不要在頁面裡改 min-height 或 font-size。 高度的算法跟按鈕相同:全站行高 1.6(14px 字 → 22.4)+上下內距 6×2+框 1×2 ≈ 36px。 內距寫成 8px 就會變 40px;外框在容器上的搜尋框用 min-height: 36px,不要寫 38px。 共用的 SegmentedControl 也固定 36px,跟欄位、按鈕放同一列不用另外鎖高度。

規則二:欄位怎麼排(幾欄、哪個跨欄)寫在頁面自己的 grid 上(.formGrid、 .createFormGrid、.fieldFull),mixin 只負責欄位本身長什麼樣。

規則三:一組「起—迄」的值是一個欄位,不是兩個。用 .timePair 這種 成對控制項,標籤寫「上課時間」,不要拆成「開始時間」「結束時間」兩個 .field。

規則四:textarea 一律固定高——_reset.scss 已全域設 resize: none, 高度由 JSX 的 rows 或頁面 CSS 的 height / min-height 決定,元件內不要再寫 resize。特殊情況(真的需要讓使用者拖高的長文編輯區)才在該頁明確寫回 resize: vertical,讓例外看得見。

開關(Switch)與勾選框 ​

「立刻生效」的啟用/停用一律用共用的 Switch(components/Switch/Switch),不要再用開關圖示按鈕(toggle_on / toggle_off)或勾選框代替:

jsx
<Switch checked={rule.enable !== 0} onChange={(next) => toggle(rule, next)} disabled={busy} ariaLabel={t("…enableSwitch")} title={t("…enableSwitch")} />
  • 分工:一切換就送出 → Switch;要按「儲存」才生效的表單欄位 → 勾選框(.checkRow)。看到開關就知道會立刻生效,兩者別混用
  • 外觀:36×20 膠囊軌道+16px 白色圓鈕,開=--color-primary、關=灰,是一個 button role="switch"
  • 表格、清單列裡只放開關本體,不寫「啟用」字樣(欄名或列內容已交代控制什麼),改用 ariaLabel+title;真的需要可見文字才給 label,寫「控制什麼」、不隨狀態改字
  • 目前狀態由軌道位置與顏色表達;列表上停用的那一列,狀態徽章直接換成紅色「停用」(例如防火牆規則的「允許」換成「停用」),不另外並排「已停用」徽章
  • 停用的那一列只淡化內容,開關所在的操作欄不淡化,才看得出能再打開
  • 目前用在:資源詳情進階設定的防火牆規則、防火牆頁規則面板、「PVE 管理」頁的節點列表

表格(Table) ​

列表頁表格一律使用 _mixins.scss 的表格 mixin 組,不要在頁面內重抄整組樣式:

scss
.tableWrap { @include table-wrap; }        // 玻璃容器 + 圓角 + 橫向卷動
.table     { @include table-base; min-width: 720px; }  // min-width 依內容自定,撐出卷動
.th        { @include table-th; }
.tr        { @include table-tr; }          // 基底不含 hover,見下方規則
.td        { @include table-td; }
  • 欄寬、對齊、特殊儲存格(.thRight、.tdNowrap…)等頁面差異寫在 @include 之後
  • RWD 行為統一為 容器橫向卷動(table-wrap 內建 overflow-x: auto),不做表格轉卡片
  • 表格嵌在既有卡片內時可只用 table-base / table-th / table-tr / table-td,省略外層 table-wrap
  • <table> 本身由 components/DataTable(TanStack Table,見開發指南)輸出,class 仍來自頁面自己的 SCSS Module:表格用 className、列用 rowClassName(row)、各欄在欄位定義的 meta.thClassName/meta.tdClassName

規則一:整列 hover 變色 = 這一列可以點 ​

整列 hover 變色是互動訊號,不是裝飾。列本身不可點(互動都在儲存格內的按鈕上)時, 不要讓整列變色,否則是假的可點暗示。

scss
.tr          { @include table-tr; }            // 不可點:只有分隔線,無 hover
.trClickable { @include table-tr-clickable; }  // 可點:游標 + hover 一起給
  • 判斷標準只有一個:<tr> 自己有沒有 onClick(或 role / tabIndex)
  • 同一張表可以混用(監控頁:節點列可點、VM 列不可點),所以用兩支 mixin 疊加,不用布林參數
  • 不要自己寫 cursor: pointer 或 &:hover { background: … }——游標與 hover 會各自漂移
  • 需要不同的 hover 色時(例如群組列本身已有底色),可在 @include 之後覆寫 background

規則二:列數會變的表格要固定欄寬 ​

table-layout 預設的 auto 依全部列的內容計算欄寬。只要展開/收合會增減 完整欄位的列,每一欄的內容都變了,整張表就會跳動。

scss
.table { @include table-base; table-layout: fixed; min-width: 1080px; }

.colStatus { width: 100px; }   // 欄寬集中宣告,搭配 JSX 的 <colgroup>
  • 只有在展開列是 <td colSpan={N}> 的整寬詳情面板時才不需要—— colspan 儲存格不參與個別欄寬計算,不會造成跳動
  • 改用 fixed 後,要移除儲存格內為了「跟 auto layout 搶寬度」而設的 min-width, 它們只會讓內容溢出欄位
  • 不指定寬度的那一欄會吸收剩餘空間(通常留給名稱欄)

規則三:儲存格圖示要帶文字沒有的資訊 ​

MIcon 一律 aria-hidden,螢幕閱讀器讀不到。若圖示編碼的資訊 就寫在緊鄰的文字裡(型別、分類),它只是版面慣性,拿掉讓文字說話即可。

清單工具列與批量操作(DataToolbar) ​

清單或表格上方的「篩選+搜尋」與「勾選後的批量操作」一律用 components/DataToolbar 這一組,不要在頁面裡另做浮動操作列或自己的勾選框:

jsx
const selection = useRowSelection(shown.filter(canSelect).map((r) => r.id)); // 依畫面順序
const selected = shown.filter((r) => selection.isSelected(r.id));

<DataToolbar
  selecting={selected.length > 0}
  batch={(
    <BatchBar countLabel={t("…selectedCount", { count: selected.length })} onClear={selection.clear} progress={progress}>
      <BatchAction icon="block" label={t("…disable")} onClick={…} />
      <BatchDivider />
      <BatchAction icon="delete" label={t("…delete")} danger onClick={…} />
    </BatchBar>
  )}
>
  <SegmentedControl … />
  <ToolbarSearch value={query} onChange={setQuery} placeholder={t("…searchPlaceholder")} />
</DataToolbar>

<SelectCheckbox checked={selection.isSelected(r.id)} onToggle={(shift) => selection.toggle(r.id, shift)} ariaLabel={…} />
  • 工具列一直都在:平常沒有底色,放狀態切換與搜尋,控制項對齊清單左右緣;有勾選時原地換成批量列(淡主色底的浮起卡片,黏在頂端)。至少 56px 高,篩選在窄螢幕排成兩行時批量列沿用同一個高度——切換時清單不會跳
  • 全選:表格放在表頭第一欄(SelectCheckbox cell);沒有表頭的清單(使用者、申請、班級卡片)傳 BatchBar 的 selectAll,放在「已選 N」前面
  • 勾選框:整格或四周都是點擊範圍;列本身可點時,列的 onClick 要略過 label。Shift+點連選一段、Esc 清除選取、只勾一部分時全選框顯示「部分」
  • 勾選狀態下整列都能點:已勾了至少一筆後,點整列也是勾選(onClick={(e) => selection.rowClick(id, e)},回傳 true 就不要再開詳情);列裡其他按鈕、連結照舊。原本點了會進詳情的列(表格列、審核列、班級票券)在勾選狀態下改成勾選,取消全部勾選後恢復
  • 只勾得到看得見的:useRowSelection 只認傳進去的那些 id;換篩選、翻頁、刷新後消失的自動取消勾選,免得切回來才發現它還勾著、被一起操作
  • 動作:只送給適用的那幾筆(停用只送啟用中的…),全都不適用就停用按鈕,title 說明會略過幾筆;送出時 progress 讓整條換成「正在… 3 / 10」;做完靜默刷新,成功的取消勾選、失敗的保留勾選(selection.replace)可以直接再試
  • 回不去的動作(刪除、移除、封存、拒絕)用 danger,放在 BatchDivider 後面的最右邊,先確認並列出名稱(最多 5 個,其餘「等 N」)
  • 沒有後端批量 API 時用 utils/runBatch 逐筆呼叫單筆 API(預設同時 4 筆,一筆失敗不影響其他筆)
  • 勾選中的列底色用 @include row-selected;(自己就是卡片的清單列傳 var(--color-surface))
  • 導覽用的 data-guide 等屬性可以直接傳給 DataToolbar(掛在工具列外框),讓導覽步驟框住整條「篩選+搜尋」
  • 加了勾選欄要重算寬度:清單列用 grid 時,一行排完或分兩行要看容器寬度(container-type: inline-size+@container),不要看視窗——側邊欄出現(≥1024)後內容比 992 時還窄,用視窗切會在 1024~1180 之間撐出橫向捲動
  • 目前用在:資源管理、使用者管理(批量啟用/停用、加入/移出群組、刪除)、申請審核(批量拒絕)、班級管理(批量封存並回收)、班級工作頁學生名單(批量移除)
  • 浮在清單上方的選單 portal 到 document.body、用 position: fixed,由 hooks/useAnchoredMenu 定位:捲動與縮放時跟著錨點按鈕重算,依剩餘空間往下或往上展開,錨點捲出視窗就收起,點外面或按 Esc 也會關閉。PowerMenu、ConnectionDialog 的連接埠建議,以及 AI API 頁與範本頁的選單都用它
  • 選單底色用不透明的 var(--color-surface),不要套 glass-surface:底下的清單列會透出來
  • 關閉動畫用 useDialogPresence(open, 130) + CSS transition,不用 onAnimationEnd

步驟列(Stepper) ​

流程分頁/設定步驟一律用共用的 Stepper(components/Stepper/Stepper),不要再手刻箭頭分段(chevron)或自己畫圓點。 目前用在班級工作頁、教學環境編輯頁、一鍵建立班級(ClassSetupPage)、首次安裝初始化精靈(SetupPage)與首次登入引導(OnboardingPage)。

jsx
<Stepper
  ariaLabel={t("...")}
  steps={[{ key, label, done, disabled }]} // 圓點依序顯示 1、2、3…,done 時換成 ✓;disabled=精靈還不能跳過去的步驟
  extras={[{ key, label, icon }]}         // 選填:不算步驟的分頁,接在分隔線後面
  activeKey={tab}
  onSelect={(key) => ...}
/>
  • 外觀是直接放在頁面上的一排「圓點+標籤」(不鋪底色、不包卡片),步與步之間以連線相接;第一顆圓點貼齊頁面內容左緣
  • 狀態只靠圓點表達:未完成=白底淡藍框編號、已完成=白底主色框 ✓、目前=主色實心加光暈;標籤只分目前(粗、深)與其他
  • 連線兩端都「走到了」(已完成或目前)才上主色,其餘用淡主色
  • 步驟列直接壓在漸層背景上,不要用 --color-border 淺灰或淡色實心底:跟背景糊在一起看不到,一律用白底+藍色系框線
  • 精靈式流程:已完成的步驟和第一個待辦步驟都能點(退回前面改完可以直接跳回來),再後面的步驟設 disabled:只擋點擊、不淡化,不要做成點了沒反應的按鈕。能不能到、打不打勾都看已存到後端的實際進度,不看「走過了沒」;網址指到還不能去的步驟時拉回能到的最後一步
  • 精靈換步驟不自動存檔:目前這一步有沒存的修改時先 confirmLeave(),確認後捨棄再換(一鍵建立班級 ClassSetupPage)
  • 精靈版面(ClassSetupPage):步驟列下方是「目前這一步的卡片|右側摘要欄」。每一步都是同一張卡片(標題、內容、底部按鈕列),按鈕列用 position: sticky; bottom: 0,卡片因此用 overflow: clip(hidden 會變成捲動容器,按鈕列就貼不住);卡片高度跟著內容走,不設 min-height。分不分兩欄看頁面實際寬度(container: wizard / inline-size,≥760px 才分欄),不夠寬時摘要收成卡片上方可展開的一列;總覽步驟本身就是摘要,不放右欄
  • 不算步驟的分頁(班級啟用後的上課進度、AI)放 extras:圓點改放圖示、不連線,不要硬塞成第 5、6 步
  • 手機只留目前步驟的標籤,其他步驟剩圓點(標籤仍留給螢幕閱讀器);還放不下時橫向捲動

動畫規範 ​

入場動畫 ​

scss
@keyframes slideUp {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}
// 使用:animation: slideUp 0.18s cubic-bezier(0.25, 0.8, 0.25, 1);
scss
@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}
// 使用:animation: fadeIn 0.15s ease;

Dialog / Popup 的進出場(標準作法) ​

Dialog 一律「遮罩 fadeIn + 內容 slideUp」進場(Modal 已內建)。離場由共用 hook hooks/useDialogPresence.js 處理:關閉時先保留 DOM 150ms、設好 closing 讓淡出播完,再卸載。照上面的範例把 presence.closing 傳給 Modal 即可。

jsx
const dialog = useDialogPresence(editTarget);   // 布林或資料物件皆可
// 關閉期間 dialog.item 會保留最後一筆資料,避免內容閃爍
{dialog.open && <EditDialog target={dialog.item} closing={dialog.closing} … />}

共用 Dialog 元件(如 ConnectionDialog)接受 closing prop,由父層的 useDialogPresence 控制。自含式 Dialog(如 VncDialog、TerminalDialog、ClassroomWatchDialog)則在內部 setClosing(true) 後 setTimeout(onClose, 150)。

離場動畫(關閉) ​

優先使用 setTimeout + CSS transition,不使用 onAnimationEnd(有已知邊界問題)。選單可以直接用 useDialogPresence(open, 130),手寫的話長這樣:

jsx
// JSX
function closeMenu() {
  setClosing(true);
  setTimeout(() => { setOpen(false); setClosing(false); }, 130);
}
scss
// SCSS
.menu {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.12s ease, transform 0.12s ease;
}
.menuOut {
  animation: none;   // 覆蓋入場 animation,讓 transition 接管
  opacity: 0;
  transform: translateY(6px);
  pointer-events: none;
}

深色模式 ​

主題切換透過 body.dark class 實現,所有顏色均已在 _themes.scss 中定義亮色 / 深色兩套值。

元件 SCSS 一律使用 CSS 自訂屬性,不需要自行寫 body.dark & 覆蓋。

如果某元件有特殊深色需求,把覆寫寫在 body.dark 底下:

scss
:global(body.dark) & {
  color: #xxx;
}

z-index 層級 ​

層級值用途
基礎卡片1一般卡片
卡片 hover / 選單50容器內的 Dropdown 選單
側邊欄/Sticky Header100側邊欄與頁面頂部的列
Portal 浮層選單150portal 到 body 的 Dropdown(如 components/PowerMenu)
Dialog / Modal300全頁覆蓋 Dialog
AI 助手2000($z-ai-assistant)高於一般對話框,對話框開著時仍可使用
畫面型對話框2100($z-screen-dialog)Modal layer="screen"(VNC、終端機、課堂觀看):AI 讀不到的內容直接蓋過助手
導覽 UserGuide3000–3199導覽聚光層必須壓過含 Dialog 在內的一切(overlay 3100、示範視窗 3099)。此區段保留給 UserGuide,一般元件勿用
Toastsonner 預設值sonner 自己的 toaster z-index 高於其他所有層級

⚠️ 注意:使用 backdrop-filter 或 transform 的元素會建立新的 stacking context,子元素的 z-index 無法穿透至外層。若發現 Dropdown 被其他卡片遮住,請確認父元素是否有這類屬性。

玻璃表面(glass-surface)搭配 overflow: hidden 的容器還會裁掉溢出的 absolute 選單。浮層若可能超出容器範圍,改用 createPortal 掛到 document.body 並以 position: fixed 定位(範例:components/PowerMenu),且背景要用不透明的 var(--color-surface),否則會透出底下的列表內容。

以 AGPL-3.0 授權釋出。