在AWS和後端挑戰中,我們建立了多租戶資料庫架構和 Auth Service,今天我們要從前端角度實作多租戶管理介面。
一個企業級 SaaS 前端需求:如何讓不同健身房的管理員能夠安全地管理自己的資料,同時提供平台管理員統一管理的能力?
在多租戶 SaaS 系統中,前端需要處理三種租戶識別方式:
// packages/kyo-types/src/tenant.ts
export interface TenantContext {
id: string;
name: string;
subdomain: string;
planType: 'basic' | 'pro' | 'enterprise';
settings: TenantSettings;
permissions: string[];
}
export interface TenantSettings {
branding: {
logo?: string;
primaryColor: string;
secondaryColor: string;
theme: 'light' | 'dark' | 'auto';
};
features: {
lineIntegration: boolean;
smsNotification: boolean;
emailNotification: boolean;
advancedAnalytics: boolean;
};
businessInfo: {
businessHours: {
open: string;
close: string;
};
timezone: string;
address: string;
phone: string;
};
}
export enum TenantIdentificationMethod {
SUBDOMAIN = 'subdomain', // gym1.kyo-system.com
PATH = 'path', // kyo-system.com/gym1
HEADER = 'header' // X-Tenant-ID
}
// apps/kyo-dashboard/src/stores/tenantStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface TenantState {
currentTenant: TenantContext | null;
availableTenants: TenantContext[]; // 平台管理員可見多個租戶
identificationMethod: TenantIdentificationMethod;
// Actions
setCurrentTenant: (tenant: TenantContext) => void;
switchTenant: (tenantId: string) => void;
updateTenantSettings: (settings: Partial<TenantSettings>) => void;
loadAvailableTenants: () => Promise<void>;
}
export const useTenantStore = create<TenantState>()(
persist(
(set, get) => ({
currentTenant: null,
availableTenants: [],
identificationMethod: TenantIdentificationMethod.SUBDOMAIN,
setCurrentTenant: (tenant) => {
set({ currentTenant: tenant });
// 應用租戶主題
applyTenantTheme(tenant.settings.branding);
// 設定全域 CSS 變數
document.documentElement.style.setProperty(
'--tenant-primary-color',
tenant.settings.branding.primaryColor
);
},
switchTenant: async (tenantId) => {
const tenant = get().availableTenants.find(t => t.id === tenantId);
if (tenant) {
get().setCurrentTenant(tenant);
// 重新導向到新租戶的子域名
if (get().identificationMethod === TenantIdentificationMethod.SUBDOMAIN) {
window.location.href = `https://${tenant.subdomain}.kyo-system.com${window.location.pathname}`;
}
}
},
updateTenantSettings: async (settings) => {
const current = get().currentTenant;
if (!current) return;
try {
const response = await fetch(`/api/tenants/${current.id}/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAuthToken()}`
},
body: JSON.stringify(settings)
});
if (response.ok) {
const updatedTenant = {
...current,
settings: { ...current.settings, ...settings }
};
set({ currentTenant: updatedTenant });
}
} catch (error) {
console.error('Failed to update tenant settings:', error);
}
},
loadAvailableTenants: async () => {
try {
const response = await fetch('/api/tenants', {
headers: {
'Authorization': `Bearer ${getAuthToken()}`
}
});
if (response.ok) {
const tenants = await response.json();
set({ availableTenants: tenants });
}
} catch (error) {
console.error('Failed to load tenants:', error);
}
}
}),
{
name: 'tenant-storage',
partialize: (state) => ({
currentTenant: state.currentTenant,
identificationMethod: state.identificationMethod
})
}
)
);
function applyTenantTheme(branding: TenantSettings['branding']) {
const root = document.documentElement;
root.style.setProperty('--tenant-primary', branding.primaryColor);
root.style.setProperty('--tenant-secondary', branding.secondaryColor);
// 設定 Mantine 主題
document.body.setAttribute('data-tenant-theme', branding.theme);
}
// apps/kyo-dashboard/src/hooks/useTenantInitialization.ts
import { useEffect, useState } from 'react';
import { useTenantStore } from '../stores/tenantStore';
import { useAuthStore } from '../stores/authStore';
export function useTenantInitialization() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const {
currentTenant,
setCurrentTenant,
identificationMethod
} = useTenantStore();
const { isAuthenticated } = useAuthStore();
useEffect(() => {
initializeTenant();
}, [isAuthenticated]);
const initializeTenant = async () => {
try {
setIsLoading(true);
setError(null);
let tenantInfo: TenantContext | null = null;
switch (identificationMethod) {
case TenantIdentificationMethod.SUBDOMAIN:
tenantInfo = await detectTenantFromSubdomain();
break;
case TenantIdentificationMethod.PATH:
tenantInfo = await detectTenantFromPath();
break;
case TenantIdentificationMethod.HEADER:
tenantInfo = await detectTenantFromHeader();
break;
}
if (!tenantInfo) {
// 如果無法識別租戶,導向租戶選擇頁面
if (isAuthenticated) {
redirectToTenantSelection();
} else {
redirectToLogin();
}
return;
}
// 驗證租戶狀態
const isValidTenant = await validateTenant(tenantInfo.id);
if (!isValidTenant) {
throw new Error('Tenant is not active or accessible');
}
setCurrentTenant(tenantInfo);
} catch (err: any) {
setError(err.message);
console.error('Tenant initialization failed:', err);
} finally {
setIsLoading(false);
}
};
const detectTenantFromSubdomain = async (): Promise<TenantContext | null> => {
const hostname = window.location.hostname;
const parts = hostname.split('.');
// 檢查是否為子域名格式 (gym1.kyo-system.com)
if (parts.length >= 3 && parts[0] !== 'www') {
const subdomain = parts[0];
try {
const response = await fetch(`/api/tenants/by-subdomain/${subdomain}`);
if (response.ok) {
return await response.json();
}
} catch (error) {
console.error('Failed to fetch tenant by subdomain:', error);
}
}
return null;
};
const detectTenantFromPath = async (): Promise<TenantContext | null> => {
const pathParts = window.location.pathname.split('/');
// 檢查路徑格式 /tenant/gym1/dashboard
if (pathParts[1] === 'tenant' && pathParts[2]) {
const tenantSlug = pathParts[2];
try {
const response = await fetch(`/api/tenants/by-slug/${tenantSlug}`);
if (response.ok) {
return await response.json();
}
} catch (error) {
console.error('Failed to fetch tenant by path:', error);
}
}
return null;
};
const validateTenant = async (tenantId: string): Promise<boolean> => {
try {
const response = await fetch(`/api/tenants/${tenantId}/status`, {
headers: {
'Authorization': `Bearer ${getAuthToken()}`
}
});
const data = await response.json();
return data.status === 'active';
} catch {
return false;
}
};
const redirectToTenantSelection = () => {
window.location.href = '/select-tenant';
};
const redirectToLogin = () => {
window.location.href = '/login';
};
return { isLoading, error, currentTenant };
}
// apps/kyo-dashboard/src/components/tenant/TenantSwitcher.tsx
import React, { useState } from 'react';
import {
Menu,
Button,
Avatar,
Group,
Text,
Divider,
ActionIcon,
Badge,
Stack
} from '@mantine/core';
import {
IconChevronDown,
IconBuilding,
IconCrown,
IconSettings,
IconPlus
} from '@tabler/icons-react';
import { useTenantStore } from '../../stores/tenantStore';
import { useAuthStore } from '../../stores/authStore';
export const TenantSwitcher: React.FC = () => {
const [opened, setOpened] = useState(false);
const {
currentTenant,
availableTenants,
switchTenant,
loadAvailableTenants
} = useTenantStore();
const { user } = useAuthStore();
React.useEffect(() => {
if (user?.role === 'platform_admin') {
loadAvailableTenants();
}
}, [user, loadAvailableTenants]);
if (!currentTenant) {
return null;
}
const isPlatformAdmin = user?.role === 'platform_admin';
const canSwitchTenant = isPlatformAdmin && availableTenants.length > 1;
const handleTenantSwitch = (tenantId: string) => {
switchTenant(tenantId);
setOpened(false);
};
const getPlanBadgeColor = (planType: string) => {
const colors = {
basic: 'gray',
pro: 'blue',
enterprise: 'gold'
};
return colors[planType as keyof typeof colors] || 'gray';
};
return (
<Menu
opened={opened}
onChange={setOpened}
position="bottom-start"
shadow="lg"
width={300}
>
<Menu.Target>
<Button
variant="subtle"
leftSection={
<Avatar
size="sm"
src={currentTenant.settings.branding.logo}
color={currentTenant.settings.branding.primaryColor}
>
<IconBuilding size="1rem" />
</Avatar>
}
rightSection={canSwitchTenant && <IconChevronDown size="1rem" />}
styles={{
root: {
padding: '8px 12px',
height: 'auto',
borderRadius: '8px'
}
}}
>
<Stack gap={2}>
<Text size="sm" fw={500}>
{currentTenant.name}
</Text>
<Badge
size="xs"
color={getPlanBadgeColor(currentTenant.planType)}
variant="light"
>
{currentTenant.planType.toUpperCase()}
</Badge>
</Stack>
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>當前健身房</Menu.Label>
<Group p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Avatar
src={currentTenant.settings.branding.logo}
color={currentTenant.settings.branding.primaryColor}
size="md"
>
<IconBuilding size="1.2rem" />
</Avatar>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{currentTenant.name}
</Text>
<Text size="xs" c="dimmed">
{currentTenant.subdomain}.kyo-system.com
</Text>
</div>
<Badge color={getPlanBadgeColor(currentTenant.planType)} size="sm">
{currentTenant.planType}
</Badge>
</Group>
{canSwitchTenant && (
<>
<Divider my="sm" />
<Menu.Label>
<Group justify="space-between">
<span>可切換的健身房</span>
<Badge size="xs" variant="light">
{availableTenants.length}
</Badge>
</Group>
</Menu.Label>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{availableTenants
.filter(tenant => tenant.id !== currentTenant.id)
.map((tenant) => (
<Menu.Item
key={tenant.id}
onClick={() => handleTenantSwitch(tenant.id)}
leftSection={
<Avatar
size="xs"
src={tenant.settings.branding.logo}
color={tenant.settings.branding.primaryColor}
>
<IconBuilding size="0.8rem" />
</Avatar>
}
rightSection={
<Badge
size="xs"
color={getPlanBadgeColor(tenant.planType)}
variant="light"
>
{tenant.planType}
</Badge>
}
>
<div>
<Text size="sm">{tenant.name}</Text>
<Text size="xs" c="dimmed">
{tenant.subdomain}
</Text>
</div>
</Menu.Item>
))}
</div>
<Divider my="sm" />
<Menu.Item
leftSection={<IconPlus size="1rem" />}
onClick={() => window.open('/admin/tenants/create', '_blank')}
>
新增健身房
</Menu.Item>
</>
)}
<Divider my="sm" />
<Menu.Item
leftSection={<IconSettings size="1rem" />}
onClick={() => window.location.href = '/settings/tenant'}
>
健身房設定
</Menu.Item>
{isPlatformAdmin && (
<Menu.Item
leftSection={<IconCrown size="1rem" />}
onClick={() => window.location.href = '/admin'}
c="orange"
>
平台管理
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
};
// apps/kyo-dashboard/src/hooks/useTenantTheme.ts
import { useMantineTheme, MantineTheme } from '@mantine/core';
import { useTenantStore } from '../stores/tenantStore';
export function useTenantTheme() {
const mantineTheme = useMantineTheme();
const { currentTenant } = useTenantStore();
const tenantTheme: Partial<MantineTheme> = React.useMemo(() => {
if (!currentTenant?.settings.branding) {
return mantineTheme;
}
const branding = currentTenant.settings.branding;
return {
...mantineTheme,
colors: {
...mantineTheme.colors,
primary: [
lighten(branding.primaryColor, 0.9),
lighten(branding.primaryColor, 0.8),
lighten(branding.primaryColor, 0.7),
lighten(branding.primaryColor, 0.6),
lighten(branding.primaryColor, 0.4),
branding.primaryColor,
darken(branding.primaryColor, 0.1),
darken(branding.primaryColor, 0.2),
darken(branding.primaryColor, 0.3),
darken(branding.primaryColor, 0.4),
],
secondary: [
lighten(branding.secondaryColor, 0.9),
lighten(branding.secondaryColor, 0.8),
lighten(branding.secondaryColor, 0.7),
lighten(branding.secondaryColor, 0.6),
lighten(branding.secondaryColor, 0.4),
branding.secondaryColor,
darken(branding.secondaryColor, 0.1),
darken(branding.secondaryColor, 0.2),
darken(branding.secondaryColor, 0.3),
darken(branding.secondaryColor, 0.4),
],
},
primaryColor: 'primary',
colorScheme: branding.theme === 'auto'
? mantineTheme.colorScheme
: branding.theme,
};
}, [currentTenant, mantineTheme]);
// 應用 CSS 變數
React.useEffect(() => {
if (currentTenant?.settings.branding) {
const branding = currentTenant.settings.branding;
const root = document.documentElement;
root.style.setProperty('--tenant-primary', branding.primaryColor);
root.style.setProperty('--tenant-secondary', branding.secondaryColor);
root.style.setProperty('--tenant-primary-rgb', hexToRgb(branding.primaryColor));
root.style.setProperty('--tenant-secondary-rgb', hexToRgb(branding.secondaryColor));
}
}, [currentTenant]);
return tenantTheme;
}
// 色彩工具函數
function lighten(color: string, amount: number): string {
// 實作顏色變亮邏輯
return color;
}
function darken(color: string, amount: number): string {
// 實作顏色變暗邏輯
return color;
}
function hexToRgb(hex: string): string {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
: '0, 0, 0';
}
// apps/kyo-dashboard/src/components/tenant/TenantLogo.tsx
import React from 'react';
import { Group, Image, Text, ThemeIcon } from '@mantine/core';
import { IconBuilding } from '@tabler/icons-react';
import { useTenantStore } from '../../stores/tenantStore';
interface TenantLogoProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
showName?: boolean;
variant?: 'default' | 'white' | 'minimal';
}
export const TenantLogo: React.FC<TenantLogoProps> = ({
size = 'md',
showName = true,
variant = 'default'
}) => {
const { currentTenant } = useTenantStore();
if (!currentTenant) {
return (
<Group gap="sm">
<ThemeIcon size={size} variant="light">
<IconBuilding size="1rem" />
</ThemeIcon>
{showName && (
<Text size={size} fw={600}>
Kyo System
</Text>
)}
</Group>
);
}
const branding = currentTenant.settings.branding;
const logoSize = {
xs: 20,
sm: 24,
md: 32,
lg: 40,
xl: 48
}[size];
return (
<Group gap="sm">
{branding.logo ? (
<Image
src={branding.logo}
alt={`${currentTenant.name} Logo`}
width={logoSize}
height={logoSize}
style={{ borderRadius: '4px' }}
/>
) : (
<ThemeIcon
size={logoSize}
color={branding.primaryColor}
variant={variant === 'white' ? 'white' : 'light'}
>
<IconBuilding size={logoSize * 0.6} />
</ThemeIcon>
)}
{showName && (
<Text
size={size}
fw={600}
c={variant === 'white' ? 'white' : undefined}
style={{
color: variant === 'minimal' ? branding.primaryColor : undefined
}}
>
{currentTenant.name}
</Text>
)}
</Group>
);
};
// apps/kyo-dashboard/src/components/layout/TenantAppShell.tsx
import React from 'react';
import {
AppShell,
Burger,
Group,
NavLink,
Stack,
Divider,
Text,
Badge
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useLocation } from 'react-router-dom';
import {
IconDashboard,
IconUsers,
IconUserCheck,
IconCalendar,
IconCreditCard,
IconChart,
IconSettings
} from '@tabler/icons-react';
import { TenantLogo } from '../tenant/TenantLogo';
import { TenantSwitcher } from '../tenant/TenantSwitcher';
import { useTenantStore } from '../../stores/tenantStore';
import { useAuthStore } from '../../stores/authStore';
interface TenantAppShellProps {
children: React.ReactNode;
}
export const TenantAppShell: React.FC<TenantAppShellProps> = ({ children }) => {
const [opened, { toggle }] = useDisclosure();
const location = useLocation();
const { currentTenant } = useTenantStore();
const { user } = useAuthStore();
// 根據用戶角色和租戶設定決定導航項目
const navigationItems = React.useMemo(() => {
if (!currentTenant || !user) return [];
const baseItems = [
{
icon: IconDashboard,
label: '儀表板',
href: '/dashboard',
permission: 'dashboard:read'
}
];
const managementItems = [
{
icon: IconUsers,
label: '會員管理',
href: '/members',
permission: 'member:read'
},
{
icon: IconUserCheck,
label: '教練管理',
href: '/trainers',
permission: 'trainer:read'
},
{
icon: IconCalendar,
label: '課程管理',
href: '/courses',
permission: 'course:read'
},
{
icon: IconCreditCard,
label: '會籍管理',
href: '/memberships',
permission: 'membership:read'
}
];
const analyticsItems = [
{
icon: IconChart,
label: '數據分析',
href: '/analytics',
permission: 'system:analytics',
badge: currentTenant.planType === 'basic' ? 'PRO' : undefined
}
];
const settingsItems = [
{
icon: IconSettings,
label: '系統設定',
href: '/settings',
permission: 'system:config'
}
];
return [
...baseItems,
...managementItems,
...analyticsItems,
...settingsItems
].filter(item => {
// 檢查用戶權限
return user.permissions?.includes(item.permission) || user.role === 'gym_owner';
});
}, [currentTenant, user]);
const isActive = (href: string) => {
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
if (!currentTenant) {
return <div>Loading tenant...</div>;
}
return (
<AppShell
header={{ height: 70 }}
navbar={{
width: 280,
breakpoint: 'sm',
collapsed: { mobile: !opened }
}}
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
<TenantLogo size="sm" />
</Group>
<TenantSwitcher />
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<Stack gap="xs">
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb="sm">
導航選單
</Text>
{navigationItems.map((item) => (
<NavLink
key={item.href}
href={item.href}
label={
<Group justify="space-between" w="100%">
<span>{item.label}</span>
{item.badge && (
<Badge size="xs" color="orange" variant="light">
{item.badge}
</Badge>
)}
</Group>
}
leftSection={<item.icon size="1rem" />}
active={isActive(item.href)}
style={{
borderRadius: '8px',
padding: '12px',
marginBottom: '4px'
}}
/>
))}
<Divider my="sm" />
<Text size="xs" c="dimmed">
當前方案:
<Badge
size="xs"
ml="xs"
color={currentTenant.planType === 'enterprise' ? 'gold' : 'blue'}
>
{currentTenant.planType.toUpperCase()}
</Badge>
</Text>
{currentTenant.planType === 'basic' && (
<Text size="xs" c="dimmed">
<a
href="/upgrade"
style={{
color: 'var(--tenant-primary)',
textDecoration: 'none'
}}
>
升級解鎖更多功能 →
</a>
</Text>
)}
</Stack>
</AppShell.Navbar>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
};
// apps/kyo-dashboard/src/components/auth/TenantRouteGuard.tsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Loader, Center, Alert } from '@mantine/core';
import { IconAlertCircle } from '@tabler/icons-react';
import { useTenantStore } from '../../stores/tenantStore';
import { useAuthStore } from '../../stores/authStore';
import { useTenantInitialization } from '../../hooks/useTenantInitialization';
interface TenantRouteGuardProps {
children: React.ReactNode;
requiredPermissions?: string[];
requiredPlan?: 'basic' | 'pro' | 'enterprise';
}
export const TenantRouteGuard: React.FC<TenantRouteGuardProps> = ({
children,
requiredPermissions = [],
requiredPlan
}) => {
const { isAuthenticated, user } = useAuthStore();
const { currentTenant } = useTenantStore();
const { isLoading, error } = useTenantInitialization();
// 等待租戶初始化完成
if (isLoading) {
return (
<Center h="100vh">
<Loader size="lg" />
</Center>
);
}
// 租戶初始化錯誤
if (error) {
return (
<Center h="100vh" p="md">
<Alert
icon={<IconAlertCircle size="1rem" />}
title="租戶載入失敗"
color="red"
>
{error}
</Alert>
</Center>
);
}
// 未認證用戶
if (!isAuthenticated || !user) {
return <Navigate to="/login" replace />;
}
// 沒有租戶上下文
if (!currentTenant) {
return <Navigate to="/select-tenant" replace />;
}
// 檢查租戶狀態
if (currentTenant.status !== 'active') {
return (
<Center h="100vh" p="md">
<Alert
icon={<IconAlertCircle size="1rem" />}
title="租戶暫停服務"
color="yellow"
>
此健身房帳戶目前暫停服務,請聯絡客服。
</Alert>
</Center>
);
}
// 檢查方案限制
if (requiredPlan) {
const planLevels = { basic: 1, pro: 2, enterprise: 3 };
const currentLevel = planLevels[currentTenant.planType];
const requiredLevel = planLevels[requiredPlan];
if (currentLevel < requiredLevel) {
return (
<Center h="100vh" p="md">
<Alert
icon={<IconAlertCircle size="1rem" />}
title="功能限制"
color="orange"
>
此功能需要 {requiredPlan.toUpperCase()} 方案,
<a href="/upgrade" style={{ marginLeft: '4px' }}>
立即升級
</a>
</Alert>
</Center>
);
}
}
// 檢查權限
if (requiredPermissions.length > 0) {
const hasPermission = requiredPermissions.every(permission =>
user.permissions?.includes(permission) || user.role === 'gym_owner'
);
if (!hasPermission) {
return (
<Center h="100vh" p="md">
<Alert
icon={<IconAlertCircle size="1rem" />}
title="權限不足"
color="red"
>
您沒有存取此頁面的權限。
</Alert>
</Center>
);
}
}
return <>{children}</>;
};
// apps/kyo-dashboard/src/lib/apiClient.ts
import { useTenantStore } from '../stores/tenantStore';
import { useAuthStore } from '../stores/authStore';
class TenantApiClient {
private baseURL: string;
constructor() {
this.baseURL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
}
private getHeaders(): Record<string, string> {
const { currentTenant } = useTenantStore.getState();
const { token } = useAuthStore.getState();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (currentTenant) {
headers['X-Tenant-ID'] = currentTenant.id;
}
return headers;
}
async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
...this.getHeaders(),
...options.headers,
},
});
if (!response.ok) {
if (response.status === 401) {
// Token 過期,重新導向到登入頁
useAuthStore.getState().logout();
window.location.href = '/login';
throw new Error('Authentication required');
}
if (response.status === 403) {
throw new Error('Permission denied');
}
if (response.status === 404 && endpoint.includes('tenant')) {
// 租戶不存在或無權存取
useTenantStore.getState().setCurrentTenant(null);
window.location.href = '/select-tenant';
throw new Error('Tenant not found');
}
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
return response.json();
}
// 便利方法
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
}
export const apiClient = new TenantApiClient();
今天我們完成了多租戶前端管理介面的核心功能:
✅ 租戶識別系統: 支援子域名、路徑、標頭多種識別方式
✅ 租戶切換功能: 平台管理員可快速切換不同健身房
✅ 品牌客製化: 動態主題系統支援租戶專屬品牌
✅ 安全權限控制: 完整的路由守衛與 API 攔截
✅ 響應式設計: 適應各種裝置的租戶導航