Major Features: - ✅ Multi-tenant architecture (tenant isolation) - ✅ Employee CRUD with lifecycle management (onboarding/offboarding) - ✅ Department tree structure with email domain management - ✅ Company info management (single-record editing) - ✅ System functions CRUD (permission management) - ✅ Email account management (multi-account per employee) - ✅ Keycloak SSO integration (auth.lab.taipei) - ✅ Redis session storage (10.1.0.254:6379) - Solves Cookie 4KB limitation - Cross-system session sharing - Sliding expiration (8 hours) - Automatic token refresh Technical Stack: Backend: - FastAPI + SQLAlchemy - PostgreSQL 16 (10.1.0.20:5433) - Keycloak Admin API integration - Docker Mailserver integration (SSH) - Alembic migrations Frontend: - Next.js 14 (App Router) - NextAuth 4 with Keycloak Provider - Redis session storage (ioredis) - Tailwind CSS Infrastructure: - Redis 7 (10.1.0.254:6379) - Session + Cache - Keycloak 26.1.0 (auth.lab.taipei) - Docker Mailserver (10.1.0.254) Architecture Highlights: - Session管理由 Keycloak + Redis 統一控制 - 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session - Token 自動刷新,異質服務整合 - 未來可無縫遷移到雲端 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
482 lines
21 KiB
TypeScript
482 lines
21 KiB
TypeScript
/**
|
||
* HR Portal 系統初始化引導頁面
|
||
*
|
||
* 功能:
|
||
* 1. 檢查系統初始化狀態(三階段:Initialization/Operational/Transition)
|
||
* 2. 引導用戶完成環境配置
|
||
* 3. 顯示當前階段與配置進度
|
||
*/
|
||
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
|
||
interface SystemStatus {
|
||
current_phase: 'initialization' | 'operational' | 'transition'
|
||
is_initialized: boolean
|
||
initialization_completed: boolean
|
||
configured_count: number
|
||
configured_categories: string[]
|
||
missing_categories: string[]
|
||
is_locked?: boolean
|
||
next_action: string
|
||
message: string
|
||
// Operational 階段欄位
|
||
last_health_check_at?: string
|
||
health_check_status?: string
|
||
// Transition 階段欄位
|
||
env_db_consistent?: boolean
|
||
inconsistencies?: string
|
||
}
|
||
|
||
interface ConfigDetail {
|
||
redis?: { host: string; port: string; db: string }
|
||
database?: { host: string; port: string; name: string; user: string }
|
||
keycloak?: { url: string; realm: string; admin_username: string }
|
||
}
|
||
|
||
export default function InstallationPage() {
|
||
const router = useRouter()
|
||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||
const [configDetails, setConfigDetails] = useState<ConfigDetail>({})
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
checkSystemStatus()
|
||
}, [])
|
||
|
||
const checkSystemStatus = async () => {
|
||
try {
|
||
setLoading(true)
|
||
setError(null)
|
||
|
||
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/check-status')
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const data: SystemStatus = await response.json()
|
||
setStatus(data)
|
||
|
||
// 載入已完成配置的詳細資訊
|
||
if (data.configured_categories.length > 0) {
|
||
await loadConfigDetails(data.configured_categories)
|
||
}
|
||
|
||
// 如果已初始化,導向健康檢查頁面
|
||
if (data.is_initialized) {
|
||
router.push('/installation/health-check')
|
||
}
|
||
} catch (err) {
|
||
console.error('[Installation] Failed to check status:', err)
|
||
setError(err instanceof Error ? err.message : '無法連接到後端 API')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadConfigDetails = async (categories: string[]) => {
|
||
const details: ConfigDetail = {}
|
||
|
||
for (const category of categories) {
|
||
try {
|
||
const response = await fetch(`http://10.1.0.245:10181/api/v1/installation/get-config/${category}`)
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
console.log(`[Installation] Loaded ${category} config:`, data)
|
||
if (data.configured && data.config) {
|
||
details[category as keyof ConfigDetail] = data.config
|
||
console.log(`[Installation] ${category} details:`, data.config)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Installation] Failed to load ${category} config:`, error)
|
||
}
|
||
}
|
||
|
||
console.log('[Installation] All config details:', details)
|
||
setConfigDetails(details)
|
||
}
|
||
|
||
const startInstallation = () => {
|
||
// 導向下一個未完成的設定階段
|
||
if (!status) return
|
||
|
||
if (!status.configured_categories.includes('redis')) {
|
||
router.push('/installation/phase1-redis')
|
||
} else if (!status.configured_categories.includes('database')) {
|
||
router.push('/installation/phase2-database')
|
||
} else if (!status.configured_categories.includes('keycloak')) {
|
||
router.push('/installation/phase3-keycloak')
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||
<div className="text-center">
|
||
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||
<h1 className="text-2xl font-bold text-gray-100 mb-2">HR Portal 系統初始化</h1>
|
||
<p className="text-gray-400">正在檢查系統狀態...</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||
<div className="bg-gray-800 rounded-lg shadow-xl p-8 max-w-md border border-gray-700">
|
||
<div className="text-center mb-6">
|
||
<div className="w-16 h-16 bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-800">
|
||
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</div>
|
||
<h2 className="text-2xl font-bold text-gray-100 mb-2">連接失敗</h2>
|
||
<p className="text-gray-300 mb-4">{error}</p>
|
||
<div className="bg-yellow-900/20 border border-yellow-700 rounded-lg p-4 text-left text-sm">
|
||
<p className="font-semibold text-yellow-400 mb-2">可能的原因:</p>
|
||
<ul className="list-disc list-inside text-yellow-300 space-y-1">
|
||
<li>後端服務未啟動 (Port 10181)</li>
|
||
<li>資料庫連接失敗</li>
|
||
<li>網路連接問題</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={checkSystemStatus}
|
||
className="w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 transition-colors"
|
||
>
|
||
重試
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 階段顏色與圖示(深色主題)
|
||
const getPhaseConfig = (phase: string) => {
|
||
switch (phase) {
|
||
case 'initialization':
|
||
return {
|
||
color: 'from-blue-500 to-indigo-600',
|
||
bgColor: 'bg-blue-900/20',
|
||
borderColor: 'border-blue-700',
|
||
textColor: 'text-blue-300',
|
||
icon: '⚙️',
|
||
title: 'Initialization 階段',
|
||
description: '系統初始化中,正在設定環境配置'
|
||
}
|
||
case 'operational':
|
||
return {
|
||
color: 'from-green-500 to-emerald-600',
|
||
bgColor: 'bg-green-900/20',
|
||
borderColor: 'border-green-700',
|
||
textColor: 'text-green-300',
|
||
icon: '✅',
|
||
title: 'Operational 階段',
|
||
description: '系統正常運作中,可進行健康檢查'
|
||
}
|
||
case 'transition':
|
||
return {
|
||
color: 'from-orange-500 to-amber-600',
|
||
bgColor: 'bg-orange-900/20',
|
||
borderColor: 'border-orange-700',
|
||
textColor: 'text-orange-300',
|
||
icon: '🔄',
|
||
title: 'Transition 階段',
|
||
description: '系統移轉中,正在檢查環境一致性'
|
||
}
|
||
default:
|
||
return {
|
||
color: 'from-gray-500 to-gray-600',
|
||
bgColor: 'bg-gray-800',
|
||
borderColor: 'border-gray-700',
|
||
textColor: 'text-gray-300',
|
||
icon: '❓',
|
||
title: '未知階段',
|
||
description: '系統狀態未知'
|
||
}
|
||
}
|
||
}
|
||
|
||
const phaseConfig = status ? getPhaseConfig(status.current_phase) : null
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-950">
|
||
<div className="container mx-auto px-4 py-16">
|
||
<div className="max-w-4xl mx-auto">
|
||
{/* Header */}
|
||
<div className="text-center mb-12">
|
||
<h1 className="text-4xl font-bold text-gray-100 mb-4">
|
||
HR Portal 系統狀態
|
||
</h1>
|
||
<p className="text-xl text-gray-400">
|
||
三階段系統管理
|
||
</p>
|
||
</div>
|
||
|
||
{/* Phase Status Card */}
|
||
{status && phaseConfig && (
|
||
<div className="bg-gray-800 rounded-xl shadow-2xl p-8 mb-8 border border-gray-700">
|
||
<div className="flex items-center justify-center mb-6">
|
||
<div className={`w-20 h-20 bg-gradient-to-br ${phaseConfig.color} rounded-full flex items-center justify-center text-4xl`}>
|
||
{phaseConfig.icon}
|
||
</div>
|
||
</div>
|
||
|
||
<h2 className="text-2xl font-bold text-center text-gray-100 mb-2">
|
||
{phaseConfig.title}
|
||
</h2>
|
||
|
||
<p className="text-center text-gray-400 mb-8">
|
||
{phaseConfig.description}
|
||
</p>
|
||
|
||
{/* Message from Backend */}
|
||
<div className={`${phaseConfig.bgColor} ${phaseConfig.borderColor} border rounded-lg p-4 mb-8`}>
|
||
<p className={`${phaseConfig.textColor} text-center font-medium`}>
|
||
{status.message}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Configuration Progress */}
|
||
{status.current_phase === 'initialization' && (
|
||
<>
|
||
<div className="mb-6">
|
||
<div className="flex justify-between items-center mb-2">
|
||
<span className="text-sm font-medium text-gray-300">環境配置進度</span>
|
||
<span className="text-sm text-gray-400">
|
||
{status.configured_count} / 3 完成
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||
<div
|
||
className="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all"
|
||
style={{ width: `${(status.configured_count / 3) * 100}%` }}
|
||
></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3 mb-8">
|
||
{[
|
||
{ key: 'redis', name: 'Redis 快取設定', icon: '🔴', route: '/installation/phase1-redis' },
|
||
{ key: 'database', name: '資料庫連接設定', icon: '🗄️', route: '/installation/phase2-database' },
|
||
{ key: 'keycloak', name: 'Keycloak SSO 設定', icon: '🔐', route: '/installation/phase3-keycloak' },
|
||
].map((step) => {
|
||
const isConfigured = status.configured_categories.includes(step.key)
|
||
const config = configDetails[step.key as keyof ConfigDetail]
|
||
|
||
// 格式化配置資訊
|
||
let configInfo = ''
|
||
if (config) {
|
||
if (step.key === 'redis') {
|
||
configInfo = `${config.host}:${config.port} (DB ${config.db})`
|
||
} else if (step.key === 'database') {
|
||
configInfo = `${config.host}:${config.port}/${config.name}`
|
||
} else if (step.key === 'keycloak') {
|
||
configInfo = `${config.url} (${config.realm})`
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={step.key}
|
||
className={`p-4 rounded-lg border ${
|
||
isConfigured
|
||
? 'bg-green-900/20 border-green-700'
|
||
: 'bg-gray-700/50 border-gray-600'
|
||
}`}
|
||
>
|
||
<div className="flex items-center">
|
||
<span className="text-2xl mr-4">{step.icon}</span>
|
||
<div className="flex-1">
|
||
<div className={`font-medium ${isConfigured ? 'text-green-300' : 'text-gray-200'}`}>
|
||
{step.name}
|
||
</div>
|
||
{isConfigured && configInfo && (
|
||
<div className="text-xs text-green-400/70 mt-1 font-mono">
|
||
{configInfo}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isConfigured ? (
|
||
<span className="text-sm text-green-400 font-medium flex items-center">
|
||
<svg className="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||
</svg>
|
||
已完成
|
||
</span>
|
||
) : (
|
||
<button
|
||
onClick={() => router.push(step.route)}
|
||
className="text-sm text-indigo-400 hover:text-indigo-300 font-medium"
|
||
>
|
||
設定 →
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="space-y-3">
|
||
{status.missing_categories.length > 0 ? (
|
||
<button
|
||
onClick={startInstallation}
|
||
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-indigo-700 hover:to-purple-700 transition-all transform hover:scale-105 shadow-lg"
|
||
>
|
||
繼續環境設定
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => router.push('/installation/complete')}
|
||
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all transform hover:scale-105 shadow-lg hover:shadow-green-500/50"
|
||
>
|
||
完成初始化 →
|
||
</button>
|
||
)}
|
||
|
||
{/* 開發測試:重置按鈕 */}
|
||
{status.configured_count > 0 && (
|
||
<button
|
||
onClick={async () => {
|
||
if (!confirm('確定要重置所有環境配置?此操作無法復原。')) return
|
||
try {
|
||
const response = await fetch('http://10.1.0.245:10181/api/v1/installation/reset-config/all', {
|
||
method: 'DELETE'
|
||
})
|
||
const data = await response.json()
|
||
if (data.success) {
|
||
alert('重置成功!頁面即將重新載入。')
|
||
window.location.reload()
|
||
} else {
|
||
alert('重置失敗:' + data.message)
|
||
}
|
||
} catch (error) {
|
||
alert('重置失敗:' + (error instanceof Error ? error.message : '未知錯誤'))
|
||
}
|
||
}}
|
||
className="w-full bg-red-600/20 border border-red-700 text-red-300 py-2 px-4 rounded-lg text-sm hover:bg-red-600/30 transition-all"
|
||
>
|
||
🔄 重置環境配置(開發測試)
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Operational Phase Actions */}
|
||
{status.current_phase === 'operational' && (
|
||
<div className="space-y-4">
|
||
<button
|
||
onClick={() => router.push('/installation/health-check')}
|
||
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all shadow-lg hover:shadow-green-500/50"
|
||
>
|
||
執行健康檢查
|
||
</button>
|
||
<button
|
||
onClick={() => router.push('/dashboard')}
|
||
className="w-full bg-gray-700 text-gray-100 border border-gray-600 py-4 px-6 rounded-lg font-semibold text-lg hover:bg-gray-600 transition-all shadow-sm"
|
||
>
|
||
前往系統主頁
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Transition Phase Actions */}
|
||
{status.current_phase === 'transition' && (
|
||
<div className="space-y-4">
|
||
<button
|
||
onClick={() => router.push('/installation/consistency-check')}
|
||
className="w-full bg-gradient-to-r from-orange-600 to-amber-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-orange-700 hover:to-amber-700 transition-all shadow-lg hover:shadow-orange-500/50"
|
||
>
|
||
檢查環境一致性
|
||
</button>
|
||
{status.env_db_consistent && (
|
||
<button
|
||
onClick={() => {/* TODO: Switch to operational */}}
|
||
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-green-700 hover:to-emerald-700 transition-all shadow-lg hover:shadow-green-500/50"
|
||
>
|
||
返回營運階段
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Info Card */}
|
||
{status && (
|
||
<div className={`${phaseConfig?.bgColor} border ${phaseConfig?.borderColor} rounded-lg p-6`}>
|
||
<h3 className={`font-semibold ${phaseConfig?.textColor} mb-3 flex items-center`}>
|
||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||
</svg>
|
||
{status.current_phase === 'initialization' && '初始化階段說明'}
|
||
{status.current_phase === 'operational' && '營運階段說明'}
|
||
{status.current_phase === 'transition' && '移轉階段說明'}
|
||
</h3>
|
||
<ul className={`space-y-2 text-sm ${phaseConfig?.textColor}`}>
|
||
{status.current_phase === 'initialization' && (
|
||
<>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>環境配置會同時寫入 .env 檔案和資料庫,確保一致性</span>
|
||
</li>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>每個環境都會進行連線測試,確保設定正確後才會儲存</span>
|
||
</li>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>完成所有環境配置後,才能進入營運階段</span>
|
||
</li>
|
||
</>
|
||
)}
|
||
{status.current_phase === 'operational' && (
|
||
<>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>系統正常運作中,可定期執行健康檢查確保服務穩定</span>
|
||
</li>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>健康檢查會驗證 Redis、Database、Keycloak 等服務狀態</span>
|
||
</li>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>需要進行系統遷移時,請先切換到 Transition 階段</span>
|
||
</li>
|
||
</>
|
||
)}
|
||
{status.current_phase === 'transition' && (
|
||
<>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>Transition 階段用於系統遷移或環境變更</span>
|
||
</li>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>請執行一致性檢查,確保 .env 檔案與資料庫配置同步</span>
|
||
</li>
|
||
<li className="flex items-start">
|
||
<span className="mr-2">•</span>
|
||
<span>只有通過一致性檢查後,才能返回營運階段</span>
|
||
</li>
|
||
</>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|