454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
/**
|
||
* 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>
|
||
)
|
||
}
|