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 标题改为「企业用工专家」
This commit is contained in:
selfrelease
2026-07-27 10:10:07 +08:00
parent 71e2bb2412
commit 3f39c56f4b
16 changed files with 1014 additions and 15 deletions
+25 -1
View File
@@ -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 {
+59
View File
@@ -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}`)
Executable
+386
View File
@@ -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
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用工合规助手</title>
<title>企业用工专家</title>
</head>
<body>
<div id="root"></div>
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#4f46e5"/>
<path d="M16 6 L24 10 L24 16 L16 20 L8 16 L8 10 Z" fill="none" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
<circle cx="16" cy="13" r="2" fill="white"/>
<path d="M12 24 L12 20 M20 24 L20 20 M16 22 L16 20" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
<rect x="10" y="24" width="12" height="2" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

+513
View File
@@ -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<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>
)
}
@@ -121,7 +121,7 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea
{/* Logo 区 */}
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
<Building2 className="w-5 h-5 text-primary" />
<span className="font-bold text-sm text-gray-900"></span>
<span className="font-bold text-sm text-gray-900"></span>
</div>
{/* 导航菜单 */}
+12 -2
View File
@@ -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<any>({
queryKey: ['dashboard'],
@@ -36,8 +38,16 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
<Breadcrumb />
</div>
{/* 右侧:通知 + 设置 + 用户菜单 */}
{/* 右侧:帮助 + 通知 + 设置 + 用户菜单 */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setHelpOpen(true)}
className="p-1.5 rounded-md hover:bg-gray-100"
aria-label="帮助"
>
<HelpCircle className="w-4 h-4 text-gray-600" />
</button>
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
<Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
<SettingsIcon className="w-4 h-4 text-gray-600" />
</Link>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function Attendance() {
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ label: '总计', value: stats.total, color: 'text-gray-700' },
{ label: '待确认', value: stats.pending, color: 'text-amber-600' },
+1 -1
View File
@@ -318,7 +318,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
</div>
{form.contractType === 'FIXED' && (
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
+1 -1
View File
@@ -727,7 +727,7 @@ export default function Dashboard() {
{/* 企业成本 */}
<div className="border-t pt-2 space-y-2">
<div className="text-xs font-medium text-gray-600 mb-1"></div>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="flex items-center justify-between p-2 rounded-md bg-blue-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" /></span>
<span className="text-xs font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
+2 -2
View File
@@ -174,7 +174,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
const renderPolicyRules = () => (
<div className="space-y-2">
<div className="text-xs font-medium text-gray-600">退 退</div>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
<div className="text-xs text-gray-500"></div>
<div className="text-sm font-semibold text-gray-700">60 63</div>
@@ -562,7 +562,7 @@ function PlanSettings({ orgData }: { orgData: any }) {
{usageData && (
<Card>
<h3 className="text-xs font-medium text-gray-700 mb-3"></h3>
<div className="grid grid-cols-3 gap-4 text-xs">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs">
<div>
<div className="text-gray-500"></div>
<div className="font-medium text-base">{usageData.employeeCount}<span className="text-gray-500 text-xs">/{usageData.maxEmployees}</span></div>
+1 -1
View File
@@ -60,7 +60,7 @@ export default function ForgotPassword() {
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span>
<span className="text-xl font-bold"></span>
</div>
<div className="card">
+1 -1
View File
@@ -50,7 +50,7 @@ export default function Login() {
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span>
<span className="text-xl font-bold"></span>
</div>
<div className="card">
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Register() {
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span>
<span className="text-xl font-bold"></span>
</div>
<div className="card">
+1 -1
View File
@@ -64,7 +64,7 @@ export default function PortalLogin() {
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-base font-bold"> </span>
<span className="text-base font-bold"> </span>
</div>
<div className="card">