iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 13

Day 13:30天打造SaaS產品前端篇-企業級包管理 + 多租戶架構前端功能

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 12 的會員管理介面實作,我們已經建立了一套完整的 CRUD 操作系統。今天我們將進入企業級開發工作流程,建立私有 NPM repository,並完善多租戶架構的前端。

今天我們將完成:

  • 🏗️ 建立私有 NPM Registry (Verdaccio)
  • 🔄 使用 yalc 進行本地包開發
  • 🏢 多租戶架構前端實現
  • 🔐 租戶權限控制系統
  • 🎨 多租戶主題管理

🏗️ 私有 NPM Registry 建立

為什麼需要私有 Registry?

// 企業級 SaaS 開發需求分析
interface EnterpriseNeeds {
  // 程式碼安全性
  security: {
    privatePackages: boolean;      // 私有包不公開
    accessControl: boolean;        // 存取權限控制
    auditTrail: boolean;          // 操作審計追蹤
  };

  // 開發效率
  efficiency: {
    fastInstallation: boolean;     // 內網高速下載
    offlineCapability: boolean;    // 離線開發支援
    customVersioning: boolean;     // 自定義版本策略
  };

  // 成本控制
  costControl: {
    bandwidthSaving: boolean;      // 節省外網頻寬
    complianceRequirement: boolean; // 合規要求
    teamCollaboration: boolean;    // 團隊協作
  };
} 

私有 NPM Registry 方案比較

在選擇私有 NPM Registry 時,我們需要考慮成本、控制權、易用性和企業需求。我們來比較目前的方案:

// 私有 Registry 方案比較
interface RegistryComparison {
  name: string;
  cost: 'free' | 'low' | 'medium' | 'high';
  setup: 'easy' | 'medium' | 'complex';
  control: 'full' | 'partial' | 'limited';
  performance: 'excellent' | 'good' | 'fair';
  enterprise: 'yes' | 'partial' | 'no';
  recommendation: string;
}

const registryOptions: RegistryComparison[] = [
  {
    name: 'Verdaccio',
    cost: 'free',           // ✅ 完全免費
    setup: 'easy',          // ✅ Docker 一鍵部署
    control: 'full',        // ✅ 完全自主控制
    performance: 'excellent', // ✅ 輕量級高效能
    enterprise: 'yes',      // ✅ 支援企業功能
    recommendation: '最佳選擇:中小企業、快速部署、完全控制'
  },
  {
    name: 'GitHub Packages',
    cost: 'medium',         // ❌ 企業版需付費
    setup: 'easy',          // ✅ 與 GitHub 整合
    control: 'limited',     // ❌ 依賴 GitHub 政策
    performance: 'good',    // ⚠️ 受 GitHub 限制
    enterprise: 'partial',  // ⚠️ 功能有限
    recommendation: '適合:已使用 GitHub Enterprise,不需要複雜配置'
  },
  {
    name: 'AWS CodeArtifact',
    cost: 'medium',         // ❌ 按使用量計費
    setup: 'medium',        // ⚠️ 需要 AWS 配置
    control: 'partial',     // ⚠️ AWS 服務依賴
    performance: 'excellent', // ✅ AWS 全球基礎設施
    enterprise: 'yes',      // ✅ 企業級功能
    recommendation: '適合:重度 AWS 用戶,需要全球分發'
  },
  {
    name: 'Nexus Repository',
    cost: 'high',           // ❌ 企業版昂貴
    setup: 'complex',       // ❌ 複雜的 JVM 配置
    control: 'full',        // ✅ 完全控制
    performance: 'good',    // ⚠️ 資源需求高
    enterprise: 'yes',      // ✅ 全功能企業解決方案
    recommendation: '適合:大型企業,多語言生態系統,有專門維運團隊'
  },
  {
    name: 'JFrog Artifactory',
    cost: 'high',           // ❌ 商業授權昂貴
    setup: 'complex',       // ❌ 企業級複雜度
    control: 'full',        // ✅ 完全控制
    performance: 'excellent', // ✅ 企業級效能
    enterprise: 'yes',      // ✅ 頂級企業功能
    recommendation: '適合:大型企業,預算充足,需要全方位 DevOps 解決方案'
  }
];

為什麼選擇 Verdaccio?

基於我們健身房 SaaS 專案的特性,Verdaccio 是最佳選擇:

1. 成本效益分析

# 每年成本比較 (假設中型團隊 10 人)
echo "📊 每年成本比較 (10 人團隊)"
echo "--------------------------------"
echo "Verdaccio:           $0     (僅伺服器成本 ~$500/年)"
echo "GitHub Packages:     $4,000  (企業版 $4/user/month)"
echo "AWS CodeArtifact:    $2,400  (估算使用量)"
echo "Nexus Pro:          $20,000  (商業授權)"
echo "JFrog Artifactory:  $50,000+ (企業版)"

2. 技術優勢對比

// Verdaccio vs AWS CodeArtifact 詳細比較
const technicalComparison = {
  deployment: {
    verdaccio: {
      setup: '5分鐘 Docker 部署',
      maintenance: '幾乎零維護',
      upgrade: 'Docker image 更新',
      backup: '簡單檔案備份'
    },
    codeartifact: {
      setup: '需要 IAM 設定、VPC 配置',
      maintenance: 'AWS 服務依賴',
      upgrade: 'AWS 自動管理',
      backup: 'AWS 原生備份'
    }
  },

  performance: {
    verdaccio: {
      localCache: '本地快取,快速存取',
      bandwidth: '內網傳輸,無限制',
      latency: '<10ms (內網)',
      offline: '完整離線支援'
    },
    codeartifact: {
      localCache: '需要配置本地快取',
      bandwidth: '計費項目,需控制',
      latency: '50-200ms (視區域)',
      offline: '受限離線支援'
    }
  },

  customization: {
    verdaccio: {
      plugins: '豐富插件生態系統',
      ui: '可完全客製化介面',
      auth: '支援多種認證方式',
      proxy: '靈活的代理配置'
    },
    codeartifact: {
      plugins: 'AWS 原生功能',
      ui: 'AWS Console 介面',
      auth: '僅支援 AWS IAM',
      proxy: 'AWS 服務整合'
    }
  }
};

3. 企業需求適配

// 健身房 SaaS 專案特殊需求
interface ProjectRequirements {
  // 開發階段需求
  development: {
    rapidPrototyping: true,    // ✅ Verdaccio 快速部署
    teamCollaboration: true,   // ✅ 簡單的權限管理
    costSensitive: true,       // ✅ 零授權成本
    easyMaintenance: true      // ✅ 低維護需求
  };

  // 部署需求
  deployment: {
    multiEnvironment: true,    // ✅ 開發/測試/生產環境
    dockerSupport: true,       // ✅ 原生 Docker 支援
    cloudAgnostic: true,       // ✅ 不依賴特定雲服務
    backupStrategy: true       // ✅ 簡單檔案備份
  };

  // 安全需求
  security: {
    accessControl: true,       // ✅ 用戶權限管理
    auditLogging: true,        // ✅ 操作日誌記錄
    networkIsolation: true,    // ✅ 內網隔離部署
    tokenAuthentication: true  // ✅ NPM token 認證
  };
}

GitHub Packages 為什麼不適合?

// GitHub Packages 限制分析
const githubPackagesLimitations = {
  cost: {
    freeUser: '500MB 儲存 + 1GB 傳輸/月',
    teamPlan: '$4/user/月 + 額外使用費',
    calculation: '10人團隊 = $480/年 + 超額費用'
  },

  restrictions: {
    publicPackages: '必須公開原始碼',
    privatePackages: '僅限 GitHub 倉庫',
    downloads: '外部下載計費',
    bandwidth: '傳輸量限制'
  },

  vendor_lock_in: {
    platform: '完全依賴 GitHub',
    migration: '遷移困難',
    control: '無法客製化',
    availability: '受 GitHub 服務狀態影響'
  }
};

AWS CodeArtifact 為什麼不是首選?

// AWS CodeArtifact 成本計算
const codeArtifactCosts = {
  storage: '$0.05/GB/月',        // 儲存費用
  requests: '$0.05/1000 requests', // API 請求費用

  estimatedMonthlyCost: {
    storage: '10GB × $0.05 = $0.5',
    requests: '100K × $0.05 = $5',
    total: '$5.5/月 = $66/年',
    note: '不含資料傳輸費用'
  },

  hiddenCosts: {
    dataTransfer: '跨區域傳輸額外收費',
    bandwidth: '外部下載計費',
    complexity: 'IAM 配置和維護成本',
    vendor_lock: 'AWS 生態系統依賴'
  }
};

Nexus 為什麼過於複雜?

# Nexus Repository 資源需求
nexus_requirements:
  minimum_ram: 4GB          # 最小記憶體需求
  recommended_ram: 8GB      # 建議記憶體
  jvm_tuning: required      # 需要 JVM 調優
  disk_space: 100GB+        # 磁碟空間需求
  maintenance: high         # 高維護需求

  vs_verdaccio:
    ram: 256MB              # Verdaccio 僅需 256MB
    setup: "docker run"     # 單行指令部署
    maintenance: minimal    # 最小維護需求
    expertise: none         # 無需特殊技能

實際場景驗證

在我們的健身房 SaaS 專案中,Verdaccio 的優勢特別明顯:

// 實際使用場景
const realWorldUsage = {
  scenario1: {
    description: '開發者本地開發',
    verdaccio: '內網 10ms 下載,離線可用',
    github: '外網依賴,無離線支援',
    aws: '需要 VPN/IAM 配置複雜'
  },

  scenario2: {
    description: 'CI/CD 管道',
    verdaccio: '內網高速,無額外費用',
    github: '計費下載,速度一般',
    aws: 'API 調用費用,配置複雜'
  },

  scenario3: {
    description: '多環境部署',
    verdaccio: '每環境獨立部署',
    github: '全域服務,無法隔離',
    aws: '需要多個 CodeArtifact 域'
  }
};

Verdaccio 企業級設定

既然選擇了 Verdaccio,讓我們深入了解如何進行企業級配置:

// 企業級 Verdaccio 配置
    pros: ['官方支援', '高穩定性', '完整功能'],
    cons: ['昂貴費用', '$7/user/month'],
    suitable: '大型企業,預算充足'
  },
  {
    name: 'GitHub Packages',
    pros: ['與 GitHub 整合', '免費私有包', '良好 CI/CD'],
    cons: ['需要 GitHub 帳號', '有流量限制'],
    suitable: '已使用 GitHub 的團隊'
  },
  {
    name: 'Verdaccio',
    pros: ['完全免費', '自主控制', '輕量高效'],
    cons: ['需自行維護', '需要伺服器'],
    suitable: '中小型團隊,技術自主'
  },
  {
    name: 'JFrog Artifactory',
    pros: ['企業級功能', '多格式支援', '高度可客製'],
    cons: ['複雜配置', '高昂費用'],
    suitable: '大型企業,多技術棧'
  }
];

// 我們選擇 Verdaccio:輕量、免費、功能完整

Verdaccio 安裝與配置

# 全域安裝 Verdaccio
npm install -g verdaccio

# 啟動 Verdaccio(預設 http://localhost:4873)
verdaccio

# 或使用 Docker(推薦生產環境)
docker run -it --rm --name verdaccio \
  -p 4873:4873 \
  -v /path/to/storage:/verdaccio/storage \
  -v /path/to/config:/verdaccio/conf \
  verdaccio/verdaccio
# verdaccio/config.yaml - 自定義配置
storage: /verdaccio/storage/data
auth:
  htpasswd:
    file: /verdaccio/storage/htpasswd
    # Maximum amount of users allowed to register, defaults to "+infinity"
    max_users: 100

# 上游 registry 配置
uplinks:
  npmjs:
    url: https://registry.npmjs.org/
    cache: true

# 包權限配置
packages:
  # 私有包(以 @kyong 開頭)
  '@kyong/*':
    access: $authenticated
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs

  # 公開包
  '**':
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs

# 伺服器配置
listen:
  - 0.0.0.0:4873

# Web UI 配置
web:
  enable: true
  title: Kyong Private Registry
  logo: https://your-domain.com/logo.png

# 日誌配置
logs:
  - { type: stdout, format: pretty, level: http }

npm/pnpm 配置

# 設置私有 registry
npm config set @kyong:registry http://localhost:4873
# 或針對整個專案
npm config set registry http://localhost:4873

# pnpm 配置
echo "@kyong:registry=http://localhost:4873" >> .npmrc
echo "//localhost:4873/:_authToken=\${NPM_TOKEN}" >> .npmrc

# 創建使用者帳號
npm adduser --registry http://localhost:4873

🔄 yalc 本地開發工作流程

yalc 簡介與安裝

# 全域安裝 yalc
npm install -g yalc

# yalc 工作原理
# 1. yalc publish - 將包發布到本地 yalc store
# 2. yalc add - 在使用端增加本地包
# 3. yalc push - 推送更新到所有使用端
# 4. yalc remove - 移除本地包依賴

yalc vs npm link 比較

// 開發工具比較
interface DevToolComparison {
  tool: string;
  pros: string[];
  cons: string[];
  useCase: string;
}

const tools: DevToolComparison[] = [
  {
    tool: 'npm link',
    pros: ['內建工具', '零配置', '即時連結'],
    cons: ['符號連結問題', 'peer dependency 衝突', '路徑問題'],
    useCase: '簡單場景,單一包開發'
  },
  {
    tool: 'yalc',
    pros: ['模擬真實發布', '無符號連結', '版本管理'],
    cons: ['需額外安裝', '手動推送更新'],
    useCase: '複雜專案,多包開發'
  },
  {
    tool: 'pnpm link',
    pros: ['pnpm 生態', '較好依賴處理'],
    cons: ['僅限 pnpm', '仍有連結問題'],
    useCase: 'pnpm workspace 環境'
  }
];

整合 yalc 到開發流程

// packages/kyo-ui/package.json - 添加開發腳本
{
  "name": "@kyong/kyo-ui",
  "version": "1.0.0",
  "scripts": {
    "build": "rollup -c",
    "build:watch": "rollup -c -w",
    "dev": "concurrently \"npm run build:watch\" \"npm run yalc:watch\"",
    "yalc:publish": "yalc publish",
    "yalc:push": "yalc push",
    "yalc:watch": "chokidar \"dist/**/*\" -c \"npm run yalc:push\"",
    "release:local": "npm run build && npm run yalc:publish",
    "release:registry": "npm run build && npm publish --registry http://localhost:4873"
  },
  "files": ["dist", "package.json", "README.md"],
  "devDependencies": {
    "chokidar-cli": "^3.0.0",
    "concurrently": "^8.2.0"
  }
}
// apps/kyo-dashboard/package.json - 消費端腳本
{
  "scripts": {
    "dev": "vite",
    "dev:local": "npm run use:local && npm run dev",
    "use:local": "yalc add @kyong/kyo-ui && yalc add @kyong/kyo-core",
    "use:registry": "yalc remove @kyong/kyo-ui && yalc remove @kyong/kyo-core && pnpm install",
    "update:local": "yalc update"
  }
}

自動化開發流程

#!/bin/bash
# scripts/dev-setup.sh - 開發環境一鍵啟動

echo "🚀 啟動 Kyong SaaS 開發環境"

# 1. 啟動 Verdaccio(背景執行)
echo "📦 啟動私有 NPM Registry..."
verdaccio &
VERDACCIO_PID=$!

# 2. 啟動包的 watch 模式
echo "🔨 啟動包開發模式..."
cd packages/kyo-ui && npm run dev &
cd packages/kyo-core && npm run dev &

# 3. 等待幾秒讓包編譯完成
sleep 5

# 4. 啟動前端開發服務器
echo "🌐 啟動前端開發服務器..."
cd apps/kyo-dashboard && npm run dev:local

# 清理函數
cleanup() {
  echo "🧹 清理開發環境..."
  kill $VERDACCIO_PID 2>/dev/null
  pkill -f "rollup.*watch" 2>/dev/null
  pkill -f "vite" 2>/dev/null
}

# 註冊清理函數
trap cleanup EXIT

🏢 多租戶架構前端實現

租戶上下文管理

// packages/kyo-core/src/multi-tenant/tenant-context.ts
import { createContext, useContext } from 'react';

export interface Tenant {
  id: string;
  name: string;
  subdomain: string;
  plan: 'free' | 'basic' | 'premium' | 'enterprise';
  status: 'active' | 'suspended' | 'trial';
  settings: {
    branding: {
      logo?: string;
      primaryColor: string;
      secondaryColor: string;
      fontFamily: string;
    };
    features: {
      memberManagement: boolean;
      paymentProcessing: boolean;
      analytics: boolean;
      customReports: boolean;
      apiAccess: boolean;
    };
    limits: {
      maxMembers: number;
      maxStaff: number;
      storageGB: number;
      apiCallsPerMonth: number;
    };
  };
  subscription: {
    planId: string;
    status: 'active' | 'cancelled' | 'past_due';
    currentPeriodStart: Date;
    currentPeriodEnd: Date;
    cancelAtPeriodEnd: boolean;
  };
  metadata: {
    createdAt: Date;
    updatedAt: Date;
    timezone: string;
    locale: string;
  };
}

export interface TenantContextValue {
  currentTenant: Tenant | null;
  tenants: Tenant[];
  isLoading: boolean;
  error: string | null;

  // 租戶操作
  switchTenant: (tenantId: string) => Promise<void>;
  refreshTenant: () => Promise<void>;

  // 權限檢查
  hasFeature: (feature: keyof Tenant['settings']['features']) => boolean;
  hasPermission: (permission: string) => boolean;
  isWithinLimits: (resource: keyof Tenant['settings']['limits'], usage: number) => boolean;
}

export const TenantContext = createContext<TenantContextValue | null>(null);

export function useTenant() {
  const context = useContext(TenantContext);
  if (!context) {
    throw new Error('useTenant must be used within a TenantProvider');
  }
  return context;
}

租戶提供者實現

// packages/kyo-core/src/multi-tenant/TenantProvider.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { TenantContext, Tenant, TenantContextValue } from './tenant-context';
import { tenantApi } from '../api/tenant-api';
import { useAuth } from '../auth/auth-context';

interface TenantProviderProps {
  children: React.ReactNode;
}

export const TenantProvider: React.FC<TenantProviderProps> = ({ children }) => {
  const { user } = useAuth();
  const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
  const [tenants, setTenants] = useState<Tenant[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // 載入用戶的租戶列表
  const loadTenants = useCallback(async () => {
    if (!user) return;

    setIsLoading(true);
    setError(null);

    try {
      const userTenants = await tenantApi.getUserTenants();
      setTenants(userTenants);

      // 設置預設租戶
      if (userTenants.length > 0 && !currentTenant) {
        const defaultTenant = userTenants[0];
        setCurrentTenant(defaultTenant);

        // 儲存到 localStorage
        localStorage.setItem('currentTenantId', defaultTenant.id);
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : '載入租戶失敗');
    } finally {
      setIsLoading(false);
    }
  }, [user, currentTenant]);

  // 切換租戶
  const switchTenant = useCallback(async (tenantId: string) => {
    const tenant = tenants.find(t => t.id === tenantId);
    if (!tenant) {
      throw new Error('租戶不存在');
    }

    setCurrentTenant(tenant);
    localStorage.setItem('currentTenantId', tenantId);

    // 觸發租戶切換事件(用於清理快取等)
    window.dispatchEvent(new CustomEvent('tenantChanged', {
      detail: { tenantId, tenant }
    }));
  }, [tenants]);

  // 刷新當前租戶資訊
  const refreshTenant = useCallback(async () => {
    if (!currentTenant) return;

    try {
      const updatedTenant = await tenantApi.getTenant(currentTenant.id);
      setCurrentTenant(updatedTenant);

      // 更新租戶列表中的對應項目
      setTenants(prev => prev.map(t =>
        t.id === updatedTenant.id ? updatedTenant : t
      ));
    } catch (err) {
      setError(err instanceof Error ? err.message : '刷新租戶資訊失敗');
    }
  }, [currentTenant]);

  // 權限檢查函數
  const hasFeature = useCallback((feature: keyof Tenant['settings']['features']) => {
    return currentTenant?.settings.features[feature] ?? false;
  }, [currentTenant]);

  const hasPermission = useCallback((permission: string) => {
    // 這裡可以實現更複雜的權限邏輯
    // 例如基於用戶角色、租戶計劃等
    return true; // 簡化實現
  }, []);

  const isWithinLimits = useCallback((
    resource: keyof Tenant['settings']['limits'],
    usage: number
  ) => {
    if (!currentTenant) return false;
    const limit = currentTenant.settings.limits[resource];
    return usage < limit;
  }, [currentTenant]);

  // 初始化時恢復租戶狀態
  useEffect(() => {
    const savedTenantId = localStorage.getItem('currentTenantId');
    if (savedTenantId && tenants.length > 0) {
      const savedTenant = tenants.find(t => t.id === savedTenantId);
      if (savedTenant) {
        setCurrentTenant(savedTenant);
      }
    }
  }, [tenants]);

  // 當用戶登入時載入租戶
  useEffect(() => {
    if (user) {
      loadTenants();
    } else {
      setCurrentTenant(null);
      setTenants([]);
    }
  }, [user, loadTenants]);

  const contextValue: TenantContextValue = {
    currentTenant,
    tenants,
    isLoading,
    error,
    switchTenant,
    refreshTenant,
    hasFeature,
    hasPermission,
    isWithinLimits,
  };

  return (
    <TenantContext.Provider value={contextValue}>
      {children}
    </TenantContext.Provider>
  );
};

租戶切換組件

// packages/kyo-ui/src/components/TenantSwitcher.tsx
import React, { useState } from 'react';
import {
  Group,
  Select,
  Avatar,
  Text,
  Badge,
  Menu,
  Button,
  Divider,
  ActionIcon,
  Loader,
  Alert,
} from '@mantine/core';
import {
  IconBuilding,
  IconChevronDown,
  IconSettings,
  IconCrown,
  IconUsers,
  IconRefresh,
} from '@tabler/icons-react';
import { useTenant } from '@kyong/kyo-core';

export interface TenantSwitcherProps {
  variant?: 'select' | 'menu';
  size?: 'xs' | 'sm' | 'md' | 'lg';
  showDetails?: boolean;
}

export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
  variant = 'menu',
  size = 'md',
  showDetails = true,
}) => {
  const {
    currentTenant,
    tenants,
    isLoading,
    error,
    switchTenant,
    refreshTenant,
  } = useTenant();

  const [isSwitching, setIsSwitching] = useState(false);

  const handleTenantSwitch = async (tenantId: string) => {
    if (tenantId === currentTenant?.id) return;

    setIsSwitching(true);
    try {
      await switchTenant(tenantId);
    } catch (err) {
      console.error('切換租戶失敗:', err);
    } finally {
      setIsSwitching(false);
    }
  };

  const getPlanBadgeColor = (plan: string) => {
    const colors = {
      free: 'gray',
      basic: 'blue',
      premium: 'green',
      enterprise: 'purple',
    };
    return colors[plan as keyof typeof colors] || 'gray';
  };

  const getPlanIcon = (plan: string) => {
    if (plan === 'enterprise') return <IconCrown size={12} />;
    return null;
  };

  if (error) {
    return (
      <Alert color="red" variant="light" p="xs">
        <Text size="xs">載入租戶失敗</Text>
      </Alert>
    );
  }

  if (isLoading || !currentTenant) {
    return (
      <Group spacing="xs">
        <Loader size="sm" />
        <Text size="sm" color="dimmed">載入中...</Text>
      </Group>
    );
  }

  if (variant === 'select') {
    return (
      <Select
        size={size}
        value={currentTenant.id}
        onChange={handleTenantSwitch}
        data={tenants.map(tenant => ({
          value: tenant.id,
          label: tenant.name,
        }))}
        icon={<IconBuilding size={16} />}
        disabled={isSwitching}
        rightSection={isSwitching ? <Loader size="xs" /> : undefined}
      />
    );
  }

  return (
    <Menu shadow="md" width={280} position="bottom-start">
      <Menu.Target>
        <Button
          variant="subtle"
          leftIcon={
            <Avatar
              src={currentTenant.settings.branding.logo}
              size="sm"
              radius="sm"
            >
              <IconBuilding size={16} />
            </Avatar>
          }
          rightIcon={
            isSwitching ? <Loader size="xs" /> : <IconChevronDown size={14} />
          }
          disabled={isSwitching}
          style={{
            border: 'none',
            backgroundColor: 'transparent',
          }}
        >
          <div style={{ textAlign: 'left' }}>
            <Text size="sm" weight={500} lineClamp={1}>
              {currentTenant.name}
            </Text>
            {showDetails && (
              <Text size="xs" color="dimmed" lineClamp={1}>
                {currentTenant.subdomain}.kyong.app
              </Text>
            )}
          </div>
        </Button>
      </Menu.Target>

      <Menu.Dropdown>
        <Menu.Label>
          <Group position="apart">
            <Text size="xs" weight={500}>
              選擇租戶
            </Text>
            <ActionIcon
              size="sm"
              variant="subtle"
              onClick={refreshTenant}
              title="刷新"
            >
              <IconRefresh size={12} />
            </ActionIcon>
          </Group>
        </Menu.Label>

        {tenants.map((tenant) => (
          <Menu.Item
            key={tenant.id}
            onClick={() => handleTenantSwitch(tenant.id)}
            disabled={tenant.id === currentTenant.id}
          >
            <Group spacing="sm">
              <Avatar
                src={tenant.settings.branding.logo}
                size="sm"
                radius="sm"
              >
                <IconBuilding size={16} />
              </Avatar>

              <div style={{ flex: 1 }}>
                <Group position="apart">
                  <Text size="sm" weight={500} lineClamp={1}>
                    {tenant.name}
                  </Text>
                  <Badge
                    size="xs"
                    color={getPlanBadgeColor(tenant.plan)}
                    leftSection={getPlanIcon(tenant.plan)}
                  >
                    {tenant.plan.toUpperCase()}
                  </Badge>
                </Group>

                <Text size="xs" color="dimmed" lineClamp={1}>
                  {tenant.subdomain}.kyong.app
                </Text>

                {showDetails && (
                  <Group spacing={4} mt={2}>
                    <IconUsers size={10} />
                    <Text size="xs" color="dimmed">
                      {tenant.settings.limits.maxMembers} 會員上限
                    </Text>
                  </Group>
                )}
              </div>
            </Group>
          </Menu.Item>
        ))}

        <Divider my="xs" />

        <Menu.Item icon={<IconSettings size={14} />}>
          租戶設定
        </Menu.Item>
      </Menu.Dropdown>
    </Menu>
  );
};

🔐 租戶權限控制系統

權限守衛組件

// packages/kyo-ui/src/components/PermissionGuard.tsx
import React from 'react';
import { Alert, Button } from '@mantine/core';
import { IconLock, IconCrown } from '@tabler/icons-react';
import { useTenant } from '@kyong/kyo-core';

interface PermissionGuardProps {
  children: React.ReactNode;
  feature?: keyof import('@kyong/kyo-core').Tenant['settings']['features'];
  permission?: string;
  plan?: string[];
  fallback?: React.ReactNode;
  showUpgrade?: boolean;
}

export const PermissionGuard: React.FC<PermissionGuardProps> = ({
  children,
  feature,
  permission,
  plan,
  fallback,
  showUpgrade = true,
}) => {
  const { currentTenant, hasFeature, hasPermission } = useTenant();

  if (!currentTenant) {
    return (
      <Alert color="orange" icon={<IconLock size={16} />}>
        請選擇租戶以繼續
      </Alert>
    );
  }

  // 檢查功能權限
  if (feature && !hasFeature(feature)) {
    return fallback || (
      <Alert color="blue" icon={<IconCrown size={16} />}>
        <div>
          此功能需要升級到更高版本的方案才能使用
          {showUpgrade && (
            <Button size="xs" variant="light" ml="md">
              立即升級
            </Button>
          )}
        </div>
      </Alert>
    );
  }

  // 檢查方案權限
  if (plan && !plan.includes(currentTenant.plan)) {
    return fallback || (
      <Alert color="blue" icon={<IconCrown size={16} />}>
        <div>
          此功能僅適用於 {plan.join('、')} 方案
          {showUpgrade && (
            <Button size="xs" variant="light" ml="md">
              立即升級
            </Button>
          )}
        </div>
      </Alert>
    );
  }

  // 檢查自定義權限
  if (permission && !hasPermission(permission)) {
    return fallback || (
      <Alert color="red" icon={<IconLock size={16} />}>
        您沒有執行此操作的權限
      </Alert>
    );
  }

  return <>{children}</>;
};

Hook 形式的權限檢查

// packages/kyo-core/src/multi-tenant/usePermissions.ts
import { useTenant } from './tenant-context';
import { useAuth } from '../auth/auth-context';

export function usePermissions() {
  const { currentTenant, hasFeature, hasPermission, isWithinLimits } = useTenant();
  const { user } = useAuth();

  const canAccessFeature = (feature: keyof typeof currentTenant.settings.features) => {
    return hasFeature(feature);
  };

  const canPerformAction = (action: string) => {
    return hasPermission(action);
  };

  const canCreateResource = (resourceType: string, currentCount: number) => {
    const limitMap = {
      'member': 'maxMembers',
      'staff': 'maxStaff',
    } as const;

    const limitKey = limitMap[resourceType as keyof typeof limitMap];
    if (!limitKey) return true;

    return isWithinLimits(limitKey, currentCount);
  };

  const getPlanLimitations = () => {
    if (!currentTenant) return [];

    const limitations = [];
    const { features, limits } = currentTenant.settings;

    // 功能限制
    if (!features.analytics) {
      limitations.push('數據分析功能需要升級');
    }
    if (!features.customReports) {
      limitations.push('自定義報表需要升級');
    }
    if (!features.apiAccess) {
      limitations.push('API 存取需要升級');
    }

    return limitations;
  };

  return {
    canAccessFeature,
    canPerformAction,
    canCreateResource,
    getPlanLimitations,
    currentPlan: currentTenant?.plan,
    isAdmin: user?.role === 'admin',
  };
}

🎨 多租戶主題管理

動態主題系統

// packages/kyo-ui/src/theming/TenantThemeProvider.tsx
import React, { useMemo } from 'react';
import { MantineProvider, createTheme, MantineTheme } from '@mantine/core';
import { useTenant } from '@kyong/kyo-core';

interface TenantThemeProviderProps {
  children: React.ReactNode;
  fallbackTheme?: MantineTheme;
}

export const TenantThemeProvider: React.FC<TenantThemeProviderProps> = ({
  children,
  fallbackTheme,
}) => {
  const { currentTenant } = useTenant();

  const tenantTheme = useMemo(() => {
    if (!currentTenant) {
      return fallbackTheme || createTheme({});
    }

    const { branding } = currentTenant.settings;

    return createTheme({
      primaryColor: branding.primaryColor,
      fontFamily: branding.fontFamily,
      headings: {
        fontFamily: branding.fontFamily,
      },
      colors: {
        // 動態建立顏色調色盤
        brand: [
          branding.primaryColor + '10',
          branding.primaryColor + '20',
          branding.primaryColor + '30',
          branding.primaryColor + '40',
          branding.primaryColor + '50',
          branding.primaryColor,
          branding.primaryColor + '70',
          branding.primaryColor + '80',
          branding.primaryColor + '90',
          branding.primaryColor + 'A0',
        ],
      },
      components: {
        Button: {
          styles: {
            root: {
              '&[data-variant="filled"]': {
                backgroundColor: branding.primaryColor,
                '&:hover': {
                  backgroundColor: branding.secondaryColor,
                },
              },
            },
          },
        },
        AppShell: {
          styles: {
            header: {
              backgroundColor: branding.primaryColor,
              borderBottom: 'none',
            },
          },
        },
      },
    });
  }, [currentTenant, fallbackTheme]);

  return (
    <MantineProvider theme={tenantTheme}>
      {children}
    </MantineProvider>
  );
};

CSS 變數注入

// packages/kyo-ui/src/theming/useTenantCSS.ts
import { useEffect } from 'react';
import { useTenant } from '@kyong/kyo-core';

export function useTenantCSS() {
  const { currentTenant } = useTenant();

  useEffect(() => {
    if (!currentTenant) return;

    const { branding } = currentTenant.settings;
    const root = document.documentElement;

    // 注入 CSS 變數
    root.style.setProperty('--tenant-primary-color', branding.primaryColor);
    root.style.setProperty('--tenant-secondary-color', branding.secondaryColor);
    root.style.setProperty('--tenant-font-family', branding.fontFamily);

    // 清理函數
    return () => {
      root.style.removeProperty('--tenant-primary-color');
      root.style.removeProperty('--tenant-secondary-color');
      root.style.removeProperty('--tenant-font-family');
    };
  }, [currentTenant]);
}

📦 發布與版本管理

自動化發布腳本

#!/bin/bash
# scripts/release.sh - 自動化發布流程

set -e

echo "🚀 開始發布流程..."

# 檢查是否有未提交的變更
if [[ -n $(git status --porcelain) ]]; then
  echo "❌ 有未提交的變更,請先提交後再發布"
  exit 1
fi

# 選擇發布類型
echo "請選擇發布類型:"
echo "1) patch (1.0.0 -> 1.0.1)"
echo "2) minor (1.0.0 -> 1.1.0)"
echo "3) major (1.0.0 -> 2.0.0)"
read -p "請輸入選擇 (1-3): " choice

case $choice in
  1) VERSION_TYPE="patch" ;;
  2) VERSION_TYPE="minor" ;;
  3) VERSION_TYPE="major" ;;
  *) echo "無效選擇"; exit 1 ;;
esac

# 更新版本號
echo "📝 更新版本號..."
pnpm version $VERSION_TYPE --workspace-root

# 建立所有包
echo "🔨 建立所有包..."
pnpm run build

# 發布到私有 registry
echo "📦 發布到私有 registry..."
pnpm -r publish --registry http://localhost:4873

# 建立 git tag
NEW_VERSION=$(node -p "require('./package.json').version")
git tag "v$NEW_VERSION"
git push origin "v$NEW_VERSION"

echo "✅ 發布完成! 版本: v$NEW_VERSION"

Changeset 整合

// .changeset/config.json
{
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [
    ["@kyong/kyo-core", "@kyong/kyo-ui", "@kyong/kyo-types"]
  ],
  "linked": [],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@kyong/docs"]
}
# 使用 changeset 管理版本
npx changeset add    # 添加變更記錄
npx changeset version # 生成版本號
npx changeset publish # 發布包

今日總結

今天我們完成了企業級包管理和多租戶架構的完整實現:

完成功能

  1. 私有 NPM Registry 建立

    • Verdaccio 配置與部署
    • 權限管理與安全配置
    • 與現有工作流程整合
  2. yalc 本地開發工作流程

    • 無縫本地包開發體驗
    • 自動化推送與更新機制
    • 與 pnpm workspace 整合
  3. 多租戶架構前端實現

    • 租戶上下文管理系統
    • 動態權限控制機制
    • 租戶切換組件實作
  4. 企業級主題管理

    • 動態主題注入系統
    • CSS 變數管理
    • 品牌一致性保證

架構特色

graph TB
    A[企業級 SaaS 架構] --> B[包管理層]
    A --> C[多租戶層]
    A --> D[權限控制層]
    A --> E[主題管理層]

    B --> B1[私有 Registry]
    B --> B2[本地開發]
    B --> B3[版本管理]

    C --> C1[租戶上下文]
    C --> C2[資料隔離]
    C --> C3[切換機制]

    D --> D1[功能權限]
    D --> D2[方案限制]
    D --> D3[操作權限]

    E --> E1[動態主題]
    E --> E2[品牌系統]
    E --> E3[CSS 變數]

企業級產品開發心得

  1. 工具鏈統一:統一的開發、測試、發布流程提升團隊協作效率
  2. 權限細分:細緻的權限控制確保安全性和功能分級
  3. 主題動態:動態主題系統提升品牌一致性和客戶體驗
  4. 版本管理:語義化版本管理降低升級風險

📚 相關資源


上一篇
Day 12:30天打造SaaS產品前端篇-健身房會員管理介面實作
下一篇
Day 14:30天打造SaaS產品前端篇-課程管理系統深度實作與視覺化排課工具
系列文
30 天製作工作室 SaaS 產品 (前端篇)18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言