Sprint 4-5: 员工自助+考勤+合规+AI+搜索
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 上下文 AI 入口组件 — 嵌入业务页面,传递页面上下文给 AI 助手
|
||||
* 支持浮动按钮 + 弹出对话框
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Bot, Send, X, Sparkles } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
|
||||
interface AIContextEntryProps {
|
||||
/** 当前页面上下文标识 */
|
||||
context: string
|
||||
/** 上下文描述(传给后端 AI) */
|
||||
contextData?: Record<string, any>
|
||||
/** 页面标题 */
|
||||
pageTitle: string
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文 AI 入口 — 浮动按钮 + 弹出式对话框
|
||||
* 自动携带当前页面上下文信息
|
||||
*/
|
||||
export function AIContextEntry({ context, contextData, pageTitle }: AIContextEntryProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/** 自动滚动到底部 */
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
/** AI 问答 */
|
||||
const askMutation = useMutation({
|
||||
mutationFn: async (question: string) => {
|
||||
const res = await api.post('/ai/context-ask', {
|
||||
context,
|
||||
contextData,
|
||||
pageTitle,
|
||||
question,
|
||||
history: messages.slice(-6),
|
||||
}) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: data.answer || data.message || '抱歉,我暂时无法回答这个问题。' }])
|
||||
},
|
||||
onError: () => {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: 'AI 服务暂时不可用,请稍后再试。' }])
|
||||
},
|
||||
})
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || askMutation.isPending) return
|
||||
const question = input.trim()
|
||||
setMessages(prev => [...prev, { role: 'user', content: question }])
|
||||
setInput('')
|
||||
askMutation.mutate(question)
|
||||
}
|
||||
|
||||
/** 预设问题 */
|
||||
const presetQuestions: Record<string, string[]> = {
|
||||
roster: ['哪些员工合同即将到期?', '如何批量导入员工?', '试用期员工有哪些风险?'],
|
||||
termination: ['离职补偿金如何计算?', '什么情况属于违法解除?', '离职交接清单包含哪些?'],
|
||||
social: ['社保基数如何确定?', '公积金缴存比例是多少?', '如何办理月度社保增减员?'],
|
||||
money: ['工资条包含哪些项目?', '个税如何计算?', '如何批量发薪?'],
|
||||
attendance: ['如何发布月度考勤?', '考勤异常如何处理?'],
|
||||
}
|
||||
const presets = presetQuestions[context] || []
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 浮动按钮 */}
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-40 w-12 h-12 rounded-full bg-primary text-white shadow-lg hover:shadow-xl transition-shadow flex items-center justify-center"
|
||||
aria-label="询问 AI"
|
||||
>
|
||||
<Bot className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* 弹出对话框 */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-end md:items-center justify-center bg-black/20 px-4 pb-4 md:pb-0" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
className="w-full max-w-md bg-white rounded-xl shadow-2xl border border-gray-200 flex flex-col max-h-[80vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">AI 顾问 · {pageTitle}</div>
|
||||
<div className="text-xs text-gray-400">基于当前页面上下文回答</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3 min-h-[200px]">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<Bot className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<div className="text-sm text-gray-400">向 AI 提问关于「{pageTitle}」的问题</div>
|
||||
{presets.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{presets.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { setInput(q) }}
|
||||
className="block w-full text-left text-xs text-primary bg-primary/5 hover:bg-primary/10 rounded-lg px-3 py-2 transition-colors"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{askMutation.isPending && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-100 rounded-lg px-3 py-2 text-sm text-gray-400">
|
||||
<span className="inline-flex gap-1">
|
||||
<span className="animate-bounce" style={{ animationDelay: '0ms' }}>·</span>
|
||||
<span className="animate-bounce" style={{ animationDelay: '150ms' }}>·</span>
|
||||
<span className="animate-bounce" style={{ animationDelay: '300ms' }}>·</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入区 */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSend() }}
|
||||
placeholder="输入问题..."
|
||||
className="flex-1 text-sm outline-none bg-gray-50 rounded-lg px-3 py-2 border border-gray-200 focus:border-primary focus:ring-2 focus:ring-primary/10 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || askMutation.isPending}
|
||||
className="w-8 h-8 rounded-lg bg-primary text-white flex items-center justify-center disabled:opacity-40 transition-opacity"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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 api from '../../lib/api'
|
||||
|
||||
/** 搜索结果类型 */
|
||||
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: '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: '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 api.get('/search', { params: { q } }) as any
|
||||
const remoteResults: SearchResult[] = (res.data?.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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user