feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage

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>
This commit is contained in:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
/**
* 麵包屑導航組件
*/
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
interface BreadcrumbItem {
name: string
href?: string
}
export function Breadcrumb() {
const pathname = usePathname()
// 定義路徑對應的顯示名稱
const pathMap: Record<string, string> = {
dashboard: '系統首頁',
employees: '員工管理',
new: '新增員工',
edit: '編輯員工',
departments: '部門管理',
organization: '組織架構',
'system-functions': '系統功能管理',
'company-info': '公司資料維護',
}
// 解析路徑生成麵包屑
const generateBreadcrumbs = (): BreadcrumbItem[] => {
const paths = pathname.split('/').filter((path) => path)
const breadcrumbs: BreadcrumbItem[] = []
paths.forEach((path, index) => {
// 跳過數字 ID (例如 /employees/123)
if (/^\d+$/.test(path)) {
breadcrumbs.push({
name: `#${path}`,
href: paths.slice(0, index + 1).join('/'),
})
return
}
const name = pathMap[path] || path
const href = '/' + paths.slice(0, index + 1).join('/')
breadcrumbs.push({ name, href })
})
return breadcrumbs
}
const breadcrumbs = generateBreadcrumbs()
// 如果只有一層(例如只在 /dashboard),不顯示麵包屑
if (breadcrumbs.length <= 1) {
return null
}
return (
<nav className="flex mb-6" aria-label="Breadcrumb">
<ol className="inline-flex items-center space-x-1 md:space-x-3">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1
return (
<li key={crumb.href || crumb.name} className="inline-flex items-center">
{index > 0 && (
<svg
className="w-4 h-4 text-gray-400 mx-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
)}
{isLast ? (
<span className="text-sm font-medium text-gray-500">{crumb.name}</span>
) : (
<Link
href={crumb.href!}
className="text-sm font-medium text-gray-700 hover:text-blue-600 transition-colors"
>
{crumb.name}
</Link>
)}
</li>
)
})}
</ol>
</nav>
)
}