From 3f39c56f4ba5f3b30add1e9c4d3733fe3f767748 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Mon, 27 Jul 2026 10:10:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=83=A8=E7=BD=B2=E5=88=B0=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8+=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=80=82?= =?UTF-8?q?=E9=85=8D+=E5=B8=AE=E5=8A=A9=E7=B3=BB=E7=BB=9FRAG=E9=9B=86?= =?UTF-8?q?=E6=88=90+=E7=BD=91=E9=A1=B5=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 部署脚本 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 标题改为「企业用工专家」 --- backend/src/routes/ai.routes.ts | 26 +- backend/src/services/rag.service.ts | 59 ++ deploy.sh | 386 +++++++++++++ frontend/index.html | 4 +- frontend/public/favicon.svg | 7 + frontend/src/components/HelpModal.tsx | 513 ++++++++++++++++++ frontend/src/components/layout/SidebarNav.tsx | 2 +- frontend/src/components/layout/TopNav.tsx | 14 +- frontend/src/pages/Attendance.tsx | 2 +- frontend/src/pages/Contracts.tsx | 2 +- frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/Settings.tsx | 4 +- frontend/src/pages/auth/ForgotPassword.tsx | 2 +- frontend/src/pages/auth/Login.tsx | 2 +- frontend/src/pages/auth/Register.tsx | 2 +- frontend/src/pages/portal/PortalLogin.tsx | 2 +- 16 files changed, 1014 insertions(+), 15 deletions(-) create mode 100755 deploy.sh create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/components/HelpModal.tsx diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index 6c23e69..5bc436a 100644 --- a/backend/src/routes/ai.routes.ts +++ b/backend/src/routes/ai.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream } from '../services/ai.service' -import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service' +import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable, searchHelp, seedHelpKnowledge } from '../services/rag.service' import prisma from '../lib/prisma' import { z } from 'zod' import { decrypt } from '../lib/crypto' @@ -632,6 +632,30 @@ router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => { } }) +// 帮助文档 RAG 搜索(语义匹配) +router.post('/rag/help-search', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { query, topK } = req.body + if (!query) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } }) + } + const results = await searchHelp(query, topK || 5) + res.json({ success: true, data: { results } }) + } catch (err) { + next(err) + } +}) + +// 帮助文档初始化(注入帮助种子数据到 RAG 知识库) +router.post('/rag/help-seed', authMiddleware, async (_req: AuthRequest, res, next) => { + try { + await seedHelpKnowledge() + res.json({ success: true, data: { message: '帮助文档已注入知识库' } }) + } catch (err) { + next(err) + } +}) + // 删除知识条目 router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { diff --git a/backend/src/services/rag.service.ts b/backend/src/services/rag.service.ts index 22fec0f..50795b9 100644 --- a/backend/src/services/rag.service.ts +++ b/backend/src/services/rag.service.ts @@ -85,6 +85,65 @@ export async function searchKnowledge(query: string, topK: number = 3): Promise< .map((r) => `【${r.title}】\n${r.content}\n(来源:${r.source},相似度:${(r.similarity * 100).toFixed(0)}%)`) } +/** 帮助文档种子数据 — 通俗易懂的系统使用指南 */ +const HELP_SEED_DATA: KnowledgeSeed[] = [ + { title: '如何添加新员工', content: '点击左侧菜单「员工管理」,点击右上角「添加员工」按钮,填写员工姓名、手机号、入职日期等基本信息,点击保存即可。带*号的是必填项,其他可以以后再补。', source: '使用帮助', category: '系统帮助-员工管理' }, + { title: '如何修改员工信息', content: '在员工列表中点击员工姓名进入详情页,然后点击右上角编辑按钮即可修改信息。所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-员工管理' }, + { title: '员工离职怎么处理', content: '请到「解聘管理」页面处理离职流程,系统会自动帮您计算经济补偿金、生成解聘协议书等法律文件。不要直接删除员工记录,保留记录有助于日后查证。直接删除会导致该员工的所有历史记录丢失。', source: '使用帮助', category: '系统帮助-员工管理' }, + { title: '合同类型有哪些', content: '常见合同类型:固定期限合同(有明确到期日)、无固定期限合同(没有到期日,长期雇佣)、完成任务合同(以完成某项工作为期限)、未签合同。员工入职1个月内必须签订书面合同,否则企业需要支付双倍工资。', source: '使用帮助', category: '系统帮助-合同管理' }, + { title: '合同到期会提醒吗', content: '系统会自动检测即将到期的合同,并在顶部通知铃铛处显示提醒数字。默认提前30天提醒,您可以在通知设置中修改天数。', source: '使用帮助', category: '系统帮助-合同管理' }, + { title: '什么是合同确认', content: '合同确认是指员工通过手机查看并确认自己的劳动合同内容。系统会生成一个链接,员工用手机打开即可查看合同详情并确认签字。您可以在员工详情的合同信息标签页中发起确认。', source: '使用帮助', category: '系统帮助-合同管理' }, + { title: '怎么发工资', content: '点击左侧菜单薪税管理,点击创建发薪批次,选择月份和批次类型,系统自动从员工信息中拉取数据并计算个税和社保扣款,确认无误后点击发放即可。系统会自动计算个税、社保扣款等。', source: '使用帮助', category: '系统帮助-工资社保' }, + { title: '社保基数和比例怎么设置', content: '在社保管理页面设置公司所在地的社保缴纳基数上下限和各项比例(养老、医疗、失业、工伤、生育)。这些数据通常每年会调整一次,请关注当地社保局通知。社保基数和比例各地不同,请以当地社保局公布的最新标准为准。', source: '使用帮助', category: '系统帮助-工资社保' }, + { title: '员工怎么查看工资条', content: '系统提供员工端(手机访问),员工可以通过手机查看自己的工资条。您在发薪后系统会自动生成工资条,员工登录员工端即可查看。', source: '使用帮助', category: '系统帮助-工资社保' }, + { title: '加班费怎么算', content: '系统会根据您设置的加班倍率自动计算。默认规则:工作日加班1.5倍,休息日加班2倍,法定节假日加班3倍。您可以在薪税管理的加班配置中修改倍率。', source: '使用帮助', category: '系统帮助-工资社保' }, + { title: '风险检测是什么意思', content: '系统会自动扫描您的员工和合同数据,发现可能存在的法律风险。比如合同到期未续签、试用期超长、未签合同等。风险分为高、中、低三个等级,建议优先处理高风险项。', source: '使用帮助', category: '系统帮助-风险检测' }, + { title: '发现风险后怎么处理', content: '在首页总览页面可以看到风险概览。点击风险项可以跳转到对应员工详情,然后根据系统建议进行处理。处理完成后风险会自动消除。建议每周查看一次风险提醒。', source: '使用帮助', category: '系统帮助-风险检测' }, + { title: 'AI助手能帮我做什么', content: 'AI助手可以回答劳动法相关问题,比如员工试用期最多多久、经济补偿金怎么计算、未婚怀孕能休产假吗、加班费怎么算等。就像问一个专业律师一样,用大白话问就行。', source: '使用帮助', category: '系统帮助-AI助手' }, + { title: '判赔预测是什么', content: '如果您要解聘员工,AI可以根据员工的信息(工资、工龄、合同等)预测可能需要支付的经济补偿金金额,帮您提前做好预算。在AI助手页面选择判赔预测模式即可使用。AI预测结果仅供参考,实际金额请以律师建议或法院判决为准。', source: '使用帮助', category: '系统帮助-AI助手' }, + { title: '怎么设置提醒通知', content: '在通知管理页面可以设置各类提醒:合同到期提前提醒天数、未签合同提醒、试用期到期提醒等。点击通知铃铛图标可以查看所有未读提醒。', source: '使用帮助', category: '系统帮助-通知设置' }, + { title: '怎么修改密码', content: '点击右上角您的用户名,进入设置页面可以修改密码。建议定期更换密码以保护账号安全。', source: '使用帮助', category: '系统帮助-通知设置' }, + { title: '怎么修改公司信息', content: '在设置页面可以修改公司名称、行业、规模等基本信息。这些信息会影响风险检测的准确性,请如实填写。', source: '使用帮助', category: '系统帮助-通知设置' }, + { title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' }, + { title: '第一次使用该从哪里开始', content: '建议按以下顺序:1添加员工信息,2填写合同信息,3设置社保基数和比例,4创建发薪批次,5有不懂的随时点帮助图标查看。不用担心填错,所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-快速入门' }, + { title: '企业用工专家是什么', content: '这是一个帮您管理员工、合同、工资和社保的工具,可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', source: '使用帮助', category: '系统帮助-快速入门' }, + { title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如身份证号都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' }, + { title: '可以导出数据吗', content: '可以。在员工管理页面可以导出员工名单为Excel文件。工资批次也可以导出为Excel方便财务对账。', source: '使用帮助', category: '系统帮助-常见问题' }, + { title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' }, +] + +/** 初始化帮助文档到 RAG 知识库 */ +export async function seedHelpKnowledge() { + await ensureRAGTable() + // 检查是否已注入帮助文档 + const count = await prisma.$queryRaw`SELECT count(*)::int as c FROM rag_knowledge WHERE category LIKE ${'系统帮助%'}` as any + if (count[0]?.c > 0) return + for (let i = 0; i < HELP_SEED_DATA.length; i++) { + const item = HELP_SEED_DATA[i] + const embedding = await getEmbedding(`${item.title} ${item.content}`) + await prisma.$executeRaw` + INSERT INTO rag_knowledge (id, title, content, source, category, embedding) + VALUES (${`help-${String(i).padStart(3, '0')}`}, ${item.title}, ${item.content}, ${item.source}, ${item.category}, ${embedding}::vector) + ` + } +} + +/** 语义搜索帮助文档(仅搜索系统帮助分类) */ +export async function searchHelp(query: string, topK: number = 5): Promise<{ title: string; content: string; source: string; category: string; similarity: number }[]> { + await ensureRAGTable() + const queryEmbedding = await getEmbedding(query) + const results = await prisma.$queryRaw` + SELECT title, content, source, category, 1 - (embedding <=> ${queryEmbedding}::vector) as similarity + FROM rag_knowledge + WHERE category LIKE ${'系统帮助%'} + ORDER BY embedding <=> ${queryEmbedding}::vector + LIMIT ${topK} + ` as any[] + return results + .filter((r) => r.similarity > 0.3) + .map((r) => ({ title: r.title, content: r.content, source: r.source, category: r.category, similarity: r.similarity })) +} + export async function addKnowledge(title: string, content: string, source: string, category: string) { await ensureRAGTable() const embedding = await getEmbedding(`${title} ${content}`) diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..7e74e68 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,386 @@ +#!/bin/bash +# 企业用工专家 一键部署脚本 +# 部署到 154.8.162.18 -> /var/www/turbohr/ -> https://on.hr8ai.top/ +# +# 用法: bash deploy.sh +# 首次部署: bash deploy.sh --init + +set -e + +# =========================================== +# 配置区 +# =========================================== +REMOTE_HOST="154.8.162.18" +REMOTE_USER="root" +REMOTE_PASS="Why_701208" +REMOTE_DIR="/var/www/turbohr" +DOMAIN="on.hr8ai.top" +APP_NAME="turbohr" +BACKEND_PORT=3400 +LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)" + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[deploy]${NC} $1"; } +warn() { echo -e "${YELLOW}[warn]${NC} $1"; } +err() { echo -e "${RED}[error]${NC} $1"; } + +# 检查 sshpass +if ! command -v sshpass &> /dev/null; then + err "请先安装 sshpass: macOS -> brew install hudochenkov/sshpass/sshpass" + exit 1 +fi + +SSH_CMD="sshpass -p '$REMOTE_PASS' ssh -o StrictHostKeyChecking=no ${REMOTE_USER}@${REMOTE_HOST}" +SCP_CMD="sshpass -p '$REMOTE_PASS' scp -o StrictHostKeyChecking=no" + +# =========================================== +# 远程执行命令的封装 +# =========================================== +remote_exec() { + sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "$1" +} + +remote_copy() { + sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no -r "$1" "${REMOTE_USER}@${REMOTE_HOST}:$2" +} + +# =========================================== +# 首次初始化:创建数据库 + Nginx 配置(系统依赖已预装) +# =========================================== +init_server() { + log "========== 首次初始化服务器 ==========" + + remote_exec " +set -e + +# 服务器已有: Node.js, PM2, PostgreSQL 15, Nginx, Redis, 宝塔面板 +# 端口 3000-3300 已被占用,本应用使用端口 ${BACKEND_PORT} + +echo '[1/4] 创建 PostgreSQL 数据库...' +sudo -u postgres psql -c \"CREATE DATABASE turbohr;\" 2>/dev/null || echo ' 数据库 turbohr 已存在' +sudo -u postgres psql -c \"CREATE USER turbohr WITH PASSWORD 'turbohr2026';\" 2>/dev/null || echo ' 用户 turbohr 已存在' +sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE turbohr TO turbohr;\" 2>/dev/null || true +sudo -u postgres psql -c \"ALTER USER turbohr WITH SUPERUSER;\" 2>/dev/null || true +echo ' PostgreSQL 数据库 turbohr 就绪' + +echo '[2/4] 创建部署目录...' +mkdir -p ${REMOTE_DIR}/backend +mkdir -p ${REMOTE_DIR}/frontend/dist +mkdir -p ${REMOTE_DIR}/backend/uploads + +echo '[3/4] 配置 Nginx (conf.d)...' +cat > /etc/nginx/conf.d/${DOMAIN}.conf << 'NGINXEOF' +server { + listen 80; + server_name __DOMAIN__; + + # 前端静态文件 + location / { + root __REMOTE_DIR__/frontend/dist; + index index.html; + try_files \$uri \$uri/ /index.html; + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ { + expires 30d; + add_header Cache-Control \"public, immutable\"; + } + } + + # 后端 API 反向代理 + location /api/ { + proxy_pass http://127.0.0.1:__BACKEND_PORT__; + proxy_http_version 1.1; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + + # SSE 流式输出支持(AI 对话) + proxy_buffering off; + proxy_cache off; + proxy_set_header Connection ''; + + # 超时设置(AI 生成可能较慢) + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + # 文件上传 + client_max_body_size 10m; + } + + # 上传文件访问 + location /uploads/ { + alias __REMOTE_DIR__/backend/uploads/; + } + + # 安全头 + add_header X-Frame-Options \"SAMEORIGIN\"; + add_header X-Content-Type-Options \"nosniff\"; + add_header X-XSS-Protection \"1; mode=block\"; + + # Gzip 压缩 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + gzip_min_length 1000; +} +NGINXEOF + +# 替换占位符 +sed -i 's|__DOMAIN__|${DOMAIN}|g' /etc/nginx/conf.d/${DOMAIN}.conf +sed -i 's|__REMOTE_DIR__|${REMOTE_DIR}|g' /etc/nginx/conf.d/${DOMAIN}.conf +sed -i 's|__BACKEND_PORT__|${BACKEND_PORT}|g' /etc/nginx/conf.d/${DOMAIN}.conf + +nginx -t && systemctl reload nginx + +echo '[4/4] 检查 certbot...' +if ! command -v certbot &> /dev/null; then + echo ' certbot 未安装,SSL 将在部署后手动配置' +else + echo ' certbot 已安装' +fi + +echo '' +echo '=========================================' +echo ' 系统初始化完成!' +echo ' 数据库: turbohr (用户: turbohr)' +echo ' 端口: ${BACKEND_PORT} (避开 3000-3300)' +echo ' Nginx: ${DOMAIN} -> :80 (conf.d)' +echo '=========================================' + " + + log "系统初始化完成" +} + +# =========================================== +# 构建前端 +# =========================================== +build_frontend() { + log "========== 构建前端 ==========" + + cd "${LOCAL_DIR}/frontend" + + if [ ! -d node_modules ]; then + log "安装前端依赖..." + npm install + fi + + log "构建前端..." + npm run build + + if [ ! -d dist ]; then + err "前端构建失败,dist 目录不存在" + exit 1 + fi + + log "前端构建完成: dist/ ($(du -sh dist | cut -f1))" +} + +# =========================================== +# 构建后端 +# =========================================== +build_backend() { + log "========== 构建后端 ==========" + + cd "${LOCAL_DIR}/backend" + + if [ ! -d node_modules ]; then + log "安装后端依赖..." + npm install + fi + + log "生成 Prisma Client..." + npx prisma generate + + log "编译 TypeScript..." + npx tsc + + if [ ! -d dist ]; then + err "后端编译失败,dist 目录不存在" + exit 1 + fi + + log "后端编译完成: dist/ ($(du -sh dist | cut -f1))" +} + +# =========================================== +# 部署到服务器 +# =========================================== +deploy_files() { + log "========== 部署文件到服务器 ==========" + + # 同步后端(排除 node_modules、.env、uploads、备份文件) + log "[1/4] 同步后端代码..." + sshpass -p "$REMOTE_PASS" rsync -avz --delete \ + --exclude='node_modules' \ + --exclude='.env' \ + --exclude='uploads/*' \ + --exclude='*.dump' \ + --exclude='backups' \ + "${LOCAL_DIR}/backend/" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/backend/" + + # 同步后端 package.json 和 prisma(确保依赖和 schema 同步) + log "[2/4] 同步前端静态文件..." + sshpass -p "$REMOTE_PASS" rsync -avz --delete \ + "${LOCAL_DIR}/frontend/dist/" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/frontend/dist/" + + # 在服务器上安装后端依赖 + 生成 Prisma Client + log "[3/4] 服务器端安装依赖..." + remote_exec "cd ${REMOTE_DIR}/backend && npm install --production=false 2>&1 | tail -5" + + log "[4/4] 生成 Prisma Client..." + remote_exec "cd ${REMOTE_DIR}/backend && npx prisma generate 2>&1 | tail -3" +} + +# =========================================== +# 配置 .env(首次或更新) +# =========================================== +setup_env() { + log "========== 配置环境变量 ==========" + + # 检查远程是否已有 .env,如果没有则创建 + remote_exec " +if [ ! -f ${REMOTE_DIR}/backend/.env ]; then + echo '创建 .env 文件...' + cat > ${REMOTE_DIR}/backend/.env << 'ENVEOF' +# 数据库 +DATABASE_URL=postgresql://turbohr:turbohr2026@localhost:5432/turbohr?schema=public + +# JWT +JWT_SECRET=turbohr-jwt-secret-2026-change-me +JWT_REFRESH_SECRET=turbohr-refresh-secret-2026-change-me + +# DashScope (通义千问) - 请填写实际 API Key +DASHSCOPE_API_KEY=sk-xxx +DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 + +# 加密 +ENCRYPTION_KEY=turbohr-32byte-encryption-key-here + +# 存储 +SUPABASE_URL= +SUPABASE_KEY= + +# 部署 +PORT=${BACKEND_PORT} +CORS_ORIGIN=https://${DOMAIN} +ENVEOF + echo '.env 已创建,请编辑 ${REMOTE_DIR}/backend/.env 填写实际配置' +else + echo '.env 已存在,跳过创建' +fi + " +} + +# =========================================== +# 数据库迁移 +# =========================================== +migrate_db() { + log "========== 数据库迁移 ==========" + remote_exec "cd ${REMOTE_DIR}/backend && npx prisma db push --accept-data-loss 2>&1 | tail -10" + log "数据库 schema 同步完成" +} + +# =========================================== +# 配置 PM2 + 启动服务 +# =========================================== +start_service() { + log "========== 启动服务 ==========" + + remote_exec " +cd ${REMOTE_DIR}/backend + +# 停止旧进程 +pm2 delete ${APP_NAME} 2>/dev/null || true + +# 使用 tsx 运行 TypeScript 源码(避免 ESM 扩展名问题) +pm2 start npx --name ${APP_NAME} --max-memory-restart 512M -- tsx src/index.ts + +# 保存 PM2 配置 +pm2 save + +# 配置开机自启 +pm2 startup systemd -u root --hp /root 2>/dev/null | tail -3 + +echo 'PM2 进程:' +pm2 list + " + + log "服务已启动" +} + +# =========================================== +# 配置 SSL 证书 +# =========================================== +setup_ssl() { + log "========== 配置 SSL 证书 ==========" + + remote_exec " +# 使用 --nginx 插件自动配置 SSL +certbot --nginx -d ${DOMAIN} --non-interactive --agree-tos --register-unsafely-without-email --redirect 2>&1 && echo 'SSL 证书配置成功' || echo 'SSL 证书配置失败,可稍后手动执行: certbot --nginx -d ${DOMAIN}' + " +} + +# =========================================== +# 验证部署 +# =========================================== +verify() { + log "========== 验证部署 ==========" + + # 检查后端 + BACKEND_CODE=$(remote_exec "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${BACKEND_PORT}/api/v1/health 2>/dev/null || echo '000'") + log "后端 /api/v1/health -> HTTP ${BACKEND_CODE}" + + # 检查前端 + FRONTEND_CODE=$(remote_exec "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null || echo '000'") + log "前端 (Nginx) -> HTTP ${FRONTEND_CODE}" + + # 检查域名 + DOMAIN_CODE=$(curl -s -o /dev/null -w '%{http_code}' "https://${DOMAIN}/" 2>/dev/null || echo '000') + log "域名 https://${DOMAIN} -> HTTP ${DOMAIN_CODE}" + + # PM2 状态 + remote_exec "pm2 describe ${APP_NAME} 2>/dev/null | grep -E 'status|restarts|uptime|pid'" + + echo "" + echo "=========================================" + echo " 企业用工专家 部署完成!" + echo " 访问地址: https://${DOMAIN}/" + echo " 后端 API: https://${DOMAIN}/api/v1/" + echo " PM2 管理: pm2 list (服务器上)" + echo "=========================================" +} + +# =========================================== +# 主流程 +# =========================================== +IS_INIT=false +if [ "$1" = "--init" ]; then + IS_INIT=true +fi + +log "开始部署 企业用工专家 到 ${REMOTE_HOST}" +log "域名: ${DOMAIN} 目录: ${REMOTE_DIR}" +echo "" + +if [ "$IS_INIT" = true ]; then + init_server + setup_env + echo "" + warn "首次初始化完成,请先编辑服务器上的 ${REMOTE_DIR}/backend/.env 填写实际配置" + warn "然后重新运行: bash deploy.sh" + exit 0 +fi + +build_frontend +build_backend +deploy_files +setup_env +migrate_db +start_service +verify diff --git a/frontend/index.html b/frontend/index.html index d0643ce..8fde364 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - 用工合规助手 + 企业用工专家
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..ccede20 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/components/HelpModal.tsx b/frontend/src/components/HelpModal.tsx new file mode 100644 index 0000000..713d9a1 --- /dev/null +++ b/frontend/src/components/HelpModal.tsx @@ -0,0 +1,513 @@ +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(null) + const [searchQuery, setSearchQuery] = useState('') + const [ragResults, setRagResults] = useState(null) + const [ragLoading, setRagLoading] = useState(false) + const [ragError, setRagError] = useState(false) + const debounceTimer = useRef | 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 ( + + {/* 搜索框 */} +
+
+ + 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" + /> +
+
+ +
+ {/* 左侧分类导航 */} +
+ {/* 移动端:横向滚动 */} +
+ {filteredCategories.map(cat => { + const Icon = cat.icon + return ( + + ) + })} +
+ {/* 桌面端:纵向列表 */} +
+ {filteredCategories.map(cat => { + const Icon = cat.icon + return ( + + ) + })} +
+
+ + {/* 右侧文章列表 */} +
+ {/* RAG 语义搜索结果模式 */} + {isSearching && ragResults !== null ? ( + <> +

+ + AI 语义搜索结果 + {ragLoading && 搜索中...} +

+ {ragError ? ( + /* RAG 不可用时回退到关键词匹配 */ + <> +

AI 搜索暂不可用,以下为关键词匹配结果:

+ {filteredCategories.length > 0 ? ( +
+ {filteredCategories.flatMap(c => c.articles).map(article => ( +
+ + {expandedArticle === article.id && ( +
+ {article.answer.split('\n').map((line, i) => ( +

{line}

+ ))} +
+ )} +
+ ))} +
+ ) : ( +
+ +

没有找到相关问题

+

试试换个关键词搜索

+
+ )} + + ) : ragResults.length > 0 ? ( +
+ {ragResults.map((r, i) => ( +
+ + {expandedArticle === `rag-${i}` && ( +
+

{r.content}

+
+ {r.category} + 来源:{r.source} +
+
+ )} +
+ ))} +
+ ) : ( +
+ +

没有找到相关问题

+

试试换个关键词,或直接在 AI 助手中提问

+
+ )} + + ) : ( + /* 正常分类浏览模式 */ + <> + {currentCategory ? ( + <> +

+ {(() => { + const Icon = currentCategory.icon + return + })()} + {currentCategory.title} +

+
+ {currentCategory.articles.map(article => { + const expanded = expandedArticle === article.id + return ( +
+ + {expanded && ( +
+ {article.answer.split('\n').map((line, i) => ( +

{line}

+ ))} + {article.steps && ( +
+

操作步骤:

+ {article.steps.map((step, i) => ( +
+ {i + 1} + {step} +
+ ))} +
+ )} + {article.tip && ( +
+ +

{article.tip}

+
+ )} + {article.warning && ( +
+ +

{article.warning}

+
+ )} +
+ )} +
+ ) + })} +
+ + ) : ( +
+ +

没有找到相关问题

+

试试换个关键词搜索

+
+ )} + + )} +
+
+ + {/* 底部联系方式 */} +
+ + + 还有问题?在 AI 助手中直接提问 + + support@hr8ai.com +
+
+ ) +} diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 4e74e2a..1a3c5e3 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -121,7 +121,7 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea {/* Logo 区 */}
- 用工合规助手 + 企业用工专家
{/* 导航菜单 */} diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx index c3b19de..c80e5e2 100644 --- a/frontend/src/components/layout/TopNav.tsx +++ b/frontend/src/components/layout/TopNav.tsx @@ -1,15 +1,17 @@ import { Link, useNavigate } from 'react-router-dom' -import { ChevronDown, Settings as SettingsIcon, Bell, Menu } from 'lucide-react' +import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle } from 'lucide-react' import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../../store/authStore' import api from '../../lib/api' import Breadcrumb from './Breadcrumb' +import HelpModal from '../HelpModal' export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) { const navigate = useNavigate() const { user, logout } = useAuthStore() const [menuOpen, setMenuOpen] = useState(false) + const [helpOpen, setHelpOpen] = useState(false) const { data: dashboardData } = useQuery({ queryKey: ['dashboard'], @@ -36,8 +38,16 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) { - {/* 右侧:通知 + 设置 + 用户菜单 */} + {/* 右侧:帮助 + 通知 + 设置 + 用户菜单 */}
+ + setHelpOpen(false)} /> diff --git a/frontend/src/pages/Attendance.tsx b/frontend/src/pages/Attendance.tsx index dc76188..f2f1b61 100644 --- a/frontend/src/pages/Attendance.tsx +++ b/frontend/src/pages/Attendance.tsx @@ -56,7 +56,7 @@ export default function Attendance() { {/* 统计卡片 */} {stats && ( -
+
{[ { label: '总计', value: stats.total, color: 'text-gray-700' }, { label: '待确认', value: stats.pending, color: 'text-amber-600' }, diff --git a/frontend/src/pages/Contracts.tsx b/frontend/src/pages/Contracts.tsx index bc3068f..48b8ad4 100644 --- a/frontend/src/pages/Contracts.tsx +++ b/frontend/src/pages/Contracts.tsx @@ -318,7 +318,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
{form.contractType === 'FIXED' && ( -
+
setForm({ ...form, endDate: e.target.value })} /> diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e4dbb78..dbd2e5e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -727,7 +727,7 @@ export default function Dashboard() { {/* 企业成本 */}
企业用工成本
-
+
企业社保 {fmt(payroll.socialOrg)} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2976934..3e90c2e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -174,7 +174,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle: const renderPolicyRules = () => (
改革规则(基准退休年龄 → 目标退休年龄)
-
+
男性职工
60岁 → 63岁
@@ -562,7 +562,7 @@ function PlanSettings({ orgData }: { orgData: any }) { {usageData && (

当前用量

-
+
员工数
{usageData.employeeCount}/{usageData.maxEmployees}
diff --git a/frontend/src/pages/auth/ForgotPassword.tsx b/frontend/src/pages/auth/ForgotPassword.tsx index 8eda665..364e534 100644 --- a/frontend/src/pages/auth/ForgotPassword.tsx +++ b/frontend/src/pages/auth/ForgotPassword.tsx @@ -60,7 +60,7 @@ export default function ForgotPassword() {
- 用工合规助手 + 企业用工专家
diff --git a/frontend/src/pages/auth/Login.tsx b/frontend/src/pages/auth/Login.tsx index be3f7ea..c62e068 100644 --- a/frontend/src/pages/auth/Login.tsx +++ b/frontend/src/pages/auth/Login.tsx @@ -50,7 +50,7 @@ export default function Login() {
- 用工合规助手 + 企业用工专家
diff --git a/frontend/src/pages/auth/Register.tsx b/frontend/src/pages/auth/Register.tsx index a52965a..23b18e0 100644 --- a/frontend/src/pages/auth/Register.tsx +++ b/frontend/src/pages/auth/Register.tsx @@ -51,7 +51,7 @@ export default function Register() {
- 用工合规助手 + 企业用工专家
diff --git a/frontend/src/pages/portal/PortalLogin.tsx b/frontend/src/pages/portal/PortalLogin.tsx index c75e0c2..44c144c 100644 --- a/frontend/src/pages/portal/PortalLogin.tsx +++ b/frontend/src/pages/portal/PortalLogin.tsx @@ -64,7 +64,7 @@ export default function PortalLogin() {
- 用工合规助手 — 员工端 + 企业用工专家 — 员工端