Files
TurboHR/frontend/src/components/HelpModal.tsx
T
selfrelease 3f39c56f4b feat: 部署到服务器+移动端适配+帮助系统RAG集成+网页图标
- 部署脚本 deploy.sh:一键部署到 154.8.162.18,端口3400,Nginx conf.d
- 应用名改为「企业用工专家」(登录/注册/侧边栏/员工端等6处)
- 移动端适配修复:Attendance/Dashboard/Settings/Contracts 6处grid响应式
- 帮助系统:HelpModal 组件 + TopNav 帮助图标 + RAG语义搜索集成
  - 后端 rag.service.ts 新增23条帮助种子数据 + searchHelp()
  - 前端搜索调用 /ai/rag/help-search,RAG不可用时回退关键词匹配
- 网页图标 favicon.svg(indigo主色+建筑人形图案)
- index.html 标题改为「企业用工专家」
2026-07-27 10:10:07 +08:00

514 lines
24 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.
import { useState, useEffect, useRef } from 'react'
import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles,
Home, Users, FileText, Calculator, Shield, UserX, Bot,
Bell, Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
import Modal from './ui/Modal'
import api from '../lib/api'
import clsx from 'clsx'
interface HelpCategory {
id: string
title: string
icon: typeof Home
articles: HelpArticle[]
}
interface HelpArticle {
id: string
question: string
answer: string
steps?: string[]
tip?: string
warning?: string
}
const categories: HelpCategory[] = [
{
id: 'start',
title: '快速入门',
icon: Lightbulb,
articles: [
{
id: 'what-is',
question: '「企业用工专家」是什么?',
answer: '这是一个帮您管理员工、合同、工资和社保的工具。您可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如:记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。',
},
{
id: 'first-steps',
question: '我第一次使用,该从哪里开始?',
answer: '建议按以下顺序操作,就像填表格一样简单:',
steps: [
'第一步:点击左侧菜单「员工管理」,添加您的员工信息(姓名、手机号、入职日期等)',
'第二步:在员工详情里填写合同信息(合同类型、签订日期、到期日期等)',
'第三步:在「社保管理」中设置您公司的社保缴纳基数和比例',
'第四步:在「薪税管理」中创建发薪批次,系统会自动帮您计算工资',
'第五步:有不懂的随时点这个「?」图标查看帮助',
],
tip: '不用担心填错,所有信息都可以随时修改。',
},
{
id: 'mobile',
question: '我可以在手机上使用吗?',
answer: '可以!用手机浏览器打开本网站即可。手机版会自动显示底部导航栏,方便您随时查看。建议添加到手机桌面,像 App 一样使用。',
steps: [
'苹果手机:Safari 打开 → 点击底部分享按钮 → 「添加到主屏幕」',
'安卓手机:Chrome 打开 → 点击右上角菜单 → 「添加到主屏幕」',
],
},
],
},
{
id: 'employee',
title: '员工管理',
icon: Users,
articles: [
{
id: 'add-employee',
question: '怎么添加新员工?',
answer: '就像填一张入职登记表:',
steps: [
'点击左侧菜单「员工管理」',
'点击右上角「添加员工」按钮',
'填写员工姓名、手机号、入职日期等基本信息',
'点击「保存」即可',
],
tip: '带 * 号的是必填项,其他可以以后再补。',
},
{
id: 'edit-employee',
question: '怎么修改员工信息?',
answer: '在员工列表中,点击员工姓名即可进入详情页,然后点击右上角「编辑」按钮修改信息。',
},
{
id: 'employee-status',
question: '员工离职了怎么处理?',
answer: '请到「解聘管理」页面处理离职流程,系统会自动帮您计算经济补偿金、生成解聘协议书等法律文件。不要直接删除员工记录,保留记录有助于日后查证。',
warning: '直接删除员工会导致该员工的所有历史记录丢失,包括合同、工资单等。',
},
],
},
{
id: 'contract',
title: '合同管理',
icon: FileText,
articles: [
{
id: 'contract-types',
question: '合同类型有哪些?我该选哪个?',
answer: '常见的合同类型:\n• 固定期限合同:有明确到期日,比如签 3 年\n• 无固定期限合同:没有到期日,长期雇佣\n• 完成任务合同:以完成某项工作为期限\n• 未签合同:还没签合同的情况',
warning: '员工入职 1 个月内必须签订书面合同,否则企业需要支付双倍工资。',
},
{
id: 'contract-expiry',
question: '合同快到期了,系统会提醒我吗?',
answer: '会的!系统会自动检测即将到期的合同,并在顶部通知铃铛处显示提醒数字。您也可以在「通知管理」页面查看所有提醒。默认提前 30 天提醒,您可以在通知设置中修改天数。',
},
{
id: 'contract-confirm',
question: '什么是合同确认?',
answer: '合同确认是指员工通过手机查看并确认自己的劳动合同内容。系统会生成一个链接,员工用手机打开即可查看合同详情并确认签字。您可以在员工详情的「合同信息」标签页中发起确认。',
},
],
},
{
id: 'salary',
title: '工资和社保',
icon: Calculator,
articles: [
{
id: 'create-payroll',
question: '怎么发工资?',
answer: '系统帮您自动化算薪:',
steps: [
'点击左侧菜单「薪税管理」',
'点击「创建发薪批次」',
'选择月份和批次类型(正常工资/补发等)',
'系统自动从员工信息中拉取数据并计算',
'确认无误后点击「发放」',
],
tip: '系统会自动计算个税、社保扣款等,您只需要核对数字即可。',
},
{
id: 'social-insurance',
question: '社保基数和比例怎么设置?',
answer: '在「社保管理」页面,您可以设置公司所在地的社保缴纳基数上下限和各项比例(养老、医疗、失业、工伤、生育)。这些数据通常每年会调整一次,请关注当地社保局通知。',
warning: '社保基数和比例各地不同,请以当地社保局公布的最新标准为准。',
},
{
id: 'payslip',
question: '员工怎么查看工资条?',
answer: '系统提供员工端(手机访问),员工可以通过手机查看自己的工资条。您在发薪后,系统会自动生成工资条,员工登录员工端即可查看。',
},
{
id: 'overtime',
question: '加班费怎么算?',
answer: '系统会根据您设置的加班倍率自动计算。默认规则:\n• 工作日加班:1.5 倍\n• 休息日加班:2 倍\n• 法定节假日加班:3 倍\n您可以在薪税管理的加班配置中修改倍率。',
},
],
},
{
id: 'risk',
title: '风险检测',
icon: AlertTriangle,
articles: [
{
id: 'what-is-risk',
question: '风险检测是什么意思?',
answer: '系统会自动扫描您的员工和合同数据,发现可能存在的法律风险。比如:合同到期未续签、试用期超长、未签合同等。风险分为高、中、低三个等级,建议优先处理高风险项。',
},
{
id: 'how-to-fix',
question: '发现风险后怎么处理?',
answer: '在首页「总览」页面可以看到风险概览。点击风险项可以跳转到对应员工详情,然后根据系统建议进行处理。处理完成后风险会自动消除。',
tip: '建议每周查看一次风险提醒,及时处理避免法律纠纷。',
},
],
},
{
id: 'ai',
title: 'AI 助手',
icon: Bot,
articles: [
{
id: 'what-is-ai',
question: 'AI 助手能帮我做什么?',
answer: 'AI 助手可以回答劳动法相关问题,比如:\n• 「员工试用期最多多久?」\n• 「经济补偿金怎么计算?」\n• 「未婚怀孕能休产假吗?」\n• 「加班费怎么算?」\n就像问一个专业律师一样,用大白话问就行。',
},
{
id: 'ai-predict',
question: '判赔预测是什么?',
answer: '如果您要解聘员工,AI 可以根据员工的信息(工资、工龄、合同等)预测可能需要支付的经济补偿金金额,帮您提前做好预算。在「AI 助手」页面选择「判赔预测」模式即可使用。',
warning: 'AI 预测结果仅供参考,实际金额请以律师建议或法院判决为准。',
},
{
id: 'ai-limit',
question: 'AI 回答准确吗?',
answer: 'AI 基于劳动法律法规训练,回答具有较高参考价值。但法律问题往往涉及具体细节,建议将 AI 回答作为参考,重要决策请咨询专业律师。',
},
],
},
{
id: 'settings',
title: '通知和设置',
icon: Settings,
articles: [
{
id: 'notification',
question: '怎么设置提醒?',
answer: '在「通知管理」页面可以设置各类提醒:\n• 合同到期提前提醒天数\n• 未签合同提醒\n• 试用期到期提醒等\n点击通知铃铛图标可以查看所有未读提醒。',
},
{
id: 'change-password',
question: '怎么修改密码?',
answer: '点击右上角您的用户名 → 「设置」页面中可以修改密码。建议定期更换密码以保护账号安全。',
},
{
id: 'company-info',
question: '怎么修改公司信息?',
answer: '在「设置」页面可以修改公司名称、行业、规模等基本信息。这些信息会影响风险检测的准确性,请如实填写。',
},
],
},
{
id: 'faq',
title: '常见问题',
icon: Phone,
articles: [
{
id: 'data-safe',
question: '我的数据安全吗?',
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如身份证号)都经过加密存储。',
},
{
id: 'data-export',
question: '可以导出数据吗?',
answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次也可以导出为 Excel 方便财务对账。',
},
{
id: 'multi-user',
question: '可以多人同时使用吗?',
answer: '可以。在「设置」页面可以添加多个 HR 账号,不同账号可以设置不同权限。比如一个管理员、几个普通 HR。',
},
{
id: 'support',
question: '遇到问题怎么办?',
answer: '您可以:\n1. 点击页面右上角的「?」图标查看帮助文档\n2. 在「AI 助手」中直接提问\n3. 联系客服:support@hr8ai.com',
},
],
},
]
interface RAGResult {
title: string
content: string
source: string
category: string
similarity: number
}
export default function HelpModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [activeCategory, setActiveCategory] = useState(categories[0].id)
const [expandedArticle, setExpandedArticle] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [ragResults, setRagResults] = useState<RAGResult[] | null>(null)
const [ragLoading, setRagLoading] = useState(false)
const [ragError, setRagError] = useState(false)
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// RAG 语义搜索(防抖 300ms
useEffect(() => {
if (!searchQuery.trim()) {
setRagResults(null)
setRagError(false)
return
}
if (debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(async () => {
setRagLoading(true)
setRagError(false)
try {
const res = await api.post('/ai/rag/help-search', { query: searchQuery, topK: 8 }) as any
setRagResults(res.data?.results || [])
} catch {
setRagError(true)
setRagResults([])
} finally {
setRagLoading(false)
}
}, 300)
return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current) }
}, [searchQuery])
const isSearching = searchQuery.trim().length > 0
const filteredCategories = isSearching
? categories.map(c => ({
...c,
articles: c.articles.filter(
a => a.question.includes(searchQuery) || a.answer.includes(searchQuery)
),
})).filter(c => c.articles.length > 0)
: categories
const currentCategory = filteredCategories.find(c => c.id === activeCategory) || filteredCategories[0]
return (
<Modal open={open} onClose={onClose} title="使用帮助" size="lg" className="p-0">
{/* 搜索框 */}
<div className="border-b border-gray-200 px-4 py-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索问题,比如「怎么添加员工」「合同到期」..."
className="w-full pl-9 pr-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="flex flex-col md:flex-row min-h-[400px] max-h-[60vh]">
{/* 左侧分类导航 */}
<div className="md:w-48 md:border-r border-gray-200 md:flex-shrink-0">
{/* 移动端:横向滚动 */}
<div className="md:hidden flex gap-1 overflow-x-auto p-2 border-b border-gray-200">
{filteredCategories.map(cat => {
const Icon = cat.icon
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs whitespace-nowrap flex-shrink-0',
activeCategory === cat.id ? 'bg-primary/10 text-primary font-medium' : 'text-gray-600'
)}
>
<Icon className="w-3.5 h-3.5" />
{cat.title}
</button>
)
})}
</div>
{/* 桌面端:纵向列表 */}
<div className="hidden md:block py-2">
{filteredCategories.map(cat => {
const Icon = cat.icon
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={clsx(
'flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors text-left',
activeCategory === cat.id
? 'bg-primary/10 text-primary font-medium border-r-2 border-primary'
: 'text-gray-600 hover:bg-gray-50'
)}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{cat.title}</span>
</button>
)
})}
</div>
</div>
{/* 右侧文章列表 */}
<div className="flex-1 overflow-y-auto p-4">
{/* RAG 语义搜索结果模式 */}
{isSearching && ragResults !== null ? (
<>
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary" />
AI
{ragLoading && <span className="text-xs font-normal text-gray-400">...</span>}
</h3>
{ragError ? (
/* RAG 不可用时回退到关键词匹配 */
<>
<p className="text-xs text-gray-400 mb-3">AI </p>
{filteredCategories.length > 0 ? (
<div className="space-y-2">
{filteredCategories.flatMap(c => c.articles).map(article => (
<div key={article.id} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedArticle(expandedArticle === article.id ? null : article.id)}
className="flex items-center justify-between w-full px-3 py-2.5 text-left hover:bg-gray-50 transition-colors"
>
<span className="text-sm font-medium text-gray-800 pr-2">{article.question}</span>
{expandedArticle === article.id ? <ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />}
</button>
{expandedArticle === article.id && (
<div className="px-3 pb-3 pt-1 text-sm text-gray-600">
{article.answer.split('\n').map((line, i) => (
<p key={i} className={line.startsWith('•') ? 'pl-2' : ''}>{line}</p>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-12 text-gray-400">
<Search className="w-8 h-8 mx-auto mb-2" />
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
)}
</>
) : ragResults.length > 0 ? (
<div className="space-y-2">
{ragResults.map((r, i) => (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedArticle(expandedArticle === `rag-${i}` ? null : `rag-${i}`)}
className="flex items-center justify-between w-full px-3 py-2.5 text-left hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-2 pr-2 min-w-0">
<span className="text-sm font-medium text-gray-800 truncate">{r.title}</span>
<span className="text-xs text-gray-400 flex-shrink-0">{(r.similarity * 100).toFixed(0)}% </span>
</div>
{expandedArticle === `rag-${i}` ? <ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />}
</button>
{expandedArticle === `rag-${i}` && (
<div className="px-3 pb-3 pt-1 text-sm text-gray-600 space-y-2">
<p>{r.content}</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span className="px-1.5 py-0.5 rounded bg-gray-100">{r.category}</span>
<span>{r.source}</span>
</div>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-12 text-gray-400">
<Search className="w-8 h-8 mx-auto mb-2" />
<p className="text-sm"></p>
<p className="text-xs mt-1"> AI </p>
</div>
)}
</>
) : (
/* 正常分类浏览模式 */
<>
{currentCategory ? (
<>
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
{(() => {
const Icon = currentCategory.icon
return <Icon className="w-4 h-4 text-primary" />
})()}
{currentCategory.title}
</h3>
<div className="space-y-2">
{currentCategory.articles.map(article => {
const expanded = expandedArticle === article.id
return (
<div key={article.id} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedArticle(expanded ? null : article.id)}
className="flex items-center justify-between w-full px-3 py-2.5 text-left hover:bg-gray-50 transition-colors"
>
<span className="text-sm font-medium text-gray-800 pr-2">{article.question}</span>
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />}
</button>
{expanded && (
<div className="px-3 pb-3 pt-1 text-sm text-gray-600 space-y-2">
{article.answer.split('\n').map((line, i) => (
<p key={i} className={line.startsWith('•') ? 'pl-2' : ''}>{line}</p>
))}
{article.steps && (
<div className="bg-blue-50 rounded-md p-3 space-y-1.5">
<p className="text-xs font-medium text-blue-700"></p>
{article.steps.map((step, i) => (
<div key={i} className="flex gap-2 text-xs text-blue-900">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-200 text-blue-700 flex items-center justify-center font-medium">{i + 1}</span>
<span className="pt-0.5">{step}</span>
</div>
))}
</div>
)}
{article.tip && (
<div className="flex items-start gap-2 bg-green-50 rounded-md p-2.5">
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-green-800">{article.tip}</p>
</div>
)}
{article.warning && (
<div className="flex items-start gap-2 bg-amber-50 rounded-md p-2.5">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">{article.warning}</p>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</>
) : (
<div className="text-center py-12 text-gray-400">
<Search className="w-8 h-8 mx-auto mb-2" />
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
)}
</>
)}
</div>
</div>
{/* 底部联系方式 */}
<div className="border-t border-gray-200 px-4 py-2.5 flex items-center justify-between text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<HelpCircle className="w-3.5 h-3.5" />
AI
</span>
<span>support@hr8ai.com</span>
</div>
</Modal>
)
}