diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts
index fca0d14..c211434 100644
--- a/backend/src/routes/portal.routes.ts
+++ b/backend/src/routes/portal.routes.ts
@@ -3,8 +3,10 @@ import bcrypt from 'bcryptjs'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
+import jwt from 'jsonwebtoken'
import prisma from '../lib/prisma'
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
+import { authMiddleware, AuthRequest } from '../middleware/auth'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
import { createEvidence } from '../services/evidence.service'
@@ -544,4 +546,69 @@ router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response
}
})
+// ========== 一次性自动登录 ==========
+
+const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret'
+
+/**
+ * 管理端生成员工一次性自动登录 token(10 分钟有效)
+ * POST /portal/auto-login-token body: { employeeId }
+ */
+router.post('/auto-login-token', authMiddleware, async (req: AuthRequest, res, next) => {
+ try {
+ const { employeeId } = req.body
+ if (!employeeId) {
+ return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
+ }
+ const employee = await prisma.employee.findFirst({
+ where: { id: employeeId, orgId: req.user!.orgId, status: 'ACTIVE' },
+ select: { id: true, name: true, phone: true, orgId: true },
+ })
+ if (!employee) {
+ return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
+ }
+ const token = jwt.sign(
+ { id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name },
+ AUTO_LOGIN_SECRET,
+ { expiresIn: '10m' }
+ )
+ res.json({ success: true, data: { token, employeeName: employee.name, phone: employee.phone } })
+ } catch (err) {
+ next(err)
+ }
+})
+
+/**
+ * 员工端自动登录(消费一次性 token)
+ * GET /portal/auto-login?token=xxx
+ */
+router.get('/auto-login', async (req, res, next) => {
+ try {
+ const { token } = req.query
+ if (!token || typeof token !== 'string') {
+ return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 token' } })
+ }
+ let payload: any
+ try {
+ payload = jwt.verify(token, AUTO_LOGIN_SECRET)
+ } catch {
+ return res.status(401).json({ success: false, error: { code: 'TOKEN_EXPIRED', message: '链接已过期,请重新扫码' } })
+ }
+ if (payload.role !== 'EMPLOYEE_AUTO') {
+ return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '无效的登录链接' } })
+ }
+ const employee = await prisma.employee.findFirst({
+ where: { id: payload.id, orgId: payload.orgId, status: 'ACTIVE' },
+ select: { id: true, name: true, department: true, orgId: true },
+ })
+ if (!employee) {
+ return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
+ }
+ const accessToken = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
+ res.json({ success: true, data: { token: accessToken, employee: { id: employee.id, name: employee.name, department: employee.department } } })
+ } catch (err) {
+ next(err)
+ }
+})
+
export default router
diff --git a/frontend/index.html b/frontend/index.html
index 8fde364..f713138 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,7 @@
-
+
企业用工专家
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2f1eae8..462a340 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import { useAuthStore } from './store/authStore'
import TopNav from './components/layout/TopNav'
import SidebarNav from './components/layout/SidebarNav'
import MobileTabBar from './components/layout/MobileTabBar'
+import PortalLayout from './components/layout/PortalLayout'
import PageContainer from './components/layout/PageContainer'
import { SkeletonPage } from './components/ui/Skeleton'
import OnboardingGuide from './components/OnboardingGuide'
@@ -31,6 +32,7 @@ const MyContract = lazy(() => import('./pages/portal/MyContract'))
const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))
const MyPolicies = lazy(() => import('./pages/portal/MyPolicies'))
+const AutoLogin = lazy(() => import('./pages/portal/AutoLogin'))
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator'))
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
@@ -66,13 +68,20 @@ function AdminLayout({ children }: { children: React.ReactNode }) {
)
}
-function PortalLayout({ children }: { children: React.ReactNode }) {
+function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) {
+ if (!showNav) {
+ return (
+
+
+ }>{children}
+
+
+ )
+ }
return (
-
-
- }>{children}
-
-
+
+ }>{children}
+
)
}
@@ -104,12 +113,13 @@ export default function App() {
} />
{/* 员工端 */}
- } />
- } />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
{/* 兜底 */}
} />
diff --git a/frontend/src/components/PortalQRModal.tsx b/frontend/src/components/PortalQRModal.tsx
new file mode 100644
index 0000000..bf2bf4d
--- /dev/null
+++ b/frontend/src/components/PortalQRModal.tsx
@@ -0,0 +1,219 @@
+/**
+ * 员工端二维码弹窗 — 展示员工端登录二维码,支持选择员工生成一次性自动登录链接
+ */
+
+import { useState, useEffect } from 'react'
+import { QRCodeSVG } from 'qrcode.react'
+import { Smartphone, Copy, Check, Search, Loader2, User, ChevronRight } from 'lucide-react'
+import Modal from './ui/Modal'
+import api from '../lib/api'
+
+interface Employee {
+ id: string
+ name: string
+ phone: string
+ department: string | null
+}
+
+export default function PortalQRModal({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const [copied, setCopied] = useState(false)
+ const [employees, setEmployees] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [search, setSearch] = useState('')
+ const [selectedEmployee, setSelectedEmployee] = useState(null)
+ const [autoLoginUrl, setAutoLoginUrl] = useState('')
+ const [generating, setGenerating] = useState(false)
+
+ const portalUrl = `${window.location.origin}/portal/login`
+
+ /** 加载员工列表 */
+ useEffect(() => {
+ if (!open) return
+ setLoading(true)
+ api.get('/roster', { params: { pageSize: 999 } }).then((res: any) => {
+ const list = res.data?.data || res.data || []
+ setEmployees(list.map((e: any) => ({
+ id: e.id,
+ name: e.name,
+ phone: e.phone,
+ department: e.department,
+ })))
+ }).catch(() => {}).finally(() => setLoading(false))
+ }, [open])
+
+ /** 选择员工后生成一次性自动登录链接 */
+ const handleSelectEmployee = async (emp: Employee) => {
+ setSelectedEmployee(emp)
+ setGenerating(true)
+ setAutoLoginUrl('')
+ try {
+ const res = await api.post('/portal/auto-login-token', { employeeId: emp.id }) as any
+ const token = res.data?.token
+ if (token) {
+ setAutoLoginUrl(`${window.location.origin}/portal/auto-login?token=${token}`)
+ }
+ } catch {
+ setAutoLoginUrl(portalUrl)
+ } finally {
+ setGenerating(false)
+ }
+ }
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(autoLoginUrl || portalUrl)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ const handleReset = () => {
+ setSelectedEmployee(null)
+ setAutoLoginUrl('')
+ setSearch('')
+ }
+
+ const filteredEmployees = search
+ ? employees.filter(e => e.name.includes(search) || e.phone.includes(search))
+ : employees
+
+ const displayUrl = autoLoginUrl || portalUrl
+
+ return (
+ { handleReset(); onClose() }} title="员工端入口" size="sm">
+
+ {/* 未选择员工时:展示员工列表 */}
+ {!selectedEmployee ? (
+ <>
+
+
+ 选择员工后生成专属登录二维码
+
+
+ {/* 搜索框 */}
+
+
+ setSearch(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"
+ />
+
+
+ {/* 员工列表 */}
+
+ {loading ? (
+
+
+
+ ) : filteredEmployees.length === 0 ? (
+
未找到员工
+ ) : (
+ filteredEmployees.map(emp => (
+
+ ))
+ )}
+
+ >
+ ) : (
+ /* 已选择员工:展示二维码 */
+ <>
+ {/* 员工信息 + 重选按钮 */}
+
+
+
+
+
+
+
{selectedEmployee.name}
+
{selectedEmployee.phone}
+
+
+
+
+
+ {/* 二维码 */}
+
+
+ {generating ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* 提示信息 */}
+ {autoLoginUrl ? (
+
+
+ 扫码后自动登录,链接 10 分钟内有效
+
+ ) : (
+
员工用手机扫码即可进入员工端
+ )}
+
+ {/* 链接地址 */}
+
+
+ {displayUrl}
+
+
+
+
+
+ {/* 功能说明 */}
+
+
+
+ 查看工资条并确认
+
+
+
+ 查看劳动合同信息
+
+
+
+ 阅读并签收规章制度
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/layout/PortalLayout.tsx b/frontend/src/components/layout/PortalLayout.tsx
new file mode 100644
index 0000000..ab917a6
--- /dev/null
+++ b/frontend/src/components/layout/PortalLayout.tsx
@@ -0,0 +1,83 @@
+/**
+ * 员工端统一布局 — 顶部 Header(Logo + 员工名 + 退出)+ 底部固定 TabBar 导航
+ * 所有已登录员工端页面共享此布局
+ */
+
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
+import Logo from '../../components/ui/Logo'
+
+const tabItems = [
+ { path: '/portal/payslip', label: '工资条', icon: DollarSign },
+ { path: '/portal/contract', label: '我的合同', icon: FileText },
+ { path: '/portal/policies', label: '规章制度', icon: ScrollText },
+]
+
+export default function PortalLayout({ children }: { children: React.ReactNode }) {
+ const location = useLocation()
+ const navigate = useNavigate()
+
+ const employee = (() => {
+ try { return JSON.parse(localStorage.getItem('portalEmployee') || '{}') } catch { return {} }
+ })()
+
+ const handleLogout = () => {
+ localStorage.removeItem('portalToken')
+ localStorage.removeItem('portalEmployee')
+ navigate('/portal/login')
+ }
+
+ return (
+
+ {/* 顶部 Header — 安全区 padding 适配刘海屏 */}
+
+
+ {/* 主内容区 */}
+
+ {children}
+
+
+ {/* 底部固定 TabBar — 安全区 padding 适配 home indicator */}
+
+
+ )
+}
diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx
index 1a3c5e3..242f267 100644
--- a/frontend/src/components/layout/SidebarNav.tsx
+++ b/frontend/src/components/layout/SidebarNav.tsx
@@ -15,6 +15,7 @@ import {
ChevronDown, ChevronRight,
Building2,
} from 'lucide-react'
+import Logo from '../ui/Logo'
interface NavItem {
path: string
@@ -120,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 c80e5e2..19973bd 100644
--- a/frontend/src/components/layout/TopNav.tsx
+++ b/frontend/src/components/layout/TopNav.tsx
@@ -1,17 +1,19 @@
import { Link, useNavigate } from 'react-router-dom'
-import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle } from 'lucide-react'
+import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle, Smartphone } 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'
+import PortalQRModal from '../PortalQRModal'
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 [portalQROpen, setPortalQROpen] = useState(false)
const { data: dashboardData } = useQuery({
queryKey: ['dashboard'],
@@ -38,8 +40,16 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
- {/* 右侧:帮助 + 通知 + 设置 + 用户菜单 */}
+ {/* 右侧:员工端 + 帮助 + 通知 + 设置 + 用户菜单 */}
+
+
setPortalQROpen(false)} />