2968484d2d
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割 - AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割 - xlsx改为动态导入, OvertimeTab从345KB降至12.7KB - api-services.ts: 请求参数 any→Record<string,unknown> - 移除前端3处console.log残留 - 后端console替换为pino logger - 前后端未使用import/变量清理 - Zod schema验证: termination/platform/special-status/work-process - 新增 leave.routes.ts, acceptance-test.routes.ts - UI组件: PageGuide, QueryError, Stepper
204 lines
7.4 KiB
TypeScript
204 lines
7.4 KiB
TypeScript
/**
|
|
* CommandPalette 全局搜索 — Cmd/Ctrl+K 唤起,支持全局搜索和快捷导航
|
|
*/
|
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Search, ArrowRight, Clock } from 'lucide-react'
|
|
import { searchApi } from '../../lib/api-services'
|
|
|
|
/** 搜索结果类型 */
|
|
interface SearchResult {
|
|
type: 'employee' | 'page' | 'action'
|
|
id: string
|
|
title: string
|
|
subtitle?: string
|
|
link: string
|
|
icon?: string
|
|
}
|
|
|
|
/** 快捷页面导航 */
|
|
const QUICK_PAGES: SearchResult[] = [
|
|
{ type: 'page', id: 'dashboard', title: '工作台', link: '/', icon: 'home' },
|
|
{ type: 'page', id: 'roster', title: '花名册', link: '/roster', icon: 'users' },
|
|
{ type: 'page', id: 'money', title: '薪税管理', link: '/money', icon: 'wallet' },
|
|
{ type: 'page', id: 'social', title: '社保公积金', link: '/social', icon: 'shield' },
|
|
{ type: 'page', id: 'termination', title: '离职管理', link: '/termination', icon: 'userX' },
|
|
{ type: 'page', id: 'attendance', title: '考勤排班', link: '/attendance', icon: 'calendar' },
|
|
{ type: 'page', id: 'leave-approval', title: '休假审批', link: '/leave-approval', icon: 'calendar' },
|
|
{ type: 'page', id: 'risk-center', title: '风险中心', link: '/risk-center', icon: 'alert' },
|
|
{ type: 'page', id: 'salary-dashboard', title: '薪酬分析', link: '/salary-dashboard', icon: 'chart' },
|
|
{ type: 'page', id: 'policies', title: '规章制度', link: '/policies', icon: 'file' },
|
|
{ type: 'page', id: 'company-files', title: '公司文件', link: '/company-files', icon: 'building' },
|
|
{ type: 'page', id: 'settings', title: '设置', link: '/settings', icon: 'gear' },
|
|
]
|
|
|
|
interface CommandPaletteProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
|
const [query, setQuery] = useState('')
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
|
const [searching, setSearching] = useState(false)
|
|
const navigate = useNavigate()
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
/** 搜索逻辑 */
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setQuery('')
|
|
setSelectedIndex(0)
|
|
setSearchResults([])
|
|
return
|
|
}
|
|
// 聚焦输入框
|
|
setTimeout(() => inputRef.current?.focus(), 50)
|
|
}, [open])
|
|
|
|
/** 执行搜索 */
|
|
useEffect(() => {
|
|
if (!query.trim()) {
|
|
setSearchResults([])
|
|
return
|
|
}
|
|
const q = query.trim().toLowerCase()
|
|
setSearching(true)
|
|
|
|
// 本地页面匹配
|
|
const localResults = QUICK_PAGES.filter(p =>
|
|
p.title.toLowerCase().includes(q)
|
|
)
|
|
|
|
// 远程搜索员工
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const res = await searchApi.search(q) as any
|
|
const remoteResults: SearchResult[] = (res?.employees || []).map((e: any) => ({
|
|
type: 'employee' as const,
|
|
id: e.id,
|
|
title: e.name,
|
|
subtitle: `${e.department || ''} · ${e.position || ''}`,
|
|
link: `/roster?search=${encodeURIComponent(e.name)}`,
|
|
}))
|
|
setSearchResults([...localResults, ...remoteResults])
|
|
} catch {
|
|
setSearchResults(localResults)
|
|
} finally {
|
|
setSearching(false)
|
|
}
|
|
}, 300)
|
|
|
|
return () => clearTimeout(timer)
|
|
}, [query])
|
|
|
|
/** 合并结果(无搜索词时显示快捷页面) */
|
|
const displayResults = useMemo(() => {
|
|
if (!query.trim()) return QUICK_PAGES
|
|
return searchResults
|
|
}, [query, searchResults])
|
|
|
|
/** 键盘导航 */
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setSelectedIndex(i => Math.min(i + 1, displayResults.length - 1))
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setSelectedIndex(i => Math.max(i - 1, 0))
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
const result = displayResults[selectedIndex]
|
|
if (result) {
|
|
navigate(result.link)
|
|
onClose()
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
onClose()
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [open, selectedIndex, displayResults, navigate, onClose])
|
|
|
|
/** 滚动到选中项 */
|
|
useEffect(() => {
|
|
const el = listRef.current?.children[selectedIndex] as HTMLElement
|
|
el?.scrollIntoView({ block: 'nearest' })
|
|
}, [selectedIndex])
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 pt-[15vh] px-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="w-full max-w-xl bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* 搜索输入 */}
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100">
|
|
<Search className="w-4 h-4 text-gray-400 shrink-0" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0) }}
|
|
placeholder="搜索员工、页面、功能..."
|
|
className="flex-1 text-sm outline-none bg-transparent"
|
|
/>
|
|
<kbd className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">ESC</kbd>
|
|
</div>
|
|
|
|
{/* 搜索结果 */}
|
|
<div ref={listRef} className="max-h-80 overflow-y-auto py-2">
|
|
{displayResults.length === 0 && !searching ? (
|
|
<div className="text-center py-8 text-sm text-gray-400">
|
|
{query ? '未找到匹配结果' : '输入关键词搜索'}
|
|
</div>
|
|
) : (
|
|
displayResults.map((result, i) => (
|
|
<button
|
|
key={`${result.type}-${result.id}`}
|
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
|
i === selectedIndex ? 'bg-primary/5' : 'hover:bg-gray-50'
|
|
}`}
|
|
onClick={() => { navigate(result.link); onClose() }}
|
|
onMouseEnter={() => setSelectedIndex(i)}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-gray-700 truncate">{result.title}</div>
|
|
{result.subtitle && (
|
|
<div className="text-xs text-gray-400 truncate">{result.subtitle}</div>
|
|
)}
|
|
</div>
|
|
{result.type === 'page' && <Clock className="w-3 h-3 text-gray-300" />}
|
|
<ArrowRight className="w-3 h-3 text-gray-300" />
|
|
</button>
|
|
))
|
|
)}
|
|
{searching && (
|
|
<div className="text-center py-2 text-xs text-gray-400">搜索中...</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 底部提示 */}
|
|
<div className="px-4 py-2 border-t border-gray-100 flex items-center justify-between text-xs text-gray-400">
|
|
<div className="flex items-center gap-3">
|
|
<span><kbd className="bg-gray-100 px-1 rounded">↑↓</kbd> 导航</span>
|
|
<span><kbd className="bg-gray-100 px-1 rounded">↵</kbd> 选择</span>
|
|
</div>
|
|
<span>{displayResults.length} 个结果</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|