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:
428
frontend/app/system-functions/FunctionFormModal.tsx
Normal file
428
frontend/app/system-functions/FunctionFormModal.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import apiClient from '@/lib/api-client'
|
||||
import AlertDialog from '@/components/ui/AlertDialog'
|
||||
|
||||
interface SystemFunction {
|
||||
id?: number
|
||||
code: string
|
||||
name: string
|
||||
function_type: number
|
||||
upper_function_id: number
|
||||
order: number
|
||||
function_icon: string
|
||||
module_code: string | null
|
||||
module_functions: string[]
|
||||
description: string
|
||||
is_mana: boolean
|
||||
is_active: boolean
|
||||
edit_by: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
function: SystemFunction | null
|
||||
onClose: () => void
|
||||
onSave: (isEdit: boolean) => void
|
||||
}
|
||||
|
||||
const AVAILABLE_OPERATIONS = [
|
||||
{ value: 'View', label: 'View' },
|
||||
{ value: 'Create', label: 'Create' },
|
||||
{ value: 'Read', label: 'Read' },
|
||||
{ value: 'Update', label: 'Update' },
|
||||
{ value: 'Delete', label: 'Delete' },
|
||||
{ value: 'Print', label: 'Print' },
|
||||
{ value: 'File', label: 'File' },
|
||||
]
|
||||
|
||||
export default function FunctionFormModal({ function: editingFunction, onClose, onSave }: Props) {
|
||||
const [formData, setFormData] = useState<SystemFunction>({
|
||||
code: '',
|
||||
name: '',
|
||||
function_type: 2,
|
||||
upper_function_id: 0,
|
||||
order: 10,
|
||||
function_icon: '',
|
||||
module_code: null,
|
||||
module_functions: [],
|
||||
description: '',
|
||||
is_mana: false,
|
||||
is_active: true,
|
||||
edit_by: 1,
|
||||
})
|
||||
|
||||
const [parentFunctions, setParentFunctions] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 對話框狀態
|
||||
const [alertDialog, setAlertDialog] = useState<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
type: 'info' | 'warning' | 'error' | 'success'
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'info',
|
||||
})
|
||||
|
||||
// 載入上層功能選項 (只顯示 function_type=1 的 NODE)
|
||||
useEffect(() => {
|
||||
const loadParentFunctions = async () => {
|
||||
try {
|
||||
const response: any = await apiClient.get('/system-functions?function_type=1&page_size=100')
|
||||
setParentFunctions(response.items || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load parent functions:', error)
|
||||
}
|
||||
}
|
||||
loadParentFunctions()
|
||||
}, [])
|
||||
|
||||
// 如果是編輯模式,填入現有資料
|
||||
useEffect(() => {
|
||||
if (editingFunction) {
|
||||
setFormData(editingFunction)
|
||||
}
|
||||
}, [editingFunction])
|
||||
|
||||
const handleChange = (field: keyof SystemFunction, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleOperationToggle = (operation: string) => {
|
||||
const currentOperations = formData.module_functions || []
|
||||
const newOperations = currentOperations.includes(operation)
|
||||
? currentOperations.filter(op => op !== operation)
|
||||
: [...currentOperations, operation]
|
||||
|
||||
setFormData(prev => ({ ...prev, module_functions: newOperations }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// 驗證
|
||||
if (!formData.code || !formData.name) {
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '欄位驗證',
|
||||
message: '請填寫功能代碼和名稱',
|
||||
type: 'warning',
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.function_type === 2 && !formData.module_code) {
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '欄位驗證',
|
||||
message: 'FUNCTION 類型需要填寫模組代碼',
|
||||
type: 'warning',
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 準備資料
|
||||
const submitData = {
|
||||
...formData,
|
||||
module_code: formData.function_type === 1 ? null : formData.module_code,
|
||||
module_functions: formData.function_type === 1 ? [] : formData.module_functions,
|
||||
}
|
||||
|
||||
const isEdit = !!editingFunction
|
||||
|
||||
if (isEdit) {
|
||||
// 更新
|
||||
await apiClient.put(`/system-functions/${editingFunction.id}`, submitData)
|
||||
} else {
|
||||
// 新增
|
||||
await apiClient.post('/system-functions', submitData)
|
||||
}
|
||||
|
||||
onSave(isEdit)
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save function:', error)
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '儲存失敗',
|
||||
message: error.response?.data?.detail || '儲存失敗,請稍後再試',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 animate-fadeIn"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden animate-slideIn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Card Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-5 py-3.5 flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{formData.name || '系統功能'} - {editingFunction ? '編輯作業' : '新增作業'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-blue-100 transition-colors"
|
||||
aria-label="關閉"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Body - Scrollable */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||
<form onSubmit={handleSubmit} className="p-6">
|
||||
{/* Row 1: 功能代碼 + 功能名稱 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
功能代碼 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={(e) => handleChange('code', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="例如: dashboard"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
功能名稱 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="例如: 系統首頁"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 功能類型 + 上層功能 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
功能類型 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value={1}
|
||||
checked={formData.function_type === 1}
|
||||
onChange={(e) => handleChange('function_type', parseInt(e.target.value))}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="px-2 py-1 bg-purple-100 text-purple-800 rounded text-xs font-medium">NODE</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value={2}
|
||||
checked={formData.function_type === 2}
|
||||
onChange={(e) => handleChange('function_type', parseInt(e.target.value))}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-medium">FUNCTION</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
上層功能
|
||||
</label>
|
||||
<select
|
||||
value={formData.upper_function_id}
|
||||
onChange={(e) => handleChange('upper_function_id', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
>
|
||||
<option value={0}>根層 (無上層)</option>
|
||||
{parentFunctions.map(func => (
|
||||
<option key={func.id} value={func.id}>
|
||||
{func.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 順序 + Emoji 圖示 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
順序 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.order}
|
||||
onChange={(e) => handleChange('order', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Emoji 圖示
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.function_icon}
|
||||
onChange={(e) => handleChange('function_icon', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="📊"
|
||||
/>
|
||||
{formData.function_icon && (
|
||||
<span className="text-2xl">{formData.function_icon}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: 模組代碼 (只在 FUNCTION 時顯示) */}
|
||||
{formData.function_type === 2 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
模組代碼 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.module_code || ''}
|
||||
onChange={(e) => handleChange('module_code', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
placeholder="例如: dashboard"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模組操作權限 (橫置) */}
|
||||
{formData.function_type === 2 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
模組操作權限
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{AVAILABLE_OPERATIONS.map(op => (
|
||||
<label
|
||||
key={op.value}
|
||||
className={`px-3 py-2 rounded-lg border-2 cursor-pointer transition-all text-sm ${
|
||||
formData.module_functions.includes(op.value)
|
||||
? 'bg-blue-50 border-blue-500 text-blue-700'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.module_functions.includes(op.value)}
|
||||
onChange={() => handleOperationToggle(op.value)}
|
||||
className="mr-2"
|
||||
/>
|
||||
{op.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 說明 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
說明
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 placeholder:text-gray-500"
|
||||
rows={2}
|
||||
placeholder="功能說明..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 5: 系統管理 + 啟用 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_mana}
|
||||
onChange={(e) => handleChange('is_mana', e.target.checked)}
|
||||
className="mr-2 w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">系統管理功能</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => handleChange('is_active', e.target.checked)}
|
||||
className="mr-2 w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">啟用</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Card Footer */}
|
||||
<div className="border-t bg-gray-50 px-5 py-2.5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors text-sm font-medium"
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 text-sm font-medium"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '儲存中...' : '儲存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Alert Dialog */}
|
||||
<AlertDialog
|
||||
isOpen={alertDialog.isOpen}
|
||||
title={alertDialog.title}
|
||||
message={alertDialog.message}
|
||||
type={alertDialog.type}
|
||||
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
frontend/app/system-functions/layout.tsx
Normal file
53
frontend/app/system-functions/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* System Functions 佈局
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { Sidebar } from '@/components/layout/sidebar'
|
||||
import { Breadcrumb } from '@/components/layout/breadcrumb'
|
||||
|
||||
export default function SystemFunctionsLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/auth/signin')
|
||||
}
|
||||
}, [status, router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">載入中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 flex-shrink-0">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-8">
|
||||
<Breadcrumb />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
600
frontend/app/system-functions/page.tsx
Normal file
600
frontend/app/system-functions/page.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import apiClient from '@/lib/api-client'
|
||||
import FunctionFormModal from './FunctionFormModal'
|
||||
import AlertDialog from '@/components/ui/AlertDialog'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
|
||||
interface SystemFunction {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
function_type: number // 1=NODE, 2=FUNCTION
|
||||
upper_function_id: number
|
||||
order: number
|
||||
function_icon: string
|
||||
module_code: string | null
|
||||
module_functions: string[]
|
||||
description: string
|
||||
is_mana: boolean
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
interface ParentFunction {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function SystemFunctionsPage() {
|
||||
const { data: session } = useSession()
|
||||
const [allFunctions, setAllFunctions] = useState<SystemFunction[]>([]) // 所有資料
|
||||
const [functions, setFunctions] = useState<SystemFunction[]>([]) // 當前頁面顯示的資料
|
||||
const [parentFunctions, setParentFunctions] = useState<ParentFunction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingFunction, setEditingFunction] = useState<SystemFunction | null>(null)
|
||||
|
||||
// 對話框狀態
|
||||
const [alertDialog, setAlertDialog] = useState<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
type: 'info' | 'warning' | 'error' | 'success'
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'info',
|
||||
})
|
||||
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
type: 'info' | 'warning' | 'error' | 'success'
|
||||
onConfirm: () => void
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'warning',
|
||||
onConfirm: () => {},
|
||||
})
|
||||
|
||||
// 篩選條件
|
||||
const [filterType, setFilterType] = useState<string>('')
|
||||
const [filterActive, setFilterActive] = useState<string>('')
|
||||
const [filterParent, setFilterParent] = useState<string>('')
|
||||
|
||||
// 分頁與排序
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(5)
|
||||
const [sortField, setSortField] = useState<'order' | 'id'>('order')
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
// 載入功能列表
|
||||
const loadFunctions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
console.log('[SystemFunctions] Loading functions...')
|
||||
|
||||
// 建立查詢參數 - 不使用 URLSearchParams,直接建立 query string
|
||||
let queryParts = ['page_size=100'] // 後端限制最大 100
|
||||
|
||||
if (filterType) {
|
||||
queryParts.push(`function_type=${filterType}`)
|
||||
}
|
||||
|
||||
// is_active 特殊處理:空字串表示不篩選,所以不加這個參數
|
||||
if (filterActive) {
|
||||
queryParts.push(`is_active=${filterActive}`)
|
||||
} else {
|
||||
// 不傳 is_active 參數,讓後端使用預設值或不篩選
|
||||
// 為了顯示所有資料(包含停用),我們明確傳 is_active=None 或不傳
|
||||
}
|
||||
|
||||
if (filterParent) {
|
||||
queryParts.push(`upper_function_id=${filterParent}`)
|
||||
}
|
||||
|
||||
const queryString = queryParts.join('&')
|
||||
console.log('[SystemFunctions] Query:', queryString)
|
||||
|
||||
const response: any = await apiClient.get(`/system-functions?${queryString}`)
|
||||
console.log('[SystemFunctions] Response:', response)
|
||||
console.log('[SystemFunctions] Items count:', response.items?.length || 0)
|
||||
setAllFunctions(response.items || [])
|
||||
setCurrentPage(1) // 重置到第一頁
|
||||
} catch (error) {
|
||||
console.error('[SystemFunctions] Failed to load functions:', error)
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '載入失敗',
|
||||
message: `載入系統功能失敗: ${(error as any)?.message || '未知錯誤'}`,
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 載入上層功能選項 (用於篩選)
|
||||
const loadParentFunctions = async () => {
|
||||
try {
|
||||
const response: any = await apiClient.get('/system-functions?function_type=1&page_size=100')
|
||||
const parents = response.items || []
|
||||
setParentFunctions([{ id: 0, code: 'root', name: '根層 (無上層)' }, ...parents])
|
||||
} catch (error) {
|
||||
console.error('Failed to load parent functions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
loadParentFunctions()
|
||||
loadFunctions()
|
||||
}
|
||||
}, [session])
|
||||
|
||||
// 當篩選條件改變時重新載入
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
loadFunctions()
|
||||
}
|
||||
}, [filterType, filterActive, filterParent])
|
||||
|
||||
// 排序和分頁處理
|
||||
useEffect(() => {
|
||||
// 排序
|
||||
const sorted = [...allFunctions].sort((a, b) => {
|
||||
const aValue = a[sortField]
|
||||
const bValue = b[sortField]
|
||||
|
||||
if (sortDirection === 'asc') {
|
||||
return aValue > bValue ? 1 : -1
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1
|
||||
}
|
||||
})
|
||||
|
||||
// 分頁
|
||||
const startIndex = (currentPage - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
const paginated = sorted.slice(startIndex, endIndex)
|
||||
|
||||
setFunctions(paginated)
|
||||
}, [allFunctions, currentPage, pageSize, sortField, sortDirection])
|
||||
|
||||
// 排序處理
|
||||
const handleSort = (field: 'order' | 'id') => {
|
||||
if (sortField === field) {
|
||||
// 切換排序方向
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
// 新欄位,預設升序
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
// 計算總頁數
|
||||
const totalPages = Math.ceil(allFunctions.length / pageSize)
|
||||
|
||||
// 切換頁碼
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
// 產生頁碼按鈕
|
||||
const renderPageNumbers = () => {
|
||||
const pages = []
|
||||
const maxVisiblePages = 5
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2))
|
||||
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
|
||||
|
||||
if (endPage - startPage < maxVisiblePages - 1) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1)
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handlePageChange(i)}
|
||||
className={`px-3 py-1 rounded ${
|
||||
i === currentPage
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100'
|
||||
} border border-gray-300`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// 新增功能
|
||||
const handleCreate = () => {
|
||||
setEditingFunction(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
// 編輯功能
|
||||
const handleEdit = (func: SystemFunction) => {
|
||||
setEditingFunction(func)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
// 刪除功能 (軟刪除)
|
||||
const handleDelete = (id: number) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: '確認刪除',
|
||||
message: '確定要刪除此功能嗎?刪除後將無法復原。',
|
||||
type: 'warning',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await apiClient.delete(`/system-functions/${id}`)
|
||||
loadFunctions()
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '刪除成功',
|
||||
message: '功能已成功刪除',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to delete function:', error)
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '刪除失敗',
|
||||
message: '刪除功能時發生錯誤,請稍後再試',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
setConfirmDialog({ ...confirmDialog, isOpen: false })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 切換啟用狀態
|
||||
const handleToggleActive = async (func: SystemFunction) => {
|
||||
try {
|
||||
await apiClient.patch(`/system-functions/${func.id}`, {
|
||||
is_active: !func.is_active,
|
||||
edit_by: 1 // TODO: 從 session 取得當前用戶 ID
|
||||
})
|
||||
loadFunctions()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle active status:', error)
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '操作失敗',
|
||||
message: '切換狀態失敗,請稍後再試',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-500">載入中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">🎯 系統功能設定</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
管理系統功能列表,包含功能代碼、名稱、圖示等設定
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ 新增功能
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 篩選條件 */}
|
||||
<div className="mb-4 flex gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">類型</label>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
|
||||
>
|
||||
<option value="" className="text-gray-900">全部</option>
|
||||
<option value="1" className="text-gray-900">NODE (分類)</option>
|
||||
<option value="2" className="text-gray-900">FUNCTION (功能)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">啟用狀態</label>
|
||||
<select
|
||||
value={filterActive}
|
||||
onChange={(e) => setFilterActive(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
|
||||
>
|
||||
<option value="" className="text-gray-900">全部</option>
|
||||
<option value="true" className="text-gray-900">啟用</option>
|
||||
<option value="false" className="text-gray-900">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">上層項目</label>
|
||||
<select
|
||||
value={filterParent}
|
||||
onChange={(e) => setFilterParent(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
|
||||
>
|
||||
<option value="" className="text-gray-900">全部</option>
|
||||
{parentFunctions.map(parent => (
|
||||
<option key={parent.id} value={parent.id} className="text-gray-900">
|
||||
{parent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(filterType || filterActive || filterParent) && (
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterType('')
|
||||
setFilterActive('')
|
||||
setFilterParent('')
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
清除篩選
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 每頁筆數選擇 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-700">每頁顯示</label>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
className="px-3 py-1 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm text-gray-900 bg-white"
|
||||
>
|
||||
<option value={5} className="text-gray-900">5</option>
|
||||
<option value={10} className="text-gray-900">10</option>
|
||||
<option value={15} className="text-gray-900">15</option>
|
||||
<option value={20} className="text-gray-900">20</option>
|
||||
<option value={25} className="text-gray-900">25</option>
|
||||
<option value={50} className="text-gray-900">50</option>
|
||||
</select>
|
||||
<span className="text-sm text-gray-700">筆</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
共 {allFunctions.length} 筆資料
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-blue-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
|
||||
圖示
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
|
||||
功能代碼
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider">
|
||||
功能名稱
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-sm font-medium text-white uppercase tracking-wider cursor-pointer hover:bg-blue-800"
|
||||
onClick={() => handleSort('order')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
順序
|
||||
{sortField === 'order' && (
|
||||
<span className="text-yellow-300">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-sm font-medium text-white uppercase tracking-wider">
|
||||
啟用
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-sm font-medium text-white uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{functions.map((func) => (
|
||||
<tr key={func.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{func.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-xl">
|
||||
{func.function_icon}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
{func.code}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{func.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{func.order}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<button
|
||||
onClick={() => handleToggleActive(func)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors cursor-pointer ${
|
||||
func.is_active
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{func.is_active ? '是' : '否'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center text-sm font-medium space-x-3">
|
||||
<button
|
||||
onClick={() => handleEdit(func)}
|
||||
className="px-3 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(func.id)}
|
||||
className="px-3 py-1 border border-red-600 text-red-600 rounded hover:bg-red-50 transition-colors"
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{allFunctions.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
尚無功能資料
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分頁控制 */}
|
||||
{allFunctions.length > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
顯示第 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, allFunctions.length)} 筆,
|
||||
共 {allFunctions.length} 筆
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 第一頁 */}
|
||||
<button
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
««
|
||||
</button>
|
||||
|
||||
{/* 上一頁 */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
|
||||
{/* 頁碼 */}
|
||||
{renderPageNumbers()}
|
||||
|
||||
{/* 下一頁 */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
|
||||
{/* 最後一頁 */}
|
||||
<button
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 rounded bg-white text-gray-700 border border-gray-300 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
»»
|
||||
</button>
|
||||
|
||||
{/* 跳頁 */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<span className="text-sm text-gray-700">跳至</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={currentPage}
|
||||
onChange={(e) => {
|
||||
const page = Number(e.target.value)
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
handlePageChange(page)
|
||||
}
|
||||
}}
|
||||
className="w-16 px-2 py-1 border border-gray-300 rounded text-center text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">頁</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<FunctionFormModal
|
||||
function={editingFunction}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={(isEdit: boolean) => {
|
||||
setShowModal(false)
|
||||
loadFunctions()
|
||||
setAlertDialog({
|
||||
isOpen: true,
|
||||
title: '儲存成功',
|
||||
message: `功能已成功${isEdit ? '更新' : '新增'}`,
|
||||
type: 'success',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Alert Dialog */}
|
||||
<AlertDialog
|
||||
isOpen={alertDialog.isOpen}
|
||||
title={alertDialog.title}
|
||||
message={alertDialog.message}
|
||||
type={alertDialog.type}
|
||||
onConfirm={() => setAlertDialog({ ...alertDialog, isOpen: false })}
|
||||
/>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
type={confirmDialog.type}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user