gongxiangp2/components/DepartmentPermissionsTab.tsx

527 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}