- 创建统一的下载错误处理模块 (lib/download-errors.ts) - 实现13种错误类型分类和中文错误消息 - 后端统一使用 createDownloadError 创建标准化错误响应 - 前端统一使用 parseDownloadError 解析错误并显示 - 修复正式系统数据库Schema,添加产品全案相关字段和表 - 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段 - 创建 material_assets, api_keys, api_call_logs 表
232 lines
7.7 KiB
TypeScript
232 lines
7.7 KiB
TypeScript
/**
|
||
* 部门树形下拉选择器组件
|
||
* 用于在表单中选择部门,支持树形结构展示
|
||
*/
|
||
|
||
'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>
|
||
)
|
||
}
|
||
|