Skip to content

第二十一章:LeptonX Lite 主題客製

學習目標

  • 理解 ABP LeptonX Lite 主題結構與限制(社群版)
  • 掌握 CSS 變數與 SCSS 客製方法
  • 實作深色/淺色模式動態切換
  • 能建立與發佈自訂主題

先備知識

  • 熟悉 CSS、SCSS 基礎
  • 已完成第四章(前端開發)或第二十章(React)
  • 了解 ABP 專案結構

正文

一、LeptonX Lite 主題簡介

特性

  • 輕量級、模組化設計
  • 支援深色/淺色主題切換
  • 響應式佈局(Mobile-first)
  • 內建暗黑模式支援
  • 社群版可用,Pro 版有更多主題

限制

  • 社群版功能較少,部分高級客製屬於 Pro
  • 某些企業級主題不可用
  • 支援 Razor Pages 與 Blazor Server/WebAssembly

二、主題資源位置

在 Razor Pages 應用中

text()

YourApp.Web/
├── wwwroot/
│   ├── css/
│   │   ├── theme.css           # 自訂樣式
│   │   └── variables.css       # CSS 變數
│   ├── js/
│   │   └── theme-switcher.js   # 主題切換邏輯
│   └── lib/
│       └── bootstrap/          # Bootstrap 資源
├── Pages/
│   ├── _Layout.cshtml          # 主佈局
│   └── _Host.cshtml            # Blazor 佈局
└── Themes/                      # 主題配置(若使用主題系統)

在 React SPA 中

text()

my-app/
├── src/
│   ├── styles/
│   │   ├── theme.css
│   │   ├── variables.css
│   │   └── dark-mode.css
│   ├── components/
│   │   └── ThemeSwitcher.tsx
│   └── App.tsx
└── public/
    └── themes/                 # 主題資源

三、CSS 變數與主題客製

使用 CSS 變數定義主題

css()

css
/* css - src/styles/variables.css */
:root {
  /* Light 模式(預設) */
  --color-primary: #007bff;
  --color-secondary: #6c757d;
  --color-danger: #dc3545;
  --color-success: #28a745;
  
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  
  --text-primary: #212529;
  --text-secondary: #6c757d;
  
  --border-color: #dee2e6;
  --shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}

/* Dark 模式 */
[data-theme="dark"] {
  --color-primary: #0d6efd;
  --color-secondary: #adb5bd;
  --color-danger: #f8615d;
  --color-success: #51cf66;
  
  --bg-primary: #1a1a1a;
  --bg-secondary: #2d2d2d;
  
  --text-primary: #e9ecef;
  --text-secondary: #adb5bd;
  
  --border-color: #495057;
  --shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3);
}

應用 CSS 變數

css()

css
/* css - src/styles/theme.css */
body {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  transition: background-color 0.3s ease, color 0.3s ease;
}

.btn-primary {
  background-color: var(--color-primary);
  border-color: var(--color-primary);
}

.card {
  background-color: var(--bg-secondary);
  border-color: var(--border-color);
  box-shadow: var(--shadow);
}

.navbar {
  background-color: var(--bg-secondary);
  border-bottom: 1px solid var(--border-color);
}

四、深色/淺色模式切換

在 Razor Pages 中實作

csharp()

csharp
// csharp - Pages/_Layout.cshtml
<script>
    // 初始化主題
    (function() {
        const theme = localStorage.getItem('theme') || 'light';
        document.documentElement.setAttribute('data-theme', theme);
    })();
    
    function toggleTheme() {
        const current = document.documentElement.getAttribute('data-theme');
        const newTheme = current === 'light' ? 'dark' : 'light';
        document.documentElement.setAttribute('data-theme', newTheme);
        localStorage.setItem('theme', newTheme);
        
        // 通知伺服器更新使用者設定
        fetch('/api/user-settings/theme', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ theme: newTheme })
        });
    }
</script>

<button onclick="toggleTheme()" class="btn btn-outline-secondary">
    <i class="fas fa-moon"></i> 切換主題
</button>

在 React 中實作

typescript()

typescript
// typescript - src/components/ThemeSwitcher.tsx
import React from 'react';

export function ThemeSwitcher() {
  const [theme, setTheme] = React.useState<'light' | 'dark'>(
    (localStorage.getItem('theme') as any) || 'light'
  );

  const toggleTheme = React.useCallback(() => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    
    // 應用主題至 DOM
    document.documentElement.setAttribute('data-theme', newTheme);
    localStorage.setItem('theme', newTheme);
    
    // 通知伺服器(可選)
    fetch('/api/user-settings/theme', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ theme: newTheme })
    }).catch(err => console.error('Failed to save theme:', err));
  }, [theme]);

  React.useEffect(() => {
    // 初始化主題
    document.documentElement.setAttribute('data-theme', theme);
  }, []);

  return (
    <button 
      onClick={toggleTheme} 
      className="btn btn-outline-secondary"
      title={`切換至 ${theme === 'light' ? '深色' : '淺色'} 模式`}
    >
      <i className={theme === 'light' ? 'fas fa-moon' : 'fas fa-sun'}></i>
    </button>
  );
}

在 App 中使用

typescript()

typescript
// typescript - src/App.tsx
import { ThemeSwitcher } from '@/components/ThemeSwitcher';

export function App() {
  return (
    <div>
      <header>
        <nav>
          {/* ... 導覽內容 ... */}
          <ThemeSwitcher />
        </nav>
      </header>
      <main>{/* 頁面內容 */}</main>
    </div>
  );
}

五、系統偏好檢測

根據作業系統設定自動選擇主題

typescript()

typescript
// typescript
function getSystemTheme(): 'light' | 'dark' {
  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
    return 'dark';
  }
  return 'light';
}

function initializeTheme() {
  const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
  const theme = savedTheme || getSystemTheme();
  
  document.documentElement.setAttribute('data-theme', theme);
}

// 監聽系統主題變更
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
  const newTheme = e.matches ? 'dark' : 'light';
  document.documentElement.setAttribute('data-theme', newTheme);
});

initializeTheme();

六、佈局客製

修改主佈局(Razor Pages)

html()

html
<!-- html - Pages/_Layout.cshtml -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>@ViewData["Title"] - 我的應用</title>
    
    <!-- 主題樣式 -->
    <link rel="stylesheet" href="~/css/variables.css" />
    <link rel="stylesheet" href="~/css/theme.css" />
    <!-- Bootstrap 或其他 CSS 框架 -->
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-light">
        <div class="container">
            <a class="navbar-brand" href="/">我的應用</a>
            <button class="btn btn-outline-secondary ms-auto" onclick="toggleTheme()">
                <i class="fas fa-moon"></i>
            </button>
        </div>
    </nav>
    
    <main class="container mt-4">
        @RenderBody()
    </main>
    
    <script src="~/js/theme-switcher.js"></script>
</body>
</html>

七、SCSS 變數與 Mixin 進階客製

使用 SCSS 簡化主題定義

scss()

scss
// scss - src/styles/theme.scss
$light-primary: #007bff;
$light-secondary: #6c757d;
$dark-primary: #0d6efd;
$dark-secondary: #adb5bd;

$light-bg: #ffffff;
$dark-bg: #1a1a1a;

// Mixin 定義主題樣式
@mixin themed {
  @media (prefers-color-scheme: light) {
    @content(
      $light-primary,
      $light-secondary,
      $light-bg
    );
  }
  
  @media (prefers-color-scheme: dark) {
    @content(
      $dark-primary,
      $dark-secondary,
      $dark-bg
    );
  }
}

// 使用 Mixin
.btn-primary {
  @include themed using ($primary, $secondary, $bg) {
    background-color: $primary;
    border-color: $primary;
  }
}

body {
  @include themed using ($primary, $secondary, $bg) {
    background-color: $bg;
    color: $secondary;
  }
}

八、主題配置檔與預設

建立主題配置

json()

json
/* json - src/themes/default.json */
{
  "name": "Default Light",
  "isDark": false,
  "colors": {
    "primary": "#007bff",
    "secondary": "#6c757d",
    "danger": "#dc3545",
    "success": "#28a745",
    "warning": "#ffc107",
    "info": "#17a2b8"
  },
  "typography": {
    "fontFamily": "Segoe UI, Arial, sans-serif",
    "fontSize": "14px",
    "lineHeight": "1.5"
  },
  "spacing": {
    "unit": "8px"
  }
}

動態載入主題配置

typescript()

typescript
// typescript
async function loadThemeConfig(themeName: string) {
  const response = await fetch(`/themes/${themeName}.json`);
  const config = await response.json();
  
  // 應用 CSS 變數
  Object.entries(config.colors).forEach(([key, value]) => {
    document.documentElement.style.setProperty(`--color-${key}`, value as string);
  });
  
  localStorage.setItem('current-theme', themeName);
}

// 使用
await loadThemeConfig('dark');

九、實務最佳實務

  • 為主題提供合理預設(遵循系統偏好)
  • 使用 CSS 變數而非硬編碼顏色
  • 確保無障礙性(對比度、字型大小)
  • 測試淺色/深色模式的可讀性
  • 提供主題切換的使用者回饋(動畫、圖示變更)
  • 支援偏好持久化(localStorage 或伺服器)

實作練習

  1. 為 React 應用建立完整的主題系統(淺/深色 + CSS 變數)
  2. 實作主題切換器組件與系統偏好檢測
  3. 建立主題配置檔並動態載入
  4. 測試不同主題下的可讀性與一致性

習題(至少 6 題)

概念題(易)

  1. CSS 變數與 SCSS 變數的差異為何?(難度:易)
  2. 為什麼應該支援 prefers-color-scheme?(難度:中)

計算 / 練習題(中) 3. 設計一個包含 10 個顏色、5 個間距、3 種字型的主題配置結構。(難度:中) 4. 比較三種主題儲存方式(localStorage、sessionStorage、伺服器資料庫)的優缺點。(難度:中)

實作 / 編碼題(較難) 5. 實作一個完整的主題系統(包括淺/深色、多主題切換、系統偏好檢測、過渡動畫)。(難度:較難) 6. 建立一個主題編輯器 UI,允許使用者自訂顏色並即時預覽。(難度:較難)

術語表

  • CSS Variable(CSS 變數):在 CSS 中定義可重用的值,如 --color-primary
  • prefers-color-scheme:CSS Media Query,檢測使用者系統深色/淺色偏好
  • Theme Switching(主題切換):動態變更應用的視覺樣式
  • Accessibility(可及性):確保應用易於使用(含視障、色弱)

參考資料

習題解答:content/solutions/ch21-solutions.md

Released under the MIT License.