feat: 员工门户UI优化 - PortalLayout/Logo组件/QR扫码登录/合同与政策页面重构
This commit is contained in:
@@ -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<Employee[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(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 (
|
||||
<Modal open={open} onClose={() => { handleReset(); onClose() }} title="员工端入口" size="sm">
|
||||
<div className="py-4">
|
||||
{/* 未选择员工时:展示员工列表 */}
|
||||
{!selectedEmployee ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 mb-3">
|
||||
<Smartphone className="w-4 h-4 text-primary" />
|
||||
<span>选择员工后生成专属登录二维码</span>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 员工列表 */}
|
||||
<div className="max-h-64 overflow-y-auto space-y-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6 text-gray-400">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
) : filteredEmployees.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-gray-400">未找到员工</div>
|
||||
) : (
|
||||
filteredEmployees.map(emp => (
|
||||
<button
|
||||
key={emp.id}
|
||||
onClick={() => handleSelectEmployee(emp)}
|
||||
className="flex items-center justify-between w-full px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 truncate">{emp.name}</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{emp.department || '未分部门'} · {emp.phone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 flex-shrink-0" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 已选择员工:展示二维码 */
|
||||
<>
|
||||
{/* 员工信息 + 重选按钮 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800">{selectedEmployee.name}</div>
|
||||
<div className="text-xs text-gray-400">{selectedEmployee.phone}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-primary hover:text-primary/80"
|
||||
>
|
||||
重选员工
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 二维码 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="p-4 bg-white rounded-xl border-2 border-gray-100 shadow-sm">
|
||||
{generating ? (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<QRCodeSVG
|
||||
value={displayUrl}
|
||||
size={200}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
{autoLoginUrl ? (
|
||||
<p className="mt-3 text-xs text-green-600 flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
扫码后自动登录,链接 10 分钟内有效
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-gray-500">员工用手机扫码即可进入员工端</p>
|
||||
)}
|
||||
|
||||
{/* 链接地址 */}
|
||||
<div className="mt-3 w-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg">
|
||||
<span className="text-xs text-gray-500 flex-1 truncate">{displayUrl}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 flex-shrink-0"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能说明 */}
|
||||
<div className="mt-4 space-y-1.5 text-xs text-gray-500">
|
||||
<p className="flex items-center gap-1.5">
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
查看工资条并确认
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5">
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
查看劳动合同信息
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5">
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
阅读并签收规章制度
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* 顶部 Header — 安全区 padding 适配刘海屏 */}
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-gray-200 pt-safe">
|
||||
<div className="max-w-md mx-auto h-14 flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Logo className="w-6 h-6 text-primary flex-shrink-0" />
|
||||
<span className="text-sm font-bold text-gray-900 truncate">企业用工专家</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{employee.name && (
|
||||
<span className="text-xs text-gray-500 truncate max-w-[60px]">{employee.name}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 px-2 py-1.5 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 max-w-md mx-auto w-full px-4 py-6 pb-28">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* 底部固定 TabBar — 安全区 padding 适配 home indicator */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-30 bg-white border-t border-gray-200 pb-safe">
|
||||
<div className="max-w-md mx-auto flex items-center justify-around h-16">
|
||||
{tabItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = location.pathname === item.path ||
|
||||
(item.path === '/portal/payslip' && location.pathname.startsWith('/portal/payslip'))
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors ${
|
||||
active ? 'text-primary' : 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${active ? 'fill-primary/10' : ''}`} />
|
||||
<span className={`text-xs ${active ? 'font-medium' : ''}`}>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 区 */}
|
||||
<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" />
|
||||
<Logo className="w-5 h-5 text-primary" />
|
||||
<span className="font-bold text-sm text-gray-900">企业用工专家</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<any>({
|
||||
queryKey: ['dashboard'],
|
||||
@@ -38,8 +40,16 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
|
||||
{/* 右侧:帮助 + 通知 + 设置 + 用户菜单 */}
|
||||
{/* 右侧:员工端 + 帮助 + 通知 + 设置 + 用户菜单 */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setPortalQROpen(true)}
|
||||
className="p-1.5 rounded-md hover:bg-gray-100"
|
||||
aria-label="员工端入口"
|
||||
>
|
||||
<Smartphone className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<PortalQRModal open={portalQROpen} onClose={() => setPortalQROpen(false)} />
|
||||
<button
|
||||
onClick={() => setHelpOpen(true)}
|
||||
className="p-1.5 rounded-md hover:bg-gray-100"
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Button({ variant = 'primary', size = 'md', className, ch
|
||||
'bg-primary text-white hover:bg-primary-dark': variant === 'primary',
|
||||
'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary',
|
||||
'bg-danger text-white hover:bg-red-700': variant === 'danger',
|
||||
'px-2.5 py-1 text-xs': size === 'sm',
|
||||
'px-3 py-1.5 text-xs': size === 'sm',
|
||||
'px-4 py-2 text-sm': size === 'md',
|
||||
'px-5 py-2.5 text-base': size === 'lg',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 应用 Logo 图标 — 与 favicon.svg 保持一致
|
||||
*/
|
||||
|
||||
interface LogoProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Logo({ className = 'w-5 h-5' }: LogoProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={className} fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="currentColor" />
|
||||
<path d="M16 6 L24 10 L24 16 L16 20 L8 16 L8 10 Z" stroke="white" strokeWidth="1.5" strokeLinejoin="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" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<rect x="10" y="24" width="12" height="2" rx="1" fill="white" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user