883 lines
34 KiB
TypeScript
883 lines
34 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useMemo } from 'react'
|
||
import { parseFetchError, handleError } from '@/lib/error-handler'
|
||
|
||
interface DepartmentPermission {
|
||
department: string
|
||
materialType: string
|
||
canView: boolean
|
||
canDownload: boolean
|
||
canShare: boolean
|
||
linkedToDepartment: string | null
|
||
}
|
||
|
||
interface Department {
|
||
id: string
|
||
name: string
|
||
parentId?: string | null
|
||
code?: string | null
|
||
sortOrder?: number
|
||
}
|
||
|
||
interface DepartmentPermissionsTabProps {
|
||
showToast: (message: string, type?: 'success' | 'error') => void
|
||
departments: Array<{ id: string; name: string; parentId?: string | null; code?: string | null; sortOrder?: number }>
|
||
}
|
||
|
||
export default function DepartmentPermissionsTab({
|
||
showToast,
|
||
departments,
|
||
}: DepartmentPermissionsTabProps) {
|
||
const [loading, setLoading] = useState(true)
|
||
const [searchKeyword, setSearchKeyword] = useState('')
|
||
const [expandedDepartments, setExpandedDepartments] = useState<Set<string>>(new Set())
|
||
const [permissions, setPermissions] = useState<Record<string, Record<string, DepartmentPermission>>>({})
|
||
const [editedPermissions, setEditedPermissions] = useState<Record<string, Record<string, DepartmentPermission>>>({})
|
||
const [dirtyDepartments, setDirtyDepartments] = useState<Set<string>>(new Set())
|
||
const [linkedDepartments, setLinkedDepartments] = useState<Record<string, string>>({})
|
||
const [hasCustomPermissions, setHasCustomPermissions] = useState<Record<string, boolean>>({})
|
||
const [materialTypes, setMaterialTypes] = useState<string[]>([])
|
||
const [savingDept, setSavingDept] = useState<string | null>(null)
|
||
|
||
// 对话框状态
|
||
const [showCopyDialog, setShowCopyDialog] = useState(false)
|
||
const [copySourceDept, setCopySourceDept] = useState<string>('')
|
||
const [selectedCopyTargets, setSelectedCopyTargets] = useState<string[]>([])
|
||
|
||
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||
const [linkTargetDept, setLinkTargetDept] = useState<string>('')
|
||
const [selectedLinkSource, setSelectedLinkSource] = useState<string>('')
|
||
|
||
// 构建部门树形结构(与部门管理和用户管理逻辑统一)
|
||
const departmentTree = useMemo(() => {
|
||
// 找出所有顶级部门(没有parentId或parentId不在列表中)
|
||
const topLevelDepts = departments.filter(dept => !dept.parentId || !departments.find(d => d.id === dept.parentId))
|
||
|
||
// 构建子部门映射
|
||
const childrenMap = new Map<string, Department[]>()
|
||
departments.forEach(dept => {
|
||
if (dept.parentId) {
|
||
if (!childrenMap.has(dept.parentId)) {
|
||
childrenMap.set(dept.parentId, [])
|
||
}
|
||
childrenMap.get(dept.parentId)!.push(dept as Department)
|
||
}
|
||
})
|
||
|
||
// 排序子部门
|
||
childrenMap.forEach((children) => {
|
||
children.sort((a, b) => {
|
||
const sortA = a.sortOrder ?? 0
|
||
const sortB = b.sortOrder ?? 0
|
||
if (sortA !== sortB) return sortA - sortB
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
})
|
||
|
||
return { topLevelDepts: topLevelDepts as Department[], childrenMap }
|
||
}, [departments])
|
||
|
||
useEffect(() => {
|
||
fetchPermissions()
|
||
}, [])
|
||
|
||
const fetchPermissions = async () => {
|
||
try {
|
||
setLoading(true)
|
||
const url = searchKeyword
|
||
? `/api/admin/department-permissions?search=${encodeURIComponent(searchKeyword)}`
|
||
: '/api/admin/department-permissions'
|
||
const response = await fetch(url)
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setPermissions(data.permissions || {})
|
||
setEditedPermissions(data.permissions || {})
|
||
setDirtyDepartments(new Set())
|
||
setLinkedDepartments(data.linkedDepartments || {})
|
||
setHasCustomPermissions(data.hasCustomPermissions || {})
|
||
setMaterialTypes(data.materialTypes || [])
|
||
} else {
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching permissions:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, 'error')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const toggleDepartment = (deptId: string) => {
|
||
setExpandedDepartments((prev) => {
|
||
const newSet = new Set(prev)
|
||
if (newSet.has(deptId)) {
|
||
newSet.delete(deptId)
|
||
} else {
|
||
newSet.add(deptId)
|
||
}
|
||
return newSet
|
||
})
|
||
}
|
||
|
||
const markDirty = (department: string) => {
|
||
setDirtyDepartments((prev) => {
|
||
const next = new Set(prev)
|
||
next.add(department)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const buildPermission = (
|
||
department: string,
|
||
materialType: string,
|
||
base?: DepartmentPermission
|
||
): DepartmentPermission => {
|
||
return (
|
||
base || {
|
||
department,
|
||
materialType,
|
||
canView: false,
|
||
canDownload: false,
|
||
canShare: true,
|
||
linkedToDepartment: linkedDepartments[department] || null,
|
||
}
|
||
)
|
||
}
|
||
|
||
const updateLocalPermission = (
|
||
department: string,
|
||
materialType: string,
|
||
field: 'canView' | 'canDownload' | 'canShare',
|
||
value: boolean
|
||
) => {
|
||
setEditedPermissions((prev) => {
|
||
const deptPerms = { ...(prev[department] || permissions[department] || {}) }
|
||
const current = buildPermission(department, materialType, deptPerms[materialType])
|
||
deptPerms[materialType] = { ...current, [field]: value }
|
||
return { ...prev, [department]: deptPerms }
|
||
})
|
||
markDirty(department)
|
||
}
|
||
|
||
const handleToggleAll = (
|
||
department: string,
|
||
field: 'canView' | 'canDownload' | 'canShare',
|
||
value: boolean
|
||
) => {
|
||
if (linkedDepartments[department]) return
|
||
setEditedPermissions((prev) => {
|
||
const deptPerms = { ...(prev[department] || permissions[department] || {}) }
|
||
materialTypes.forEach((mt) => {
|
||
const current = buildPermission(department, mt, deptPerms[mt])
|
||
deptPerms[mt] = { ...current, [field]: value }
|
||
})
|
||
return { ...prev, [department]: deptPerms }
|
||
})
|
||
markDirty(department)
|
||
}
|
||
|
||
const handleSaveDepartment = async (department: string) => {
|
||
if (linkedDepartments[department]) {
|
||
showToast('已关联的部门不可单独保存,请先取消关联', 'error')
|
||
return
|
||
}
|
||
if (!dirtyDepartments.has(department)) {
|
||
showToast('没有需要保存的更改', 'error')
|
||
return
|
||
}
|
||
const deptPerms = editedPermissions[department] || {}
|
||
const payload = materialTypes.map((mt) => {
|
||
const perm = buildPermission(department, mt, deptPerms[mt])
|
||
return {
|
||
materialType: mt,
|
||
canView: perm.canView,
|
||
canDownload: perm.canDownload,
|
||
canShare: perm.canShare,
|
||
}
|
||
})
|
||
|
||
try {
|
||
setSavingDept(department)
|
||
const response = await fetch('/api/admin/department-permissions/batch', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
department,
|
||
permissions: payload,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
showToast('已保存')
|
||
await fetchPermissions()
|
||
} else {
|
||
const error = await response.json()
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving permissions:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, 'error')
|
||
} finally {
|
||
setSavingDept(null)
|
||
}
|
||
}
|
||
|
||
const handleCopyPermissions = (sourceDept: string) => {
|
||
const targetDepts = departments
|
||
.map((d) => d.name)
|
||
.filter((name) => name !== sourceDept)
|
||
|
||
if (targetDepts.length === 0) {
|
||
showToast('没有可复制的目标部门', 'error')
|
||
return
|
||
}
|
||
|
||
setCopySourceDept(sourceDept)
|
||
setSelectedCopyTargets([])
|
||
setShowCopyDialog(true)
|
||
}
|
||
|
||
const handleConfirmCopy = async () => {
|
||
if (selectedCopyTargets.length === 0) {
|
||
showToast('请至少选择一个目标部门', 'error')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/department-permissions/copy', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
sourceDepartment: copySourceDept,
|
||
targetDepartments: selectedCopyTargets,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
await fetchPermissions()
|
||
showToast('权限复制成功')
|
||
setShowCopyDialog(false)
|
||
} else {
|
||
const error = await response.json()
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error copying permissions:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, 'error')
|
||
}
|
||
}
|
||
|
||
const handleLinkPermissions = (targetDept: string) => {
|
||
const sourceDepts = departments
|
||
.map((d) => d.name)
|
||
.filter((name) => name !== targetDept)
|
||
|
||
if (sourceDepts.length === 0) {
|
||
showToast('没有可关联的源部门', 'error')
|
||
return
|
||
}
|
||
|
||
setLinkTargetDept(targetDept)
|
||
setSelectedLinkSource('')
|
||
setShowLinkDialog(true)
|
||
}
|
||
|
||
const handleConfirmLink = async () => {
|
||
if (!selectedLinkSource) {
|
||
showToast('请选择一个源部门', 'error')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/department-permissions/link', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
targetDepartment: linkTargetDept,
|
||
sourceDepartment: selectedLinkSource,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
await fetchPermissions()
|
||
showToast('权限关联成功')
|
||
setShowLinkDialog(false)
|
||
} else {
|
||
const error = await response.json()
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error linking permissions:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, 'error')
|
||
}
|
||
}
|
||
|
||
const handleUnlinkPermissions = async (department: string) => {
|
||
const confirmed = window.confirm(`确定要取消"${department}"的权限关联吗?`)
|
||
|
||
if (!confirmed) return
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/admin/department-permissions/link?department=${encodeURIComponent(department)}`,
|
||
{ method: 'DELETE' }
|
||
)
|
||
|
||
if (response.ok) {
|
||
await fetchPermissions()
|
||
showToast('已取消关联')
|
||
} else {
|
||
const error = await response.json()
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error unlinking permissions:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, 'error')
|
||
}
|
||
}
|
||
|
||
// 获取父部门名称
|
||
const getParentDepartmentName = (deptName: string): string | null => {
|
||
const dept = departments.find((d) => d.name === deptName)
|
||
if (!dept?.parentId) return null
|
||
const parentDept = departments.find((d) => d.id === dept.parentId)
|
||
return parentDept?.name || null
|
||
}
|
||
|
||
// 判断权限是否继承自父部门
|
||
const isInheritedFromParent = (deptName: string): boolean => {
|
||
// 如果已关联到其他部门,不是继承
|
||
if (linkedDepartments[deptName]) return false
|
||
// 如果有自定义权限配置,不是继承
|
||
if (hasCustomPermissions[deptName]) return false
|
||
// 如果有父部门,且没有自定义配置,则是继承
|
||
return !!getParentDepartmentName(deptName)
|
||
}
|
||
|
||
// 使用父部门权限(删除自定义配置,恢复继承)
|
||
const handleUseParentPermissions = async (department: string) => {
|
||
const parentDept = getParentDepartmentName(department)
|
||
if (!parentDept) {
|
||
showToast('该部门没有父部门,无法使用父部门权限', 'error')
|
||
return
|
||
}
|
||
|
||
const confirmed = window.confirm(
|
||
`确定要将"${department}"的权限还原为继承自"${parentDept}"吗?\n这将删除所有自定义权限配置。`
|
||
)
|
||
|
||
if (!confirmed) return
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`/api/admin/department-permissions/inherit?department=${encodeURIComponent(department)}`,
|
||
{ method: 'DELETE' }
|
||
)
|
||
|
||
if (response.ok) {
|
||
await fetchPermissions()
|
||
showToast('已恢复为继承父部门权限')
|
||
} else {
|
||
const error = await response.json()
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error using parent permissions:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, 'error')
|
||
}
|
||
}
|
||
|
||
// 创建独立配置(基于父部门权限)
|
||
const handleCreateIndependentPermissions = async (department: string) => {
|
||
const parentDept = getParentDepartmentName(department)
|
||
if (!parentDept) {
|
||
showToast('该部门没有父部门,无法基于父部门创建配置', 'error')
|
||
return
|
||
}
|
||
|
||
if (hasCustomPermissions[department]) {
|
||
showToast('该部门已有自定义权限配置', 'error')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/department-permissions/inherit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ department }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
await fetchPermissions()
|
||
showToast('已创建独立配置,可以开始编辑')
|
||
} else {
|
||
const error = await response.json()
|
||
const parsedError = await parseFetchError(response)
|
||
showToast(parsedError.message, 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating independent permissions:', error)
|
||
const { message } = handleError(error)
|
||
showToast(message, 'error')
|
||
}
|
||
}
|
||
|
||
// 递归检查部门或其子部门是否匹配搜索关键词
|
||
const hasMatchingDepartment = (dept: Department): boolean => {
|
||
if (searchKeyword && dept.name.toLowerCase().includes(searchKeyword.toLowerCase())) {
|
||
return true
|
||
}
|
||
const children = departmentTree.childrenMap.get(dept.id) || []
|
||
return children.some(hasMatchingDepartment)
|
||
}
|
||
|
||
// 递归渲染部门树
|
||
const renderDepartmentNode = (dept: Department, level: number = 0): React.ReactNode | null => {
|
||
const children = departmentTree.childrenMap.get(dept.id) || []
|
||
const hasChildren = children.length > 0
|
||
const isExpanded = expandedDepartments.has(dept.id)
|
||
const deptName = dept.name
|
||
const linkedTo = linkedDepartments[deptName]
|
||
const deptPermissions = editedPermissions[deptName] || {}
|
||
const isDirty = dirtyDepartments.has(deptName)
|
||
const indent = level * 24
|
||
|
||
// 如果有关键词搜索,检查是否匹配(包括子部门)
|
||
if (searchKeyword && !hasMatchingDepartment(dept)) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div key={dept.id}>
|
||
<div
|
||
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
|
||
style={{ marginLeft: level > 0 ? '16px' : '0', marginTop: level > 0 ? '8px' : '0' }}
|
||
>
|
||
<div
|
||
className="px-4 py-3 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center justify-between"
|
||
style={{ paddingLeft: `${indent + 16}px` }}
|
||
onClick={() => toggleDepartment(dept.id)}
|
||
>
|
||
<div className="flex items-center gap-2 flex-1">
|
||
{/* 展开/折叠图标 */}
|
||
{hasChildren ? (
|
||
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center">
|
||
<svg
|
||
className={`w-3 h-3 transform transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||
</svg>
|
||
</div>
|
||
) : (
|
||
<div className="w-5" />
|
||
)}
|
||
|
||
{/* 部门图标 */}
|
||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||
</svg>
|
||
|
||
<span className="font-medium text-gray-900 dark:text-white">{deptName}</span>
|
||
{dept.code && (
|
||
<span className="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||
{dept.code}
|
||
</span>
|
||
)}
|
||
{/* 权限状态显示 */}
|
||
{linkedTo ? (
|
||
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||
(🔗 关联到: {linkedTo})
|
||
</span>
|
||
) : isInheritedFromParent(deptName) ? (
|
||
<span className="text-xs text-green-600 dark:text-green-400">
|
||
(🔗 继承自: {getParentDepartmentName(deptName)})
|
||
</span>
|
||
) : hasCustomPermissions[deptName] ? (
|
||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||
(✏️ 自定义配置)
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="flex gap-2 flex-wrap" onClick={(e) => e.stopPropagation()}>
|
||
{/* 主要操作:权限继承相关 */}
|
||
{!linkedTo && (
|
||
<>
|
||
{isInheritedFromParent(deptName) ? (
|
||
<button
|
||
onClick={() => handleCreateIndependentPermissions(deptName)}
|
||
className="px-3 py-1 text-sm bg-purple-500 text-white rounded hover:bg-purple-600 transition-colors"
|
||
title="创建独立配置,基于父部门权限"
|
||
>
|
||
独立配置
|
||
</button>
|
||
) : hasCustomPermissions[deptName] ? (
|
||
<button
|
||
onClick={() => handleUseParentPermissions(deptName)}
|
||
className="px-3 py-1 text-sm bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors"
|
||
title="还原为继承父部门权限"
|
||
>
|
||
还原为继承
|
||
</button>
|
||
) : null}
|
||
</>
|
||
)}
|
||
|
||
{/* 高级操作:复制和关联 */}
|
||
{linkedTo ? (
|
||
<button
|
||
onClick={() => handleUnlinkPermissions(deptName)}
|
||
className="px-3 py-1 text-sm bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors"
|
||
title="取消关联"
|
||
>
|
||
取消关联
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={() => handleCopyPermissions(deptName)}
|
||
className="px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||
title="复制权限到其他部门"
|
||
>
|
||
复制到
|
||
</button>
|
||
<button
|
||
onClick={() => handleLinkPermissions(deptName)}
|
||
className="px-3 py-1 text-sm bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||
title="关联到其他部门"
|
||
>
|
||
关联到
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{isExpanded && (
|
||
<div className="p-4 bg-white dark:bg-gray-900">
|
||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||
<span className="text-sm text-gray-600 dark:text-gray-400">批量操作:</span>
|
||
<button
|
||
disabled={!!linkedTo}
|
||
onClick={() => handleToggleAll(deptName, 'canView', true)}
|
||
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
|
||
>
|
||
全选可见
|
||
</button>
|
||
<button
|
||
disabled={!!linkedTo}
|
||
onClick={() => handleToggleAll(deptName, 'canView', false)}
|
||
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
|
||
>
|
||
取消可见
|
||
</button>
|
||
<button
|
||
disabled={!!linkedTo}
|
||
onClick={() => handleToggleAll(deptName, 'canDownload', true)}
|
||
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
|
||
>
|
||
全选下载
|
||
</button>
|
||
<button
|
||
disabled={!!linkedTo}
|
||
onClick={() => handleToggleAll(deptName, 'canDownload', false)}
|
||
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
|
||
>
|
||
取消下载
|
||
</button>
|
||
<button
|
||
disabled={!!linkedTo}
|
||
onClick={() => handleToggleAll(deptName, 'canShare', true)}
|
||
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
|
||
>
|
||
全选分享
|
||
</button>
|
||
<button
|
||
disabled={!!linkedTo}
|
||
onClick={() => handleToggleAll(deptName, 'canShare', false)}
|
||
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
|
||
>
|
||
取消分享
|
||
</button>
|
||
<div className="flex-1" />
|
||
<button
|
||
disabled={!!linkedTo || !isDirty || savingDept === deptName}
|
||
onClick={() => handleSaveDepartment(deptName)}
|
||
className="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{savingDept === deptName ? '保存中...' : isDirty ? '保存更改' : '已保存'}
|
||
</button>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||
<th className="text-left py-2 px-3 text-gray-700 dark:text-gray-300">
|
||
素材类型
|
||
</th>
|
||
<th className="text-center py-2 px-3 text-gray-700 dark:text-gray-300">
|
||
可见
|
||
</th>
|
||
<th className="text-center py-2 px-3 text-gray-700 dark:text-gray-300">
|
||
下载
|
||
</th>
|
||
<th className="text-center py-2 px-3 text-gray-700 dark:text-gray-300">
|
||
分享
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{materialTypes.map((materialType) => {
|
||
const perm = deptPermissions[materialType]
|
||
const isLinked = !!linkedTo
|
||
const isInherited = isInheritedFromParent(deptName)
|
||
const canView = perm?.canView ?? false
|
||
const canDownload = perm?.canDownload ?? false
|
||
const canShare = perm?.canShare ?? true
|
||
const isDisabled = isLinked || isInherited
|
||
|
||
return (
|
||
<tr
|
||
key={materialType}
|
||
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||
>
|
||
<td className="py-2 px-3 text-gray-900 dark:text-gray-100">
|
||
<div className="flex items-center gap-2">
|
||
<span>{materialType}</span>
|
||
{isInherited && (
|
||
<span className="text-xs text-green-600 dark:text-green-400" title="继承自父部门">
|
||
🔗
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={canView}
|
||
disabled={isDisabled}
|
||
onChange={(e) =>
|
||
updateLocalPermission(deptName, materialType, 'canView', e.target.checked)
|
||
}
|
||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title={isDisabled ? (isLinked ? '已关联,无法修改' : '继承状态,请先独立配置') : ''}
|
||
/>
|
||
</td>
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={canDownload}
|
||
disabled={isDisabled}
|
||
onChange={(e) =>
|
||
updateLocalPermission(deptName, materialType, 'canDownload', e.target.checked)
|
||
}
|
||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title={isDisabled ? (isLinked ? '已关联,无法修改' : '继承状态,请先独立配置') : ''}
|
||
/>
|
||
</td>
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={canShare}
|
||
disabled={isDisabled}
|
||
onChange={(e) =>
|
||
updateLocalPermission(deptName, materialType, 'canShare', e.target.checked)
|
||
}
|
||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
title={isDisabled ? (isLinked ? '已关联,无法修改' : '继承状态,请先独立配置') : ''}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 递归渲染子部门 */}
|
||
{isExpanded && hasChildren && (
|
||
<div>
|
||
{children.map(child => renderDepartmentNode(child, level + 1))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="card-apple">
|
||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">加载中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="card-apple">
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">部门权限配置</h2>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||
配置各部门对不同素材类型的查看、下载、分享权限
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={searchKeyword}
|
||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter') {
|
||
fetchPermissions()
|
||
}
|
||
}}
|
||
placeholder="搜索部门名称..."
|
||
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
|
||
/>
|
||
<button
|
||
onClick={fetchPermissions}
|
||
className="px-4 py-2 bg-gray-900 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-600 transition-colors"
|
||
>
|
||
搜索
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-2">
|
||
{departmentTree.topLevelDepts.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||
{searchKeyword ? '未找到匹配的部门' : '暂无部门'}
|
||
</div>
|
||
) : (
|
||
departmentTree.topLevelDepts.map(dept => renderDepartmentNode(dept, 0))
|
||
)}
|
||
</div>
|
||
|
||
{/* 复制权限对话框 */}
|
||
{showCopyDialog && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||
复制权限配置
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||
将 <span className="font-medium">{copySourceDept}</span> 的权限配置复制到以下部门:
|
||
</p>
|
||
<div className="mb-4">
|
||
<div className="border border-gray-300 dark:border-gray-600 rounded-lg p-3 max-h-64 overflow-y-auto bg-white dark:bg-gray-900">
|
||
{departments
|
||
.filter((d) => d.name !== copySourceDept)
|
||
.map((dept) => (
|
||
<label
|
||
key={dept.id}
|
||
className="flex items-center py-2 px-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedCopyTargets.includes(dept.name)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedCopyTargets([...selectedCopyTargets, dept.name])
|
||
} else {
|
||
setSelectedCopyTargets(selectedCopyTargets.filter((n) => n !== dept.name))
|
||
}
|
||
}}
|
||
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||
/>
|
||
<span className="ml-2 text-gray-900 dark:text-white">{dept.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
onClick={() => setShowCopyDialog(false)}
|
||
className="px-4 py-2 text-sm rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleConfirmCopy}
|
||
className="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||
>
|
||
确定
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 关联权限对话框 */}
|
||
{showLinkDialog && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||
关联权限配置
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||
将 <span className="font-medium">{linkTargetDept}</span> 的权限关联到以下部门:
|
||
</p>
|
||
<div className="mb-4">
|
||
<select
|
||
value={selectedLinkSource}
|
||
onChange={(e) => setSelectedLinkSource(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none"
|
||
>
|
||
<option value="">请选择部门</option>
|
||
{departments
|
||
.filter((d) => d.name !== linkTargetDept)
|
||
.map((dept) => (
|
||
<option key={dept.id} value={dept.name}>
|
||
{dept.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||
<p className="text-xs text-blue-800 dark:text-blue-200">
|
||
💡 关联后,{linkTargetDept} 的权限将实时跟随目标部门,且无法独立修改权限配置。
|
||
</p>
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
onClick={() => setShowLinkDialog(false)}
|
||
className="px-4 py-2 text-sm rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleConfirmLink}
|
||
disabled={!selectedLinkSource}
|
||
className="px-4 py-2 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
确定
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|