feat: 實作 Multi-tenancy 多租戶架構 (stancl/tenancy)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m3s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

- 安裝並設定 stancl/tenancy 套件
- 分離 Central / Tenant migrations
- 建立 Tenant Model 與資料遷移指令
- 建立房東後台 CRUD (Landlord Dashboard)
- 新增租戶管理頁面 (列表、新增、編輯、詳情)
- 新增域名管理功能
- 更新部署手冊
This commit is contained in:
2026-01-15 13:15:18 +08:00
parent 3e3d8ffb6c
commit 4f745c1021
51 changed files with 1954 additions and 1 deletions

View File

@@ -5,6 +5,9 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
# Multi-tenancy 設定 (用逗號分隔多個中央網域)
CENTRAL_DOMAINS=localhost,127.0.0.1
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 將現有資料遷移到租戶資料庫
*
* 此指令用於初次設定多租戶時,將現有的 ERP 資料遷移到第一個租戶
*/
class MigrateToTenant extends Command
{
protected $signature = 'tenancy:migrate-data {tenant_id} {--dry-run : 只顯示會遷移的表,不實際執行}';
protected $description = '將現有 central DB 資料遷移到指定租戶資料庫';
/**
* 需要遷移的表 (依賴順序排列)
*/
protected array $tablesToMigrate = [
'users',
'password_reset_tokens',
'sessions',
'cache',
'cache_locks',
'jobs',
'job_batches',
'failed_jobs',
'categories',
'units',
'vendors',
'products',
'product_vendor',
'warehouses',
'inventories',
'inventory_transactions',
'purchase_orders',
'purchase_order_items',
'permissions',
'roles',
'model_has_permissions',
'model_has_roles',
'role_has_permissions',
];
public function handle(): int
{
$tenantId = $this->argument('tenant_id');
$dryRun = $this->option('dry-run');
// 檢查租戶是否存在
$tenant = Tenant::find($tenantId);
if (!$tenant) {
$this->error("租戶 '{$tenantId}' 不存在!請先建立租戶。");
return self::FAILURE;
}
$this->info("開始遷移資料到租戶: {$tenantId}");
$this->info("租戶資料庫: tenant{$tenantId}");
if ($dryRun) {
$this->warn('⚠️ Dry Run 模式 - 不會實際遷移資料');
}
// 取得 central 資料庫連線
$centralConnection = config('database.default');
$tenantDbName = 'tenant' . $tenantId;
foreach ($this->tablesToMigrate as $table) {
// 檢查表是否存在於 central
if (!Schema::connection($centralConnection)->hasTable($table)) {
$this->line(" ⏭️ 跳過 {$table} (表不存在)");
continue;
}
// 計算資料筆數
$count = DB::connection($centralConnection)->table($table)->count();
if ($count === 0) {
$this->line(" ⏭️ 跳過 {$table} (無資料)");
continue;
}
if ($dryRun) {
$this->info(" 📋 {$table}: {$count} 筆資料");
continue;
}
// 實際遷移資料
$this->info(" 🔄 遷移 {$table}: {$count} 筆資料...");
try {
// 使用租戶上下文執行
$tenant->run(function () use ($centralConnection, $table) {
// 取得 central 資料
$data = DB::connection($centralConnection)->table($table)->get();
if ($data->isEmpty()) {
return;
}
// 關閉外鍵檢查
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// 清空目標表
DB::table($table)->truncate();
// 分批插入 (每批 100 筆)
foreach ($data->chunk(100) as $chunk) {
DB::table($table)->insert($chunk->map(fn($item) => (array) $item)->toArray());
}
// 恢復外鍵檢查
DB::statement('SET FOREIGN_KEY_CHECKS=1');
});
$this->info("{$table} 遷移完成");
} catch (\Exception $e) {
$this->error("{$table} 遷移失敗: " . $e->getMessage());
return self::FAILURE;
}
}
$this->newLine();
$this->info('🎉 資料遷移完成!');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Landlord;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use Inertia\Inertia;
class DashboardController extends Controller
{
public function index()
{
$stats = [
'totalTenants' => Tenant::count(),
'activeTenants' => Tenant::whereJsonContains('data->is_active', true)->count(),
'recentTenants' => Tenant::latest()->take(5)->get()->map(function ($tenant) {
return [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'is_active' => $tenant->is_active ?? true,
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
'domains' => $tenant->domains->pluck('domain')->toArray(),
];
}),
];
return Inertia::render('Landlord/Dashboard', $stats);
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers\Landlord;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class TenantController extends Controller
{
/**
* 顯示租戶列表
*/
public function index()
{
$tenants = Tenant::with('domains')->get()->map(function ($tenant) {
return [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'email' => $tenant->email ?? null,
'is_active' => $tenant->is_active ?? true,
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
'domains' => $tenant->domains->pluck('domain')->toArray(),
];
});
return Inertia::render('Landlord/Tenant/Index', [
'tenants' => $tenants,
]);
}
/**
* 顯示新增租戶表單
*/
public function create()
{
return Inertia::render('Landlord/Tenant/Create');
}
/**
* 儲存新租戶
*/
public function store(Request $request)
{
$validated = $request->validate([
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
'name' => ['required', 'string', 'max:100'],
'email' => ['nullable', 'email', 'max:100'],
'domain' => ['nullable', 'string', 'max:100'],
]);
$tenant = Tenant::create([
'id' => $validated['id'],
'name' => $validated['name'],
'email' => $validated['email'] ?? null,
'is_active' => true,
]);
// 如果有指定域名,則綁定
if (!empty($validated['domain'])) {
$tenant->domains()->create(['domain' => $validated['domain']]);
}
return redirect()->route('landlord.tenants.index')
->with('success', "租戶 {$validated['name']} 建立成功!");
}
/**
* 顯示單一租戶詳情
*/
public function show(string $id)
{
$tenant = Tenant::with('domains')->findOrFail($id);
return Inertia::render('Landlord/Tenant/Show', [
'tenant' => [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'email' => $tenant->email ?? null,
'is_active' => $tenant->is_active ?? true,
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
'updated_at' => $tenant->updated_at->format('Y-m-d H:i'),
'domains' => $tenant->domains->map(fn($d) => [
'id' => $d->id,
'domain' => $d->domain,
])->toArray(),
],
]);
}
/**
* 顯示編輯租戶表單
*/
public function edit(string $id)
{
$tenant = Tenant::findOrFail($id);
return Inertia::render('Landlord/Tenant/Edit', [
'tenant' => [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'email' => $tenant->email ?? null,
'is_active' => $tenant->is_active ?? true,
],
]);
}
/**
* 更新租戶
*/
public function update(Request $request, string $id)
{
$tenant = Tenant::findOrFail($id);
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'email' => ['nullable', 'email', 'max:100'],
'is_active' => ['boolean'],
]);
$tenant->update($validated);
return redirect()->route('landlord.tenants.index')
->with('success', "租戶 {$validated['name']} 更新成功!");
}
/**
* 刪除租戶
*/
public function destroy(string $id)
{
$tenant = Tenant::findOrFail($id);
$name = $tenant->name ?? $id;
$tenant->delete();
return redirect()->route('landlord.tenants.index')
->with('success', "租戶 {$name} 已刪除!");
}
/**
* 新增域名到租戶
*/
public function addDomain(Request $request, string $id)
{
$tenant = Tenant::findOrFail($id);
$validated = $request->validate([
'domain' => ['required', 'string', 'max:100', Rule::unique('domains', 'domain')],
]);
$tenant->domains()->create(['domain' => $validated['domain']]);
return back()->with('success', "域名 {$validated['domain']} 已綁定!");
}
/**
* 移除租戶的域名
*/
public function removeDomain(string $id, int $domainId)
{
$tenant = Tenant::findOrFail($id);
$domain = $tenant->domains()->findOrFail($domainId);
$domainName = $domain->domain;
$domain->delete();
return back()->with('success', "域名 {$domainName} 已移除!");
}
}

34
app/Models/Tenant.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
/**
* 租戶 Model
*
* 代表 ERP 系統中的每一個客戶公司 (如:小小冰室、酒水客戶等)
*
* 自訂屬性 (存在 data JSON 欄位中,可透過 $tenant->name 存取):
* - name: 租戶名稱 (: 小小冰室)
* - email: 聯絡信箱
* - is_active: 是否啟用
*/
class Tenant extends BaseTenant implements TenantWithDatabase
{
use HasDatabase, HasDomains;
/**
* 定義獨立欄位 ( data JSON)
* 只有 id 是獨立欄位,其他自訂屬性都存在 data JSON
*/
public static function getCustomColumns(): array
{
return [
'id',
];
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Route;
class AppServiceProvider extends ServiceProvider
{
@@ -26,5 +27,13 @@ class AppServiceProvider extends ServiceProvider
\Illuminate\Support\Facades\Gate::before(function ($user, $ability) {
return $user->hasRole('super-admin') ? true : null;
});
// 載入房東後台路由 (只在 central domain 可用)
$this->app->booted(function () {
if (file_exists(base_path('routes/landlord.php'))) {
Route::middleware('web')->group(base_path('routes/landlord.php'));
}
});
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Events;
use Stancl\Tenancy\Jobs;
use Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Middleware;
class TenancyServiceProvider extends ServiceProvider
{
// By default, no namespace is used to support the callable array syntax.
public static string $controllerNamespace = '';
public function events()
{
return [
// Tenant events
Events\CreatingTenant::class => [],
Events\TenantCreated::class => [
JobPipeline::make([
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class,
// Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want!
])->send(function (Events\TenantCreated $event) {
return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
],
Events\SavingTenant::class => [],
Events\TenantSaved::class => [],
Events\UpdatingTenant::class => [],
Events\TenantUpdated::class => [],
Events\DeletingTenant::class => [],
Events\TenantDeleted::class => [
JobPipeline::make([
Jobs\DeleteDatabase::class,
])->send(function (Events\TenantDeleted $event) {
return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
],
// Domain events
Events\CreatingDomain::class => [],
Events\DomainCreated::class => [],
Events\SavingDomain::class => [],
Events\DomainSaved::class => [],
Events\UpdatingDomain::class => [],
Events\DomainUpdated::class => [],
Events\DeletingDomain::class => [],
Events\DomainDeleted::class => [],
// Database events
Events\DatabaseCreated::class => [],
Events\DatabaseMigrated::class => [],
Events\DatabaseSeeded::class => [],
Events\DatabaseRolledBack::class => [],
Events\DatabaseDeleted::class => [],
// Tenancy events
Events\InitializingTenancy::class => [],
Events\TenancyInitialized::class => [
Listeners\BootstrapTenancy::class,
],
Events\EndingTenancy::class => [],
Events\TenancyEnded::class => [
Listeners\RevertToCentralContext::class,
],
Events\BootstrappingTenancy::class => [],
Events\TenancyBootstrapped::class => [],
Events\RevertingToCentralContext::class => [],
Events\RevertedToCentralContext::class => [],
// Resource syncing
Events\SyncedResourceSaved::class => [
Listeners\UpdateSyncedResource::class,
],
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
Events\SyncedResourceChangedInForeignDatabase::class => [],
];
}
public function register()
{
//
}
public function boot()
{
$this->bootEvents();
$this->mapRoutes();
$this->makeTenancyMiddlewareHighestPriority();
}
protected function bootEvents()
{
foreach ($this->events() as $event => $listeners) {
foreach ($listeners as $listener) {
if ($listener instanceof JobPipeline) {
$listener = $listener->toListener();
}
Event::listen($event, $listener);
}
}
}
protected function mapRoutes()
{
$this->app->booted(function () {
if (file_exists(base_path('routes/tenant.php'))) {
Route::namespace(static::$controllerNamespace)
->group(base_path('routes/tenant.php'));
}
});
}
protected function makeTenancyMiddlewareHighestPriority()
{
$tenancyMiddleware = [
// Even higher priority than the initialization middleware
Middleware\PreventAccessFromCentralDomains::class,
Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class,
Middleware\InitializeTenancyByDomainOrSubdomain::class,
Middleware\InitializeTenancyByPath::class,
Middleware\InitializeTenancyByRequestData::class,
];
foreach (array_reverse($tenancyMiddleware) as $middleware) {
$this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware);
}
}
}

View File

@@ -2,4 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\TenancyServiceProvider::class,
];

View File

@@ -14,6 +14,7 @@
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"spatie/laravel-permission": "^6.24",
"stancl/tenancy": "^3.9",
"tightenco/ziggy": "^2.6"
},
"require-dev": {

228
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "edfbf8e1cc43c925ea8b04fc1f93da65",
"content-hash": "931b01f076d9ee28568cd36f178a0c04",
"packages": [
{
"name": "brick/math",
@@ -508,6 +508,59 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "facade/ignition-contracts",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/facade/ignition-contracts.git",
"reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
"reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
"shasum": ""
},
"require": {
"php": "^7.3|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v2.15.8",
"phpunit/phpunit": "^9.3.11",
"vimeo/psalm": "^3.17.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Facade\\IgnitionContracts\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://flareapp.io",
"role": "Developer"
}
],
"description": "Solution contracts for Ignition",
"homepage": "https://github.com/facade/ignition-contracts",
"keywords": [
"contracts",
"flare",
"ignition"
],
"support": {
"issues": "https://github.com/facade/ignition-contracts/issues",
"source": "https://github.com/facade/ignition-contracts/tree/1.0.2"
},
"time": "2020-10-16T08:27:54+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@@ -3443,6 +3496,179 @@
],
"time": "2025-12-13T21:45:21+00:00"
},
{
"name": "stancl/jobpipeline",
"version": "v1.8.1",
"source": {
"type": "git",
"url": "https://github.com/archtechx/jobpipeline.git",
"reference": "c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/archtechx/jobpipeline/zipball/c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c",
"reference": "c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c",
"shasum": ""
},
"require": {
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.0"
},
"require-dev": {
"ext-redis": "*",
"orchestra/testbench": "^8.0|^9.0|^10.0",
"spatie/valuestore": "^1.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Stancl\\JobPipeline\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Samuel Štancl",
"email": "samuel.stancl@gmail.com"
}
],
"description": "Turn any series of jobs into Laravel listeners.",
"support": {
"issues": "https://github.com/archtechx/jobpipeline/issues",
"source": "https://github.com/archtechx/jobpipeline/tree/v1.8.1"
},
"time": "2025-07-29T20:21:17+00:00"
},
{
"name": "stancl/tenancy",
"version": "v3.9.1",
"source": {
"type": "git",
"url": "https://github.com/archtechx/tenancy.git",
"reference": "d98a170fbd2e114604bfec3bc6267a3d6e02dec1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/archtechx/tenancy/zipball/d98a170fbd2e114604bfec3bc6267a3d6e02dec1",
"reference": "d98a170fbd2e114604bfec3bc6267a3d6e02dec1",
"shasum": ""
},
"require": {
"ext-json": "*",
"facade/ignition-contracts": "^1.0.2",
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.0",
"ramsey/uuid": "^4.7.3",
"stancl/jobpipeline": "^1.8.0",
"stancl/virtualcolumn": "^1.5.0"
},
"require-dev": {
"doctrine/dbal": "^3.6.0",
"laravel/framework": "^10.0|^11.0|^12.0",
"league/flysystem-aws-s3-v3": "^3.12.2",
"orchestra/testbench": "^8.0|^9.0|^10.0",
"spatie/valuestore": "^1.3.2"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Tenancy": "Stancl\\Tenancy\\Facades\\Tenancy",
"GlobalCache": "Stancl\\Tenancy\\Facades\\GlobalCache"
},
"providers": [
"Stancl\\Tenancy\\TenancyServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Stancl\\Tenancy\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Samuel Štancl",
"email": "samuel.stancl@gmail.com"
}
],
"description": "Automatic multi-tenancy for your Laravel application.",
"keywords": [
"laravel",
"multi-database",
"multi-tenancy",
"tenancy"
],
"support": {
"issues": "https://github.com/archtechx/tenancy/issues",
"source": "https://github.com/archtechx/tenancy/tree/v3.9.1"
},
"funding": [
{
"url": "https://tenancyforlaravel.com/donate",
"type": "custom"
},
{
"url": "https://github.com/stancl",
"type": "github"
}
],
"time": "2025-03-13T16:02:11+00:00"
},
{
"name": "stancl/virtualcolumn",
"version": "v1.5.0",
"source": {
"type": "git",
"url": "https://github.com/archtechx/virtualcolumn.git",
"reference": "75718edcfeeb19abc1970f5395043f7d43cce5bc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/archtechx/virtualcolumn/zipball/75718edcfeeb19abc1970f5395043f7d43cce5bc",
"reference": "75718edcfeeb19abc1970f5395043f7d43cce5bc",
"shasum": ""
},
"require": {
"illuminate/database": ">=10.0",
"illuminate/support": ">=10.0"
},
"require-dev": {
"orchestra/testbench": ">=8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Stancl\\VirtualColumn\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Samuel Štancl",
"email": "samuel.stancl@gmail.com"
}
],
"description": "Eloquent virtual column.",
"support": {
"issues": "https://github.com/archtechx/virtualcolumn/issues",
"source": "https://github.com/archtechx/virtualcolumn/tree/v1.5.0"
},
"time": "2025-02-25T13:12:44+00:00"
},
{
"name": "symfony/clock",
"version": "v8.0.0",

199
config/tenancy.php Normal file
View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
use Stancl\Tenancy\Database\Models\Domain;
use App\Models\Tenant;
return [
'tenant_model' => Tenant::class,
'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
'domain_model' => Domain::class,
/**
* The list of domains hosting your central app.
*
* Only relevant if you're using the domain or subdomain identification middleware.
*/
'central_domains' => [
'127.0.0.1',
'localhost',
],
/**
* Tenancy bootstrappers are executed when tenancy is initialized.
* Their responsibility is making Laravel features tenant-aware.
*
* To configure their behavior, see the config keys below.
*/
'bootstrappers' => [
Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
],
/**
* Database tenancy config. Used by DatabaseTenancyBootstrapper.
*/
'database' => [
'central_connection' => env('DB_CONNECTION', 'central'),
/**
* Connection used as a "template" for the dynamically created tenant database connection.
* Note: don't name your template connection tenant. That name is reserved by package.
*/
'template_tenant_connection' => null,
/**
* Tenant database names are created like this:
* prefix + tenant_id + suffix.
*/
'prefix' => 'tenant',
'suffix' => '',
/**
* TenantDatabaseManagers are classes that handle the creation & deletion of tenant databases.
*/
'managers' => [
'sqlite' => Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager::class,
'mysql' => Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager::class,
'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,
/**
* Use this database manager for MySQL to have a DB user created for each tenant database.
* You can customize the grants given to these users by changing the $grants property.
*/
// 'mysql' => Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager::class,
/**
* Disable the pgsql manager above, and enable the one below if you
* want to separate tenant DBs by schemas rather than databases.
*/
// 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database
],
],
/**
* Cache tenancy config. Used by CacheTenancyBootstrapper.
*
* This works for all Cache facade calls, cache() helper
* calls and direct calls to injected cache stores.
*
* Each key in cache will have a tag applied on it. This tag is used to
* scope the cache both when writing to it and when reading from it.
*
* You can clear cache selectively by specifying the tag.
*/
'cache' => [
'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call.
],
/**
* Filesystem tenancy config. Used by FilesystemTenancyBootstrapper.
* https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper.
*/
'filesystem' => [
/**
* Each disk listed in the 'disks' array will be suffixed by the suffix_base, followed by the tenant_id.
*/
'suffix_base' => 'tenant',
'disks' => [
'local',
'public',
// 's3',
],
/**
* Use this for local disks.
*
* See https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper
*/
'root_override' => [
// Disks whose roots should be overridden after storage_path() is suffixed.
'local' => '%storage_path%/app/',
'public' => '%storage_path%/app/public/',
],
/**
* Should storage_path() be suffixed.
*
* Note: Disabling this will likely break local disk tenancy. Only disable this if you're using an external file storage service like S3.
*
* For the vast majority of applications, this feature should be enabled. But in some
* edge cases, it can cause issues (like using Passport with Vapor - see #196), so
* you may want to disable this if you are experiencing these edge case issues.
*/
'suffix_storage_path' => true,
/**
* By default, asset() calls are made multi-tenant too. You can use global_asset() and mix()
* for global, non-tenant-specific assets. However, you might have some issues when using
* packages that use asset() calls inside the tenant app. To avoid such issues, you can
* disable asset() helper tenancy and explicitly use tenant_asset() calls in places
* where you want to use tenant-specific assets (product images, avatars, etc).
*/
'asset_helper_tenancy' => true,
],
/**
* Redis tenancy config. Used by RedisTenancyBootstrapper.
*
* Note: You need phpredis to use Redis tenancy.
*
* Note: You don't need to use this if you're using Redis only for cache.
* Redis tenancy is only relevant if you're making direct Redis calls,
* either using the Redis facade or by injecting it as a dependency.
*/
'redis' => [
'prefix_base' => 'tenant', // Each key in Redis will be prepended by this prefix_base, followed by the tenant id.
'prefixed_connections' => [ // Redis connections whose keys are prefixed, to separate one tenant's keys from another.
// 'default',
],
],
/**
* Features are classes that provide additional functionality
* not needed for tenancy to be bootstrapped. They are run
* regardless of whether tenancy has been initialized.
*
* See the documentation page for each class to
* understand which ones you want to enable.
*/
'features' => [
// Stancl\Tenancy\Features\UserImpersonation::class,
// Stancl\Tenancy\Features\TelescopeTags::class,
// Stancl\Tenancy\Features\UniversalRoutes::class,
// Stancl\Tenancy\Features\TenantConfig::class, // https://tenancyforlaravel.com/docs/v3/features/tenant-config
// Stancl\Tenancy\Features\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect
// Stancl\Tenancy\Features\ViteBundler::class,
],
/**
* Should tenancy routes be registered.
*
* Tenancy routes include tenant asset routes. By default, this route is
* enabled. But it may be useful to disable them if you use external
* storage (e.g. S3 / Dropbox) or have a custom asset controller.
*/
'routes' => true,
/**
* Parameters used by the tenants:migrate command.
*/
'migration_parameters' => [
'--force' => true, // This needs to be true to run migrations in production.
'--path' => [database_path('migrations/tenant')],
'--realpath' => true,
],
/**
* Parameters used by the tenants:seed command.
*/
'seeder_parameters' => [
'--class' => 'DatabaseSeeder', // root seeder class
// '--force' => true, // This needs to be true to seed tenant databases in production
],
];

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTenantsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->string('id')->primary();
// your custom columns may go here
$table->timestamps();
$table->json('data')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('tenants');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateDomainsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::create('domains', function (Blueprint $table) {
$table->increments('id');
$table->string('domain', 255)->unique();
$table->string('tenant_id');
$table->timestamps();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('domains');
}
}

View File

@@ -0,0 +1,71 @@
# Multi-tenancy 部署手冊
> 記錄本地開發完成後,上 Demo/Production 環境時需要手動執行的操作。
> CI/CD 會自動執行的項目已排除。
---
## Step 1: 安裝 stancl/tenancy
**CI/CD 會自動執行**`composer install`
**手動操作**:無
---
## Step 2: 設定 Central Domain + Tenant 識別
**手動操作**
1. 修改 `.env`,加入:
```bash
# Demo 環境 (192.168.0.103)
CENTRAL_DOMAINS=192.168.0.103,localhost
# Production 環境 (erp.koori.tw)
CENTRAL_DOMAINS=erp.koori.tw
```
---
## Step 3: 分離 Migrations
**CI/CD 會自動執行**`php artisan migrate --force`
**手動操作**:無
> 注意migrations 結構已調整如下:
> - `database/migrations/` - Central tables (tenants, domains)
> - `database/migrations/tenant/` - Tenant tables (所有業務表)
---
## Step 4: 遷移現有資料到 tenant_koori
**首次部署手動操作**
1. 授予 MySQL sail 使用者 CREATE DATABASE 權限:
```bash
docker exec koori-erp-mysql mysql -uroot -p[PASSWORD] -e "GRANT ALL PRIVILEGES ON *.* TO 'sail'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
```
2. 建立第一個租戶 (小小冰室)
```bash
docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute="
use App\Models\Tenant;
Tenant::create(['id' => 'koori', 'name' => '小小冰室']);
"
```
3. 為租戶綁定域名:
```bash
docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute="
use App\Models\Tenant;
Tenant::find('koori')->domains()->create(['domain' => 'koori.your-domain.com']);
"
```
4. 執行資料遷移 (從 central DB 複製到 tenant DB)
```bash
docker exec -w /var/www/html koori-erp-laravel php artisan tenancy:migrate-data koori
```
## Step 5: 建立房東後台
**手動操作**:無
---
## 其他注意事項
- 待補充...

View File

@@ -0,0 +1,132 @@
import { Link, usePage } from "@inertiajs/react";
import { cn } from "@/lib/utils";
import {
Building2,
LayoutDashboard,
LogOut,
User,
} from "lucide-react";
import { Toaster } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
interface LandlordLayoutProps {
children: React.ReactNode;
title?: string;
}
export default function LandlordLayout({ children, title }: LandlordLayoutProps) {
const { url, props } = usePage();
// @ts-ignore
const user = props.auth?.user || { name: 'Admin', username: 'admin' };
const menuItems = [
{
label: "儀表板",
href: "/landlord",
icon: LayoutDashboard,
active: url === "/landlord" || url === "/landlord/",
},
{
label: "租戶管理",
href: "/landlord/tenants",
icon: Building2,
active: url.startsWith("/landlord/tenants"),
},
];
return (
<div className="flex min-h-screen bg-slate-50">
{/* Sidebar */}
<aside className="fixed left-0 top-0 bottom-0 w-64 bg-slate-900 text-white flex flex-col">
<div className="h-16 flex items-center px-6 border-b border-slate-800">
<Link href="/landlord" className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary-main rounded-lg flex items-center justify-center">
<Building2 className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-lg"></span>
</Link>
</div>
<nav className="flex-1 p-4 space-y-1">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors",
item.active
? "bg-primary-main text-white"
: "text-slate-400 hover:bg-slate-800 hover:text-white"
)}
>
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.label}</span>
</Link>
))}
</nav>
<div className="p-4 border-t border-slate-800">
<Link
href="/"
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors text-sm"
>
ERP
</Link>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 ml-64">
{/* Header */}
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
<h1 className="text-lg font-semibold text-slate-900">
{title || "房東管理後台"}
</h1>
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
<div className="flex flex-col items-end mr-1">
<span className="text-sm font-medium text-slate-700">
{user.name}
</span>
<span className="text-xs text-slate-500"></span>
</div>
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center">
<User className="h-5 w-5 text-slate-600" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 z-[100]">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('logout')}
method="post"
as="button"
className="w-full flex items-center cursor-pointer text-red-600"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
{/* Content */}
<div className="p-6">
{children}
</div>
</main>
<Toaster richColors closeButton position="top-center" />
</div>
);
}

View File

@@ -0,0 +1,106 @@
import LandlordLayout from "@/Layouts/LandlordLayout";
import { Building2, Users, Activity } from "lucide-react";
import { Link } from "@inertiajs/react";
interface Tenant {
id: string;
name: string;
is_active: boolean;
created_at: string;
domains: string[];
}
interface DashboardProps {
totalTenants: number;
activeTenants: number;
recentTenants: Tenant[];
}
export default function Dashboard({ totalTenants, activeTenants, recentTenants }: DashboardProps) {
const statsCards = [
{
title: "租戶總數",
value: totalTenants,
icon: Building2,
color: "bg-blue-500",
},
{
title: "啟用中",
value: activeTenants,
icon: Activity,
color: "bg-green-500",
},
{
title: "停用中",
value: totalTenants - activeTenants,
icon: Users,
color: "bg-slate-400",
},
];
return (
<LandlordLayout title="儀表板">
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{statsCards.map((stat) => (
<div
key={stat.title}
className="bg-white rounded-xl border border-slate-200 p-6 flex items-center gap-4"
>
<div className={`w-12 h-12 ${stat.color} rounded-lg flex items-center justify-center`}>
<stat.icon className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-slate-500">{stat.title}</p>
<p className="text-2xl font-bold text-slate-900">{stat.value}</p>
</div>
</div>
))}
</div>
{/* Recent Tenants */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900"></h2>
<Link
href="/landlord/tenants"
className="text-sm text-primary-main hover:underline"
>
</Link>
</div>
<div className="divide-y divide-slate-100">
{recentTenants.length === 0 ? (
<div className="px-6 py-8 text-center text-slate-500">
</div>
) : (
recentTenants.map((tenant) => (
<div key={tenant.id} className="px-6 py-4 flex items-center justify-between">
<div>
<p className="font-medium text-slate-900">{tenant.name}</p>
<p className="text-sm text-slate-500">
{tenant.domains.length > 0 ? tenant.domains.join(", ") : "無綁定域名"}
</p>
</div>
<div className="flex items-center gap-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
? "bg-green-100 text-green-700"
: "bg-slate-100 text-slate-600"
}`}
>
{tenant.is_active ? "啟用" : "停用"}
</span>
<span className="text-sm text-slate-400">{tenant.created_at}</span>
</div>
</div>
))
)}
</div>
</div>
</div>
</LandlordLayout>
);
}

View File

@@ -0,0 +1,104 @@
import LandlordLayout from "@/Layouts/LandlordLayout";
import { useForm, Link } from "@inertiajs/react";
import { FormEvent } from "react";
export default function TenantCreate() {
const { data, setData, post, processing, errors } = useForm({
id: "",
name: "",
email: "",
domain: "",
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
post(route("landlord.tenants.store"));
};
return (
<LandlordLayout title="新增租戶">
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900"></h1>
<p className="text-slate-500 mt-1"></p>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
ID <span className="text-red-500">*</span>
</label>
<input
type="text"
value={data.id}
onChange={(e) => setData("id", e.target.value.toLowerCase())}
placeholder="例如koori, alcohol"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
<p className="mt-1 text-sm text-slate-500">使</p>
{errors.id && <p className="mt-1 text-sm text-red-500">{errors.id}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={data.name}
onChange={(e) => setData("name", e.target.value)}
placeholder="例如:小小冰室"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
</label>
<input
type="email"
value={data.email}
onChange={(e) => setData("email", e.target.value)}
placeholder="admin@example.com"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
</label>
<input
type="text"
value={data.domain}
onChange={(e) => setData("domain", e.target.value)}
placeholder="例如koori.erp.koori.tw"
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
<p className="mt-1 text-sm text-slate-500"></p>
{errors.domain && <p className="mt-1 text-sm text-red-500">{errors.domain}</p>}
</div>
<div className="flex items-center gap-4 pt-4 border-t">
<button
type="submit"
disabled={processing}
className="bg-primary-main hover:bg-primary-dark text-white px-6 py-2 rounded-lg disabled:opacity-50 transition-colors"
>
{processing ? "處理中..." : "建立租戶"}
</button>
<Link
href="/landlord/tenants"
className="text-slate-600 hover:text-slate-900"
>
</Link>
</div>
</form>
</div>
</LandlordLayout>
);
}

View File

@@ -0,0 +1,107 @@
import LandlordLayout from "@/Layouts/LandlordLayout";
import { useForm, Link } from "@inertiajs/react";
import { FormEvent } from "react";
interface Tenant {
id: string;
name: string;
email: string | null;
is_active: boolean;
}
interface Props {
tenant: Tenant;
}
export default function TenantEdit({ tenant }: Props) {
const { data, setData, put, processing, errors } = useForm({
name: tenant.name,
email: tenant.email || "",
is_active: tenant.is_active,
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
put(route("landlord.tenants.update", tenant.id));
};
return (
<LandlordLayout title="編輯租戶">
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900"></h1>
<p className="text-slate-500 mt-1"> {tenant.id} </p>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
ID
</label>
<input
type="text"
value={tenant.id}
disabled
className="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-50 text-slate-500"
/>
<p className="mt-1 text-sm text-slate-500"> ID </p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={data.name}
onChange={(e) => setData("name", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
</label>
<input
type="email"
value={data.email}
onChange={(e) => setData("email", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
</div>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={data.is_active}
onChange={(e) => setData("is_active", e.target.checked)}
className="w-4 h-4 rounded border-slate-300 text-primary-main focus:ring-primary-main"
/>
<span className="text-sm font-medium text-slate-700"></span>
</label>
</div>
<div className="flex items-center gap-4 pt-4 border-t">
<button
type="submit"
disabled={processing}
className="bg-primary-main hover:bg-primary-dark text-white px-6 py-2 rounded-lg disabled:opacity-50 transition-colors"
>
{processing ? "儲存中..." : "儲存變更"}
</button>
<Link
href="/landlord/tenants"
className="text-slate-600 hover:text-slate-900"
>
</Link>
</div>
</form>
</div>
</LandlordLayout>
);
}

View File

@@ -0,0 +1,188 @@
import LandlordLayout from "@/Layouts/LandlordLayout";
import { Link, router } from "@inertiajs/react";
import { Plus, Edit, Trash2, Globe } from "lucide-react";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
interface Tenant {
id: string;
name: string;
email: string | null;
is_active: boolean;
created_at: string;
domains: string[];
}
interface Props {
tenants: Tenant[];
}
export default function TenantIndex({ tenants }: Props) {
const [deleteTarget, setDeleteTarget] = useState<Tenant | null>(null);
const handleDelete = () => {
if (deleteTarget) {
router.delete(route("landlord.tenants.destroy", deleteTarget.id));
setDeleteTarget(null);
}
};
return (
<LandlordLayout title="租戶管理">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900"></h1>
<p className="text-slate-500 mt-1"></p>
</div>
<Link
href="/landlord/tenants/create"
className="bg-primary-main hover:bg-primary-dark text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<Plus className="w-4 h-4" />
</Link>
</div>
{/* Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase">
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{tenants.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-500">
</td>
</tr>
) : (
tenants.map((tenant) => (
<tr key={tenant.id} className="hover:bg-slate-50">
<td className="px-6 py-4 font-mono text-sm text-slate-600">
{tenant.id}
</td>
<td className="px-6 py-4">
<div>
<p className="font-medium text-slate-900">{tenant.name}</p>
{tenant.email && (
<p className="text-sm text-slate-500">{tenant.email}</p>
)}
</div>
</td>
<td className="px-6 py-4">
{tenant.domains.length > 0 ? (
<div className="flex items-center gap-1 flex-wrap">
{tenant.domains.map((domain) => (
<span
key={domain}
className="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 rounded text-xs"
>
<Globe className="w-3 h-3" />
{domain}
</span>
))}
</div>
) : (
<span className="text-slate-400 text-sm"></span>
)}
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
? "bg-green-100 text-green-700"
: "bg-slate-100 text-slate-600"
}`}
>
{tenant.is_active ? "啟用" : "停用"}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">
{tenant.created_at}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link
href={`/landlord/tenants/${tenant.id}`}
className="p-2 text-slate-400 hover:text-primary-main hover:bg-slate-100 rounded-lg transition-colors"
title="查看詳情"
>
<Globe className="w-4 h-4" />
</Link>
<Link
href={`/landlord/tenants/${tenant.id}/edit`}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-slate-100 rounded-lg transition-colors"
title="編輯"
>
<Edit className="w-4 h-4" />
</Link>
<button
onClick={() => setDeleteTarget(tenant)}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="刪除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</LandlordLayout>
);
}

View File

@@ -0,0 +1,165 @@
import LandlordLayout from "@/Layouts/LandlordLayout";
import { Link, useForm, router } from "@inertiajs/react";
import { Globe, Plus, Trash2, ArrowLeft } from "lucide-react";
import { FormEvent, useState } from "react";
interface Domain {
id: number;
domain: string;
}
interface Tenant {
id: string;
name: string;
email: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
domains: Domain[];
}
interface Props {
tenant: Tenant;
}
export default function TenantShow({ tenant }: Props) {
const [showAddDomain, setShowAddDomain] = useState(false);
const { data, setData, post, processing, errors, reset } = useForm({
domain: "",
});
const handleAddDomain = (e: FormEvent) => {
e.preventDefault();
post(route("landlord.tenants.domains.store", tenant.id), {
onSuccess: () => {
reset();
setShowAddDomain(false);
},
});
};
const handleRemoveDomain = (domainId: number) => {
if (confirm("確定要移除這個域名嗎?")) {
router.delete(route("landlord.tenants.domains.destroy", [tenant.id, domainId]));
}
};
return (
<LandlordLayout title="租戶詳情">
<div className="max-w-3xl space-y-6">
{/* Back Link */}
<Link
href="/landlord/tenants"
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-900"
>
<ArrowLeft className="w-4 h-4" />
</Link>
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">{tenant.name}</h1>
<p className="text-slate-500 mt-1"> ID: {tenant.id}</p>
</div>
<Link
href={`/landlord/tenants/${tenant.id}/edit`}
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2 rounded-lg transition-colors"
>
</Link>
</div>
{/* Info Card */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4"></h2>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-slate-500"></dt>
<dd className="mt-1">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
? "bg-green-100 text-green-700"
: "bg-slate-100 text-slate-600"
}`}
>
{tenant.is_active ? "啟用" : "停用"}
</span>
</dd>
</div>
<div>
<dt className="text-sm text-slate-500"></dt>
<dd className="mt-1 text-slate-900">{tenant.email || "-"}</dd>
</div>
<div>
<dt className="text-sm text-slate-500"></dt>
<dd className="mt-1 text-slate-900">{tenant.created_at}</dd>
</div>
<div>
<dt className="text-sm text-slate-500"></dt>
<dd className="mt-1 text-slate-900">{tenant.updated_at}</dd>
</div>
</dl>
</div>
{/* Domains Card */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900"></h2>
<button
onClick={() => setShowAddDomain(!showAddDomain)}
className="text-primary-main hover:text-primary-dark flex items-center gap-1 text-sm"
>
<Plus className="w-4 h-4" />
</button>
</div>
{showAddDomain && (
<form onSubmit={handleAddDomain} className="mb-4 flex gap-2">
<input
type="text"
value={data.domain}
onChange={(e) => setData("domain", e.target.value)}
placeholder="例如koori.erp.koori.tw"
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
<button
type="submit"
disabled={processing}
className="bg-primary-main hover:bg-primary-dark text-white px-4 py-2 rounded-lg disabled:opacity-50"
>
</button>
</form>
)}
{errors.domain && <p className="mb-4 text-sm text-red-500">{errors.domain}</p>}
{tenant.domains.length === 0 ? (
<p className="text-slate-500 text-sm"></p>
) : (
<ul className="space-y-2">
{tenant.domains.map((domain) => (
<li
key={domain.id}
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
>
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-slate-400" />
<span className="text-slate-900">{domain.domain}</span>
</div>
<button
onClick={() => handleRemoveDomain(domain.id)}
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
</LandlordLayout>
);
}

27
routes/landlord.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Landlord\DashboardController;
use App\Http\Controllers\Landlord\TenantController;
/*
|--------------------------------------------------------------------------
| Landlord Routes (房東後台路由)
|--------------------------------------------------------------------------
|
| 這些路由用於中央管理後台,只能透過 central domain 存取。
| 用於管理租戶 (新增、編輯、停用) 等系統級操作。
|
*/
Route::prefix('landlord')->name('landlord.')->middleware(['web', 'auth'])->group(function () {
// 房東儀表板
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
// 租戶管理 CRUD
Route::resource('tenants', TenantController::class);
// 租戶域名管理
Route::post('tenants/{tenant}/domains', [TenantController::class, 'addDomain'])->name('tenants.domains.store');
Route::delete('tenants/{tenant}/domains/{domain}', [TenantController::class, 'removeDomain'])->name('tenants.domains.destroy');
});

26
routes/tenant.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
/*
|--------------------------------------------------------------------------
| Tenant Routes
|--------------------------------------------------------------------------
|
| 租戶專屬路由。當使用者透過租戶網域 ( koori.koori-erp.test) 存取時,
| 會自動初始化租戶 context 並連接到對應的租戶資料庫。
|
*/
Route::middleware([
'web',
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
])->group(function () {
// 載入與 central 相同的 ERP 路由,但運行在租戶資料庫 context 中
require base_path('routes/web.php');
});