527 lines
20 KiB
TypeScript
527 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
|
||
interface DepartmentPermission {
|
||
department: string
|
||
materialType: string
|
||
canView: boolean
|
||
canDownload: boolean
|
||
canShare: boolean
|
||
linkedToDepartment: string | null
|
||
}
|
||
|
||
interface DepartmentPermissionsTabProps {
|
||
showToast: (message: string, type?: 'success' | 'error') => void
|
||
departments: Array<{ id: string; name: string }>
|
||
}
|
||
|
||
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 [materialTypes, setMaterialTypes] = useState<string[]>([])
|
||
const [savingDept, setSavingDept] = useState<string | null>(null)
|
||
|
||
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 || {})
|
||
setMaterialTypes(data.materialTypes || [])
|
||
} else {
|
||
showToast('获取权限配置失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching permissions:', error)
|
||
showToast('获取权限配置失败', 'error')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const toggleDepartment = (dept: string) => {
|
||
setExpandedDepartments((prev) => {
|
||
const newSet = new Set(prev)
|
||
if (newSet.has(dept)) {
|
||
newSet.delete(dept)
|
||
} else {
|
||
newSet.add(dept)
|
||
}
|
||
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()
|
||
showToast(error.error || '保存失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving permissions:', error)
|
||
showToast('保存失败', 'error')
|
||
} finally {
|
||
setSavingDept(null)
|
||
}
|
||
}
|
||
|
||
const handleCopyPermissions = async (sourceDept: string) => {
|
||
const targetDepts = departments
|
||
.map((d) => d.name)
|
||
.filter((name) => name !== sourceDept)
|
||
|
||
if (targetDepts.length === 0) {
|
||
showToast('没有可复制的目标部门', 'error')
|
||
return
|
||
}
|
||
|
||
const confirmed = window.confirm(
|
||
`确定要将"${sourceDept}"的权限配置复制到所有其他部门吗?\n这将覆盖目标部门的所有权限配置。`
|
||
)
|
||
|
||
if (!confirmed) return
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/department-permissions/copy', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
sourceDepartment: sourceDept,
|
||
targetDepartments: targetDepts,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
await fetchPermissions()
|
||
showToast('权限复制成功')
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '复制失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error copying permissions:', error)
|
||
showToast('复制失败', 'error')
|
||
}
|
||
}
|
||
|
||
const handleLinkPermissions = async (targetDept: string) => {
|
||
const sourceDepts = departments
|
||
.map((d) => d.name)
|
||
.filter((name) => name !== targetDept)
|
||
|
||
if (sourceDepts.length === 0) {
|
||
showToast('没有可关联的源部门', 'error')
|
||
return
|
||
}
|
||
|
||
const sourceDept = window.prompt(
|
||
`请输入要关联的源部门名称:\n可用部门:${sourceDepts.join(', ')}`,
|
||
''
|
||
)
|
||
|
||
if (!sourceDept || !sourceDepts.includes(sourceDept)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/department-permissions/link', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
targetDepartment: targetDept,
|
||
sourceDepartment: sourceDept,
|
||
}),
|
||
})
|
||
|
||
if (response.ok) {
|
||
await fetchPermissions()
|
||
showToast('权限关联成功')
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '关联失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error linking permissions:', error)
|
||
showToast('关联失败', '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()
|
||
showToast(error.error || '取消失败', 'error')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error unlinking permissions:', error)
|
||
showToast('取消失败', 'error')
|
||
}
|
||
}
|
||
|
||
const filteredDepartments = searchKeyword
|
||
? departments.filter((d) => d.name.includes(searchKeyword))
|
||
: departments
|
||
|
||
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">
|
||
{filteredDepartments.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||
{searchKeyword ? '未找到匹配的部门' : '暂无部门'}
|
||
</div>
|
||
) : (
|
||
filteredDepartments.map((dept) => {
|
||
const deptName = dept.name
|
||
const isExpanded = expandedDepartments.has(deptName)
|
||
const linkedTo = linkedDepartments[deptName]
|
||
const deptPermissions = editedPermissions[deptName] || {}
|
||
const isDirty = dirtyDepartments.has(deptName)
|
||
|
||
return (
|
||
<div
|
||
key={dept.id}
|
||
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
|
||
>
|
||
<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"
|
||
onClick={() => toggleDepartment(deptName)}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-gray-500 dark:text-gray-400">
|
||
{isExpanded ? '▼' : '▶'}
|
||
</span>
|
||
<span className="font-medium text-gray-900 dark:text-white">{deptName}</span>
|
||
{linkedTo && (
|
||
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||
(已关联到: {linkedTo})
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||
<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>
|
||
{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={() => 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 canView = perm?.canView ?? false
|
||
const canDownload = perm?.canDownload ?? false
|
||
const canShare = perm?.canShare ?? true
|
||
|
||
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">
|
||
{materialType}
|
||
</td>
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={canView}
|
||
disabled={isLinked}
|
||
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"
|
||
/>
|
||
</td>
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={canDownload}
|
||
disabled={isLinked}
|
||
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"
|
||
/>
|
||
</td>
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={canShare}
|
||
disabled={isLinked}
|
||
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"
|
||
/>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|