gongxiangp2/components/DepartmentTreeSelect.tsx
chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

232 lines
7.7 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 { useState, useRef, useEffect } from 'react'
interface Department {
id: string
name: string
parentId?: string | null
code?: string | null
children?: Department[]
}
interface DepartmentTreeSelectProps {
departments: Department[]
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
allowEmpty?: boolean
emptyLabel?: string
}
export default function DepartmentTreeSelect({
departments,
value,
onChange,
placeholder = '请选择部门',
className = '',
allowEmpty = true,
emptyLabel = '未分配',
}: DepartmentTreeSelectProps) {
const [isOpen, setIsOpen] = useState(false)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const wrapperRef = useRef<HTMLDivElement>(null)
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
// 构建部门树
const buildTree = (depts: Department[]): Department[] => {
const deptMap = new Map<string, Department>()
const rootDepts: Department[] = []
// 创建映射
depts.forEach(dept => {
deptMap.set(dept.id, { ...dept, children: [] })
})
// 构建树
depts.forEach(dept => {
const node = deptMap.get(dept.id)!
if (dept.parentId && deptMap.has(dept.parentId)) {
const parent = deptMap.get(dept.parentId)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
rootDepts.push(node)
}
})
return rootDepts
}
const treeData = buildTree(departments)
// 获取选中的部门名称
const getSelectedName = (): string => {
if (!value) return emptyLabel
const dept = departments.find(d => d.id === value || d.name === value)
return dept ? dept.name : (value || emptyLabel)
}
// 获取当前值的部门ID(用于匹配)
const getValueId = (): string => {
if (!value) return ''
const dept = departments.find(d => d.id === value || d.name === value)
return dept ? dept.id : value
}
// 切换展开/收起
const toggleExpand = (deptId: string, e: React.MouseEvent) => {
e.stopPropagation()
setExpandedIds(prev => {
const newSet = new Set(prev)
if (newSet.has(deptId)) {
newSet.delete(deptId)
} else {
newSet.add(deptId)
}
return newSet
})
}
// 选择部门
const handleSelect = (deptId: string, deptName: string) => {
onChange(deptId)
setIsOpen(false)
}
// 递归渲染部门树
const renderDepartmentTree = (depts: Department[], level: number = 0): React.ReactNode => {
const currentValueId = getValueId()
return depts.map(dept => {
const hasChildren = dept.children && dept.children.length > 0
const isExpanded = expandedIds.has(dept.id)
const isSelected = currentValueId === dept.id
return (
<div key={dept.id}>
<div
className={`flex items-center gap-2 px-3 py-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 cursor-pointer ${isSelected ? 'bg-blue-100 dark:bg-blue-900/50' : ''}`}
style={{ paddingLeft: `${level * 1.5 + 0.75}rem` }}
onClick={() => handleSelect(dept.id, dept.name)}
>
{/* 展开/折叠按钮 */}
{hasChildren ? (
<div
className="flex-shrink-0 w-4 h-4 flex items-center justify-center"
onClick={(e) => toggleExpand(dept.id, e)}
>
<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="flex-shrink-0 w-4 h-4" />
)}
{/* 部门图标 */}
<svg className="w-4 h-4 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="flex-1 text-sm text-gray-900 dark:text-white">
{dept.name}
</span>
{/* 选中标记 */}
{isSelected && (
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{/* 子部门 */}
{hasChildren && isExpanded && (
<div>
{renderDepartmentTree(dept.children!, level + 1)}
</div>
)}
</div>
)
})
}
return (
<div ref={wrapperRef} className={`relative ${className}`}>
{/* 输入框 */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`w-full px-3 py-2 text-left border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${isOpen ? 'ring-2 ring-blue-500' : ''}`}
>
<div className="flex items-center justify-between">
<span className={value ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'}>
{getSelectedName()}
</span>
<svg
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* 下拉列表 */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{allowEmpty && (
<div
className={`flex items-center gap-2 px-3 py-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 cursor-pointer ${!value ? 'bg-blue-100 dark:bg-blue-900/50' : ''}`}
onClick={() => handleSelect('', '')}
>
<div className="flex-shrink-0 w-4 h-4" />
<span className="flex-1 text-sm text-gray-900 dark:text-white">{emptyLabel}</span>
{!value && (
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
)}
{treeData.length > 0 ? (
renderDepartmentTree(treeData)
) : (
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 text-center">
暂无部门
</div>
)}
</div>
)}
</div>
)
}