經過 Day 12 的會員管理介面實作,我們已經建立了一套完整的 CRUD 操作系統。今天我們將進入企業級開發工作流程,建立私有 NPM repository,並完善多租戶架構的前端。
今天我們將完成:
// 企業級 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 時,我們需要考慮成本、控制權、易用性和企業需求。我們來比較目前的方案:
// 私有 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 解決方案'
}
];
基於我們健身房 SaaS 專案的特性,Verdaccio 是最佳選擇:
# 每年成本比較 (假設中型團隊 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+ (企業版)"
// 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 服務整合'
}
}
};
// 健身房 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 限制分析
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 成本計算
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 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 配置
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
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 }
# 設置私有 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
npm install -g yalc
# yalc 工作原理
# 1. yalc publish - 將包發布到本地 yalc store
# 2. yalc add - 在使用端增加本地包
# 3. yalc push - 推送更新到所有使用端
# 4. yalc remove - 移除本地包依賴
// 開發工具比較
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 環境'
}
];
// 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}</>;
};
// 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>
);
};
// 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/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 # 發布包
今天我們完成了企業級包管理和多租戶架構的完整實現:
私有 NPM Registry 建立
yalc 本地開發工作流程
多租戶架構前端實現
企業級主題管理
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 變數]