flnxi-Wu/app/admin/_components/DepartmentsTab.tsx
2026-05-25 14:52:24 +08:00

454 lines
18 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.

/**
* DepartmentsTab 组件
*
* 部门管理Tab组件
* 从主页面拆分出来,负责部门管理功能
* 包括部门树形结构、创建、删除、重命名等
* 使用三层架构样式系统,支持深浅色主题
*
* @created 2025-01-XX(拆分自 page.tsx)
* @component
*/
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
import { useDialog } from '@/components/ui/DialogProvider'
import type { Department, DepartmentNode, ToastFn } from './types'
import { buildDepartmentTree, filterDepartmentTree } from '../_utils/departmentTree'
import ProBadge from '@/components/ui/ProBadge'
import UpgradeModal from '@/components/ui/UpgradeModal'
import { useLicenseStatus } from '@/hooks/useLicenseStatus'
interface DepartmentsTabProps {
departments: Department[]
onCreateDepartment: (name: string) => Promise<void> | void
onRenameDepartment: (id: string, newName: string) => Promise<void> | void
onDeleteDepartment: (id: string, fullName: string) => Promise<void> | void
showToast: ToastFn
}
export default function DepartmentsTab({
departments,
onCreateDepartment,
onRenameDepartment,
onDeleteDepartment,
showToast,
}: DepartmentsTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const { confirm, prompt } = useDialog()
const { isFree, isFeatureEnabled } = useLicenseStatus()
const [departmentSearchTerm, setDepartmentSearchTerm] = useState('')
const [collapsedDepartments, setCollapsedDepartments] = useState<Set<string>>(new Set())
const [renamingDepartmentId, setRenamingDepartmentId] = useState<string | null>(null)
const [renamingDepartmentName, setRenamingDepartmentName] = useState('')
const [addingDepartment, setAddingDepartment] = useState(false)
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
// 免费版限制:不能添加子分支(部门隔离功能)
const canAddSubDepartment = !isFree && isFeatureEnabled('departmentIsolation')
const borderInputIndigo = theme === 'dark' ? 'border-indigo-600' : 'border-indigo-300'
const bgInput = theme === 'dark' ? 'bg-slate-900' : 'bg-white'
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
const textDesc = theme === 'dark' ? 'text-slate-300' : 'text-slate-600'
const departmentTree = useMemo(
() => buildDepartmentTree(departments),
[departments],
)
const filteredDepartmentTree = useMemo(
() => filterDepartmentTree(departmentTree, departmentSearchTerm),
[departmentTree, departmentSearchTerm],
)
const toggleDepartmentCollapse = useCallback((deptName: string) => {
setCollapsedDepartments((prev) => {
const next = new Set(prev)
if (next.has(deptName)) {
next.delete(deptName)
} else {
next.add(deptName)
}
return next
})
}, [])
const handleRootAdd = async () => {
const input = await prompt({
title: '添加一级分支',
message: '为「主公司」添加一级分支,例如:一部、设计部、技术部等',
placeholder: '请输入分支名称',
})
if (!input) return
const trimmed = input.trim()
if (!trimmed) {
showToast('分支名称不能为空', 'error')
return
}
setAddingDepartment(true)
try {
await onCreateDepartment(trimmed)
} finally {
setAddingDepartment(false)
}
}
const handleAddSubDepartment = async (parentName: string) => {
const input = await prompt({
title: '添加子分支',
message: `为「${parentName}」添加子分支\n\n建议直接输入子分支名,例如:短视频、直播组等`,
defaultValue: `${parentName}-`,
placeholder: '请输入子分支名称',
})
if (!input) return
const trimmed = input.trim()
if (!trimmed) {
showToast('子分支名称不能为空', 'error')
return
}
const finalName = trimmed.startsWith(parentName) ? trimmed : `${parentName}-${trimmed}`
setAddingDepartment(true)
try {
await onCreateDepartment(finalName)
} finally {
setAddingDepartment(false)
}
}
const handleRenameSubmit = async () => {
if (!renamingDepartmentId) return
const trimmed = renamingDepartmentName.trim()
if (!trimmed) {
showToast('部门名称不能为空', 'error')
return
}
await onRenameDepartment(renamingDepartmentId, trimmed)
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}
const handleStartRename = (deptId: string, currentName: string) => {
setRenamingDepartmentId(deptId)
setRenamingDepartmentName(currentName)
}
const handleDelete = async (deptId: string, fullName: string) => {
const confirmed = await confirm({
title: '确认删除',
message: `确定要删除部门「${fullName}」吗?`,
variant: 'danger',
})
if (!confirmed) return
await onDeleteDepartment(deptId, fullName)
}
const renderDeptNode = (node: DepartmentNode, depth = 0): JSX.Element => {
const isCollapsed = collapsedDepartments.has(node.fullName)
const hasChildren = node.children.length > 0
const level = Math.min(depth, 2)
const depthContainerClasses =
level === 0
? 'bg-slate-50 dark:bg-slate-900/60 border-slate-200 dark:border-slate-700'
: level === 1
? 'bg-slate-50/80 dark:bg-slate-900/40 border-slate-200 dark:border-slate-700'
: 'bg-slate-50/60 dark:bg-slate-900/30 border-slate-200 dark:border-slate-800'
return (
<div key={node.fullName} className="space-y-1.5">
<div
className={cn(
'group flex items-center justify-between px-4 py-3 border-l-2 rounded-lg transition-colors',
depthContainerClasses,
)}
style={{ marginLeft: depth * 12 }}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{hasChildren ? (
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
title={isCollapsed ? '展开' : '折叠'}
onClick={(e) => {
e.stopPropagation()
toggleDepartmentCollapse(node.fullName)
}}
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-300 ease-out text-slate-500 dark:text-slate-300',
!isCollapsed && '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>
</button>
) : (
<span className="flex-shrink-0 w-6 h-6" />
)}
{renamingDepartmentId === node.id ? (
<input
value={renamingDepartmentName}
onChange={(e) => setRenamingDepartmentName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleRenameSubmit()
} else if (e.key === 'Escape') {
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}
}}
className={cn(
styles.inputStyles.base,
styles.inputModifiers.compact,
'flex-1 max-w-xs', // 限制最大宽度,避免输入框太宽
borderInputIndigo,
bgInput,
)}
autoFocus
/>
) : (
<span className={cn('text-sm font-semibold truncate', textTitle)}>{node.fullName}</span>
)}
</div>
<div className="flex items-center gap-2">
{renamingDepartmentId === node.id ? (
<>
<button
type="button"
onClick={() => void handleRenameSubmit()}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色
)}
>
保存
</button>
<button
type="button"
onClick={() => {
setRenamingDepartmentId(null)
setRenamingDepartmentName('')
}}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
)}
>
取消
</button>
</>
) : (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (!canAddSubDepartment) {
setShowUpgradeModal(true)
return
}
void handleAddSubDepartment(node.fullName)
}}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'bg-indigo-600 hover:bg-indigo-700 text-white relative', // 显式添加背景色和文字色
!canAddSubDepartment && 'opacity-80'
)}
>
添加子分支
{!canAddSubDepartment && <ProBadge size="small" className="absolute -top-1.5 -right-1.5" />}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleStartRename(node.id, node.fullName)
}}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色和文字色
)}
>
重命名
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void handleDelete(node.id, node.fullName)
}}
className={cn(
styles.buttonStyles.danger,
styles.buttonModifiers.compact,
// 统一UI样式已包含颜色,不需要额外添加
)}
>
删除
</button>
</>
)}
</div>
</div>
{!isCollapsed && node.children.length > 0 && (
<div className="ml-6 space-y-2 animate-in fade-in slide-in-from-top-2 duration-300">
{node.children.map((child) => renderDeptNode(child, depth + 1))}
</div>
)}
</div>
)
}
return (
<div className="space-y-6">
<section
className={cn(
'relative overflow-hidden rounded-2xl border mb-6 flex-shrink-0 p-5',
theme === 'dark'
? 'border-slate-700 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-900',
'transition-colors',
)}
>
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<p className={cn('text-xs font-medium', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
部门管理
</p>
<h2 className={cn('text-xl font-semibold', textTitle)}>组织结构</h2>
<p className={cn('text-sm leading-relaxed', textDesc)}>
树形结构展示上下级,配合搜索框即可迅速过滤任意分支或定位负责人。
</p>
</div>
<div className="relative w-full md:w-[320px] lg:w-[360px]">
<input
type="text"
value={departmentSearchTerm}
onChange={(e) => setDepartmentSearchTerm(e.target.value)}
placeholder="搜索分支名称,例如:一部 / 技术部 / 主公司"
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<svg className={cn('absolute left-4 top-3 w-5 h-5', theme === 'dark' ? 'text-slate-500' : 'text-slate-400')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{departmentSearchTerm && (
<button
type="button"
onClick={() => setDepartmentSearchTerm('')}
className={cn(
styles.buttonStyles.ghost,
styles.buttonModifiers.icon,
'absolute right-2 top-2',
theme === 'dark' ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
)}
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>
</div>
</section>
<div className={cn(styles.cardStyles.base, 'overflow-hidden')}>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">组织结构视图</h3>
<span className="text-xs text-slate-500 dark:text-slate-400">
以「主公司」为根的树形结构,仅展示分支(不含人员),支持折叠与搜索
</span>
</div>
<div className="mt-4 space-y-2">
<div className="space-y-2">
<div className="group flex items-center justify-between px-4 py-2.5 border-l-4 border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-900/60 transition-colors">
<div className="flex items-center gap-3">
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
title={collapsedDepartments.has('ROOT') ? '展开' : '折叠'}
onClick={() => toggleDepartmentCollapse('ROOT')}
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-300 ease-out text-slate-500 dark:text-slate-300',
!collapsedDepartments.has('ROOT') && '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>
</button>
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-900 dark:text-white">主公司</span>
<span className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
一级分支:{filteredDepartmentTree.length}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
if (!canAddSubDepartment) {
setShowUpgradeModal(true)
return
}
void handleRootAdd()
}}
disabled={addingDepartment}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'relative',
!canAddSubDepartment && 'opacity-80'
)}
>
{addingDepartment ? '添加中...' : '添加子分支'}
{!canAddSubDepartment && <ProBadge size="small" className="absolute -top-1.5 -right-1.5" />}
</button>
</div>
</div>
{!collapsedDepartments.has('ROOT') && (
<div className="mt-2 space-y-2 ml-6 animate-in fade-in slide-in-from-top-2 duration-300">
{departments.length === 0 ? (
<div className="text-xs text-slate-500 dark:text-slate-400 px-4 py-2">
当前还没有任何分支,请使用上方按钮为「主公司」添加第一个一级分支
</div>
) : filteredDepartmentTree.length === 0 ? (
<div className="text-xs text-slate-500 dark:text-slate-400 px-4 py-2">
未找到匹配的分支,请调整搜索关键词或清空搜索
</div>
) : (
filteredDepartmentTree.map((node) => renderDeptNode(node))
)}
</div>
)}
</div>
</div>
</div>
</div>
{/* 升级提示弹窗 */}
<UpgradeModal
isOpen={showUpgradeModal}
onClose={() => setShowUpgradeModal(false)}
featureKey="departmentIsolation"
/>
</div>
)
}