feat: 员工门户UI优化 - PortalLayout/Logo组件/QR扫码登录/合同与政策页面重构

This commit is contained in:
selfrelease
2026-07-27 14:59:01 +08:00
parent 3f39c56f4b
commit 034fcc4111
18 changed files with 896 additions and 297 deletions
+67
View File
@@ -3,8 +3,10 @@ import bcrypt from 'bcryptjs'
import multer from 'multer' import multer from 'multer'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import jwt from 'jsonwebtoken'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { signAccessToken, verifyAccessToken } from '../lib/jwt' import { signAccessToken, verifyAccessToken } from '../lib/jwt'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema' import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore' import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
import { createEvidence } from '../services/evidence.service' 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 export default router
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<title>企业用工专家</title> <title>企业用工专家</title>
</head> </head>
<body> <body>
+22 -12
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from './store/authStore'
import TopNav from './components/layout/TopNav' import TopNav from './components/layout/TopNav'
import SidebarNav from './components/layout/SidebarNav' import SidebarNav from './components/layout/SidebarNav'
import MobileTabBar from './components/layout/MobileTabBar' import MobileTabBar from './components/layout/MobileTabBar'
import PortalLayout from './components/layout/PortalLayout'
import PageContainer from './components/layout/PageContainer' import PageContainer from './components/layout/PageContainer'
import { SkeletonPage } from './components/ui/Skeleton' import { SkeletonPage } from './components/ui/Skeleton'
import OnboardingGuide from './components/OnboardingGuide' import OnboardingGuide from './components/OnboardingGuide'
@@ -31,6 +32,7 @@ const MyContract = lazy(() => import('./pages/portal/MyContract'))
const Onboarding = lazy(() => import('./pages/portal/Onboarding')) const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm')) const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))
const MyPolicies = lazy(() => import('./pages/portal/MyPolicies')) const MyPolicies = lazy(() => import('./pages/portal/MyPolicies'))
const AutoLogin = lazy(() => import('./pages/portal/AutoLogin'))
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator')) const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator'))
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck')) const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) 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 (
<div className="min-h-screen bg-surface pt-safe pb-safe">
<main className="max-w-md mx-auto py-6 px-4">
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</main>
</div>
)
}
return ( return (
<div className="min-h-screen bg-surface"> <PortalLayout>
<main className="max-w-md mx-auto py-6 px-4"> <Suspense fallback={<SkeletonPage />}>{children}</Suspense>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense> </PortalLayout>
</main>
</div>
) )
} }
@@ -104,12 +113,13 @@ export default function App() {
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} /> <Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
{/* 员工端 */} {/* 员工端 */}
<Route path="/portal/login" element={<PortalLayout><PortalLogin /></PortalLayout>} /> <Route path="/portal/login" element={<PortalLayoutWrapper showNav={false}><PortalLogin /></PortalLayoutWrapper>} />
<Route path="/portal/payslip" element={<PortalLayout><Payslip /></PortalLayout>} /> <Route path="/portal/payslip" element={<PortalLayoutWrapper><Payslip /></PortalLayoutWrapper>} />
<Route path="/portal/contract" element={<PortalLayout><MyContract /></PortalLayout>} /> <Route path="/portal/contract" element={<PortalLayoutWrapper><MyContract /></PortalLayoutWrapper>} />
<Route path="/portal/onboarding" element={<PortalLayout><Onboarding /></PortalLayout>} /> <Route path="/portal/onboarding" element={<PortalLayoutWrapper showNav={false}><Onboarding /></PortalLayoutWrapper>} />
<Route path="/portal/contract-confirm" element={<PortalLayout><ContractConfirm /></PortalLayout>} /> <Route path="/portal/contract-confirm" element={<PortalLayoutWrapper showNav={false}><ContractConfirm /></PortalLayoutWrapper>} />
<Route path="/portal/policies" element={<PortalLayout><MyPolicies /></PortalLayout>} /> <Route path="/portal/policies" element={<PortalLayoutWrapper><MyPolicies /></PortalLayoutWrapper>} />
<Route path="/portal/auto-login" element={<PortalLayoutWrapper showNav={false}><AutoLogin /></PortalLayoutWrapper>} />
{/* 兜底 */} {/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+219
View File
@@ -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, ChevronDown, ChevronRight,
Building2, Building2,
} from 'lucide-react' } from 'lucide-react'
import Logo from '../ui/Logo'
interface NavItem { interface NavItem {
path: string path: string
@@ -120,7 +121,7 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea
> >
{/* Logo 区 */} {/* Logo 区 */}
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0"> <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> <span className="font-bold text-sm text-gray-900"></span>
</div> </div>
+12 -2
View File
@@ -1,17 +1,19 @@
import { Link, useNavigate } from 'react-router-dom' 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 { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import api from '../../lib/api' import api from '../../lib/api'
import Breadcrumb from './Breadcrumb' import Breadcrumb from './Breadcrumb'
import HelpModal from '../HelpModal' import HelpModal from '../HelpModal'
import PortalQRModal from '../PortalQRModal'
export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) { export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [helpOpen, setHelpOpen] = useState(false) const [helpOpen, setHelpOpen] = useState(false)
const [portalQROpen, setPortalQROpen] = useState(false)
const { data: dashboardData } = useQuery<any>({ const { data: dashboardData } = useQuery<any>({
queryKey: ['dashboard'], queryKey: ['dashboard'],
@@ -38,8 +40,16 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
<Breadcrumb /> <Breadcrumb />
</div> </div>
{/* 右侧:帮助 + 通知 + 设置 + 用户菜单 */} {/* 右侧:员工端 + 帮助 + 通知 + 设置 + 用户菜单 */}
<div className="flex items-center gap-2 shrink-0"> <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 <button
onClick={() => setHelpOpen(true)} onClick={() => setHelpOpen(true)}
className="p-1.5 rounded-md hover:bg-gray-100" className="p-1.5 rounded-md hover:bg-gray-100"
+1 -1
View File
@@ -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-primary text-white hover:bg-primary-dark': variant === 'primary',
'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary', 'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary',
'bg-danger text-white hover:bg-red-700': variant === 'danger', '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-4 py-2 text-sm': size === 'md',
'px-5 py-2.5 text-base': size === 'lg', 'px-5 py-2.5 text-base': size === 'lg',
}, },
+19
View File
@@ -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>
)
}
+9
View File
@@ -8,11 +8,20 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 1.5; line-height: 1.5;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
overscroll-behavior-y: none;
} }
* { * {
@apply box-border; @apply box-border;
} }
/* 移动端安全区工具类 */
.pt-safe { padding-top: env(safe-area-inset-top); }
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
.pl-safe { padding-left: env(safe-area-inset-left); }
.pr-safe { padding-right: env(safe-area-inset-right); }
} }
@layer components { @layer components {
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Building2, Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react'
import Logo from '../../components/ui/Logo'
import api from '../../lib/api' import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input' import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button' import Button from '../../components/ui/Button'
@@ -59,7 +60,7 @@ export default function ForgotPassword() {
<div className="min-h-screen flex items-center justify-center bg-surface px-4"> <div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8"> <div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" /> <Logo className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span> <span className="text-xl font-bold"></span>
</div> </div>
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { Building2, Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react'
import Logo from '../../components/ui/Logo'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
@@ -49,7 +50,7 @@ export default function Login() {
<div className="min-h-screen flex items-center justify-center bg-surface px-4"> <div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8"> <div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" /> <Logo className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span> <span className="text-xl font-bold"></span>
</div> </div>
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { Building2, Eye, EyeOff } from 'lucide-react' import { Eye, EyeOff } from 'lucide-react'
import Logo from '../../components/ui/Logo'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
@@ -50,7 +51,7 @@ export default function Register() {
<div className="min-h-screen flex items-center justify-center bg-surface px-4"> <div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8"> <div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" /> <Logo className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span> <span className="text-xl font-bold"></span>
</div> </div>
+80
View File
@@ -0,0 +1,80 @@
/**
* 员工端自动登录页 — 扫码后通过一次性 token 自动登录,无需输入密码
*/
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react'
import api from '../../lib/api'
import Logo from '../../components/ui/Logo'
export default function AutoLogin() {
const [params] = useSearchParams()
const token = params.get('token') || ''
const navigate = useNavigate()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
if (!token) {
setStatus('error')
setErrorMsg('缺少登录凭证')
return
}
api.get('/portal/auto-login', { params: { token } }).then((res: any) => {
const data = res.data?.data || res.data
if (data?.token) {
localStorage.setItem('portalToken', data.token)
localStorage.setItem('portalEmployee', JSON.stringify(data.employee))
setStatus('success')
setTimeout(() => navigate('/portal/payslip'), 1000)
} else {
setStatus('error')
setErrorMsg('登录失败,请重试')
}
}).catch((err: any) => {
setStatus('error')
setErrorMsg(err.response?.data?.error?.message || '登录链接无效或已过期')
})
}, [token, navigate])
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4 pt-safe pb-safe">
<div className="max-w-sm w-full text-center">
<div className="flex flex-col items-center justify-center gap-2 mb-8">
<Logo className="w-10 h-10 text-primary" />
<span className="text-base font-bold text-center"> </span>
</div>
{status === 'loading' && (
<>
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-sm text-gray-600">...</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<p className="text-sm font-medium text-gray-800"></p>
<p className="text-xs text-gray-400 mt-1">...</p>
</>
)}
{status === 'error' && (
<>
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-sm font-medium text-gray-800 mb-2"></p>
<p className="text-xs text-gray-500 mb-4">{errorMsg}</p>
<button
onClick={() => navigate('/portal/login')}
className="text-sm text-primary hover:underline"
>
</button>
</>
)}
</div>
</div>
)
}
+98 -69
View File
@@ -1,13 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { FileText, AlertCircle, Check, RefreshCw, Calendar, Briefcase, Clock } from 'lucide-react'
import { FileText, AlertCircle, Check, RefreshCw } from 'lucide-react'
import api from '../../lib/api' import api from '../../lib/api'
import Card from '../../components/ui/Card' import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button' import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState' import EmptyState from '../../components/ui/EmptyState'
// 金额格式化:保留两位小数 + 千分位 /** 金额格式化:保留两位小数 + 千分位 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const portalApi = api.create({ baseURL: '/api/v1/portal' }) const portalApi = api.create({ baseURL: '/api/v1/portal' })
@@ -29,7 +28,6 @@ export default function MyContract() {
}, },
}) })
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
const contract = data const contract = data
const daysToExpire = contract?.endDate const daysToExpire = contract?.endDate
@@ -52,79 +50,110 @@ export default function MyContract() {
} }
return ( return (
<div className="min-h-screen bg-gray-50 px-4 py-6"> <div className="space-y-4">
<div className="max-w-md mx-auto"> {/* 页面标题 */}
<div className="flex items-center justify-between mb-6"> <h1 className="text-lg font-bold text-gray-900"></h1>
<div className="flex items-center gap-2">
<FileText className="w-6 h-6 text-primary" /> {isLoading ? (
<h1 className="text-sm font-semibold"></h1> <Card className="p-6">
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-100 rounded w-1/2" />
<div className="h-4 bg-gray-100 rounded w-full" />
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-10 bg-gray-100 rounded-lg w-full" />
</div> </div>
<div className="flex items-center gap-3"> </Card>
<span className="text-sm text-gray-500">{employee.name}</span> ) : !contract ? (
<Link to="/portal/payslip" className="text-sm text-primary"></Link> <Card className="p-6">
</div> <EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
</div> </Card>
) : (
<Card> <>
{isLoading ? ( {/* 到期提醒横幅 */}
<div className="text-center py-8 text-gray-400">...</div> {daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
) : !contract ? ( <div className="flex items-start gap-2 px-4 py-3 rounded-xl bg-amber-50 text-amber-700 text-sm">
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" /> <AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
) : ( <span> <strong>{daysToExpire}</strong> </span>
<div className="space-y-3">
{/* 到期提醒 */}
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
<AlertCircle className="w-4 h-4" />
{daysToExpire}
</div>
)}
<div className="space-y-2 text-sm">
<Row label="合同类型" value={contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
<Row label="签订方式" value={contract.signMethod === 'PAPER' ? '纸质合同' : '电子合同'} />
{contract.signDate && <Row label="签订日期" value={new Date(contract.signDate).toISOString().slice(0, 10)} />}
<Row label="合同开始" value={new Date(contract.startDate).toISOString().slice(0, 10)} />
{contract.endDate && <Row label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />}
{contract.contractYears > 0 && <Row label="合同期限" value={`${contract.contractYears}`} />}
{contract.probationMonths > 0 && <Row label="试用期" value={`${contract.probationMonths}个月`} />}
{contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(contract.probationSalary))}`} />}
</div>
{/* 签署确认记录 */}
<div className="border-t pt-3">
<h3 className="font-medium text-sm mb-2"></h3>
{isConfirmed ? (
<div className="flex items-center gap-2 text-sm text-safe">
<Check className="w-4 h-4" />
{new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()}
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-warning">
<AlertCircle className="w-4 h-4" />
</div>
<Button size="sm" variant="secondary" onClick={handleResend} disabled={resending}>
<RefreshCw className="w-3 h-3 mr-1" />{resending ? '重发中...' : '重发确认链接'}
</Button>
{resendMsg && <div className="text-xs text-gray-500">{resendMsg}</div>}
</div>
)}
</div>
</div> </div>
)} )}
</Card>
</div> {/* 合同概览卡片 */}
<Card className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-primary" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 truncate">
{contract.contractType === 'FIXED' ? '固定期限劳动合同' : contract.contractType === 'UNFIXED' ? '无固定期限劳动合同' : '未签订'}
</div>
<div className="text-xs text-gray-500">{contract.signMethod === 'PAPER' ? '纸质合同' : '电子合同'}</div>
</div>
</div>
<div className="space-y-3">
<InfoRow icon={Calendar} label="合同开始" value={new Date(contract.startDate).toISOString().slice(0, 10)} />
{contract.endDate && (
<InfoRow icon={Calendar} label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />
)}
{contract.contractYears > 0 && (
<InfoRow icon={Briefcase} label="合同期限" value={`${contract.contractYears}`} />
)}
{contract.signDate && (
<InfoRow icon={Calendar} label="签订日期" value={new Date(contract.signDate).toISOString().slice(0, 10)} />
)}
{contract.probationMonths > 0 && (
<InfoRow icon={Clock} label="试用期" value={`${contract.probationMonths}个月`} />
)}
{contract.probationSalary > 0 && (
<InfoRow icon={Briefcase} label="试用期工资" value={`¥${fmt(Number(contract.probationSalary))}`} />
)}
</div>
</Card>
{/* 签署确认记录 */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3"></h3>
{isConfirmed ? (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<Check className="w-5 h-5 text-green-600" />
</div>
<div>
<div className="text-sm font-medium text-green-700"></div>
<div className="text-xs text-gray-400">
{new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()}
</div>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-amber-600">
<AlertCircle className="w-4 h-4" />
<span></span>
</div>
<Button size="sm" variant="secondary" onClick={handleResend} disabled={resending}>
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${resending ? 'animate-spin' : ''}`} />
{resending ? '重发中...' : '重发确认链接'}
</Button>
{resendMsg && <div className="text-xs text-gray-500">{resendMsg}</div>}
</div>
)}
</Card>
</>
)}
</div> </div>
) )
} }
function Row({ label, value }: { label: string; value: string }) { function InfoRow({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
return ( return (
<div className="flex justify-between"> <div className="flex items-center justify-between gap-2">
<span className="text-gray-500">{label}</span> <div className="flex items-center gap-2 flex-shrink-0">
<span className="font-medium">{value}</span> <Icon className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500">{label}</span>
</div>
<span className="text-sm font-medium text-gray-800 text-right truncate">{value}</span>
</div> </div>
) )
} }
+131 -90
View File
@@ -5,12 +5,11 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, CheckCircle, Clock, ArrowLeft, ChevronRight } from 'lucide-react' import { FileText, CheckCircle, Clock, ArrowLeft, ChevronRight, ScrollText } from 'lucide-react'
import api from '../../lib/api' import api from '../../lib/api'
import Card from '../../components/ui/Card' import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button' import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState' import EmptyState from '../../components/ui/EmptyState'
import PortalNav from './PortalNav'
const portalApi = api.create({ baseURL: '/api/v1/portal' }) const portalApi = api.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => { portalApi.interceptors.request.use((config: any) => {
@@ -52,109 +51,151 @@ export default function MyPolicies() {
}, },
}) })
/** 待签收数量 */
const pendingCount = (list || []).filter((p: any) => !p.hasRead).length
/** 详情页 */
if (selectedId) { if (selectedId) {
return ( return (
<div className="min-h-screen bg-surface"> <div className="space-y-4">
<div className="max-w-md mx-auto py-6 px-4"> <button
<PortalNav /> onClick={() => setSelectedId(null)}
<button className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2"
onClick={() => setSelectedId(null)} >
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm mb-4" <ArrowLeft className="w-4 h-4" />
> </button>
<ArrowLeft className="w-4 h-4" />
</button>
{detailLoading ? ( {detailLoading ? (
<div className="text-center py-8 text-gray-500">...</div> <Card className="p-6">
) : detail ? ( <div className="animate-pulse space-y-3">
<Card> <div className="h-6 bg-gray-100 rounded w-2/3" />
<div className="flex items-center gap-2 mb-3"> <div className="h-4 bg-gray-100 rounded w-1/3" />
<div className="h-20 bg-gray-100 rounded w-full" />
</div>
</Card>
) : detail ? (
<Card className="p-5">
<div className="flex items-center gap-2 mb-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<FileText className="w-5 h-5 text-primary" /> <FileText className="w-5 h-5 text-primary" />
<h1 className="text-base font-semibold">{detail.title}</h1>
</div> </div>
<div className="flex items-center gap-2 mb-4"> <div className="flex-1 min-w-0">
<span className="text-xs text-gray-500"> <h1 className="text-base font-semibold truncate">{detail.title}</h1>
<div className="text-xs text-gray-500 mt-0.5">
{detail.publishedAt?.slice(0, 10) || '-'} {detail.publishedAt?.slice(0, 10) || '-'}
</div>
</div>
</div>
{/* 签收状态徽章 */}
<div className="mb-4">
{detail.hasRead ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs bg-green-100 text-green-700">
<CheckCircle className="w-3.5 h-3.5" />
</span> </span>
{detail.hasRead ? ( ) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-100 text-green-700"> <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs bg-amber-100 text-amber-700">
<CheckCircle className="w-3 h-3" /> <Clock className="w-3.5 h-3.5" />
</span> </span>
) : ( )}
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700"> </div>
<Clock className="w-3 h-3" />
</span> {/* 制度正文 */}
)} <div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed mb-6">
</div> {detail.content || '暂无内容'}
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed mb-6"> </div>
{detail.content || '暂无内容'}
</div> {/* 签收操作 */}
<div className="border-t pt-4"> <div className="border-t pt-4">
{detail.hasRead ? ( {detail.hasRead ? (
<div className="text-center text-xs text-gray-500"> <div className="text-center text-xs text-gray-500">
{detail.readAt?.slice(0, 19).replace('T', ' ')} {detail.readAt?.slice(0, 19).replace('T', ' ')}
</div> </div>
) : ( ) : (
<Button <Button
className="w-full" className="w-full"
onClick={() => readMutation.mutate(detail.id)} onClick={() => readMutation.mutate(detail.id)}
disabled={readMutation.isPending} disabled={readMutation.isPending}
> >
{readMutation.isPending ? '确认中...' : '我已阅读并理解,确认签收'} {readMutation.isPending ? '确认中...' : '我已阅读并理解,确认签收'}
</Button> </Button>
)} )}
</div> </div>
</Card> </Card>
) : ( ) : (
<Card className="p-6">
<EmptyState title="制度不存在" description="该制度可能已被撤回" /> <EmptyState title="制度不存在" description="该制度可能已被撤回" />
)} </Card>
</div> )}
</div> </div>
) )
} }
/** 列表页 */
return ( return (
<div className="min-h-screen bg-surface"> <div className="space-y-4">
<div className="max-w-md mx-auto py-6 px-4"> {/* 页面标题 */}
<PortalNav /> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 mb-4"> <h1 className="text-lg font-bold text-gray-900"></h1>
<FileText className="w-5 h-5 text-primary" /> {pendingCount > 0 && (
<h1 className="text-base font-semibold"></h1> <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs bg-amber-100 text-amber-700">
</div> <Clock className="w-3.5 h-3.5" />
{pendingCount}
{isLoading ? ( </span>
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无公示制度" description="公司尚未公示任何规章制度" />
) : (
<div className="space-y-2">
{list.map((p: any) => (
<Card key={p.id} className="cursor-pointer hover:shadow-md transition-shadow" >
<div onClick={() => setSelectedId(p.id)} className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{p.title}</span>
{p.hasRead ? (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700">
<CheckCircle className="w-3 h-3" />
</span>
) : (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{p.publishedAt?.slice(0, 10) || '-'}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
</div>
</Card>
))}
</div>
)} )}
</div> </div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<Card key={i} className="p-4">
<div className="animate-pulse flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-xl" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-3 bg-gray-100 rounded w-1/3" />
</div>
</div>
</Card>
))}
</div>
) : !list || list.length === 0 ? (
<Card className="p-6">
<EmptyState title="暂无公示制度" description="公司尚未公示任何规章制度" />
</Card>
) : (
<div className="space-y-2">
{list.map((p: any) => (
<Card key={p.id} className="cursor-pointer hover:shadow-md transition-shadow">
<div onClick={() => setSelectedId(p.id)} className="flex items-center gap-3 p-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
p.hasRead ? 'bg-green-100' : 'bg-amber-100'
}`}>
<ScrollText className={`w-5 h-5 ${p.hasRead ? 'text-green-600' : 'text-amber-600'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate min-w-0">{p.title}</span>
{p.hasRead ? (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700 flex-shrink-0">
<CheckCircle className="w-3 h-3" />
</span>
) : (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700 flex-shrink-0">
<Clock className="w-3 h-3" />
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{p.publishedAt?.slice(0, 10) || '-'}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 flex-shrink-0" />
</div>
</Card>
))}
</div>
)}
</div> </div>
) )
} }
+129 -101
View File
@@ -1,15 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Check, TrendingUp, Download, Wallet, ChevronLeft, ChevronRight } from 'lucide-react'
import { DollarSign, Check, TrendingUp, Download } from 'lucide-react'
import api from '../../lib/api' import api from '../../lib/api'
import Card from '../../components/ui/Card' import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button' import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState' import EmptyState from '../../components/ui/EmptyState'
// 金额格式化:保留两位小数 + 千分位 /** 金额格式化:保留两位小数 + 千分位 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 员工端 API 实例(自动携带 portalToken */
const portalApi = api.create({ baseURL: '/api/v1/portal' }) const portalApi = api.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => { portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken') const token = localStorage.getItem('portalToken')
@@ -45,16 +45,19 @@ export default function Payslip() {
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}') const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
/** 月份切换 */
const handleMonthChange = (delta: number) => {
const d = new Date(month + '-01')
d.setMonth(d.getMonth() + delta)
setMonth(d.toISOString().slice(0, 7))
}
/** 导出 CSV */
const handleExport = () => { const handleExport = () => {
if (!history || history.length === 0) return if (!history || history.length === 0) return
const headers = ['月份', '基本工资', '加班费', '津贴', '扣款', '应发合计', '确认状态'] const headers = ['月份', '基本工资', '加班费', '津贴', '扣款', '应发合计', '确认状态']
const rows = history.map((p: any) => [ const rows = history.map((p: any) => [
p.month, p.month, p.baseSalary, p.overtimePay, p.allowance, p.deduction, p.totalPay,
p.baseSalary,
p.overtimePay,
p.allowance,
p.deduction,
p.totalPay,
p.confirmedAt ? '已确认' : '未确认', p.confirmedAt ? '已确认' : '未确认',
]) ])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n') const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
@@ -67,120 +70,145 @@ export default function Payslip() {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
const sortedHistory = [...(history || [])].sort((a: any, b: any) => a.month.localeCompare(b.month)) const sortedHistory = [...(history || [])].sort((a: any, b: any) => b.month.localeCompare(a.month))
const maxPay = Math.max(...sortedHistory.map((p: any) => Number(p.totalPay) || 0), 1) const maxPay = Math.max(...sortedHistory.map((p: any) => Number(p.totalPay) || 0), 1)
return ( /** 工资明细行 */
<div className="min-h-screen bg-gray-50 px-4 py-6"> const SalaryRow = ({ label, value, danger }: { label: string; value: number; danger?: boolean }) => {
<div className="max-w-md mx-auto"> if (!value || value === 0) return null
<div className="flex items-center justify-between mb-6"> return (
<div className="flex items-center gap-2"> <div className="flex justify-between items-center py-2">
<DollarSign className="w-6 h-6 text-primary" /> <span className="text-sm text-gray-500">{label}</span>
<h1 className="text-sm font-semibold"></h1> <span className={`text-sm font-medium ${danger ? 'text-red-500' : 'text-gray-800'}`}>
</div> {danger ? '-' : ''}¥{fmt(Math.abs(Number(value)))}
<div className="flex items-center gap-3"> </span>
<span className="text-sm text-gray-500">{employee.name}</span> </div>
<Link to="/portal/contract" className="text-sm text-primary"></Link> )
</div> }
</div>
<div className="flex items-center gap-2 mb-4"> return (
<input <div className="space-y-4">
type="month" {/* 页面标题 */}
value={month} <div className="flex items-center justify-between gap-2">
onChange={(e) => setMonth(e.target.value)} <h1 className="text-lg font-bold text-gray-900 flex-shrink-0"></h1>
className="px-3 py-2 rounded-md border border-gray-300 text-sm" <div className="flex items-center gap-1.5 flex-shrink-0">
/>
<button <button
onClick={() => setShowHistory(!showHistory)} onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium" className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors ${
showHistory ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-600'
}`}
> >
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={handleExport} onClick={handleExport}
disabled={!history || history.length === 0} disabled={!history || history.length === 0}
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50" className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-gray-100 text-xs font-medium text-gray-600 disabled:opacity-50"
> >
<Download className="w-4 h-4" /> <Download className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</div>
{showHistory && sortedHistory.length > 0 && ( {/* 月份选择器 */}
<Card className="mb-4"> <div className="flex items-center justify-between bg-white rounded-xl px-3 py-3 shadow-sm">
<h3 className="text-xs font-medium mb-3"> {sortedHistory.length} </h3> <button onClick={() => handleMonthChange(-1)} className="p-2 -ml-1 rounded-md hover:bg-gray-100 active:bg-gray-200">
<div className="space-y-2"> <ChevronLeft className="w-5 h-5 text-gray-400" />
{sortedHistory.map((p: any) => ( </button>
<div key={p.id} className="flex items-center gap-2"> <span className="text-base font-semibold text-gray-900">{month.replace('-', '年')}</span>
<span className="text-xs text-gray-500 w-16 flex-shrink-0">{p.month}</span> <button onClick={() => handleMonthChange(1)} className="p-2 -mr-1 rounded-md hover:bg-gray-100 active:bg-gray-200">
<div className="flex-1 bg-gray-100 rounded-full h-5 relative overflow-hidden"> <ChevronRight className="w-5 h-5 text-gray-400" />
<div </button>
className="bg-primary h-full rounded-full transition-all" </div>
style={{ width: `${(Number(p.totalPay) / maxPay) * 100}%` }}
/> {/* 趋势图 */}
{showHistory && sortedHistory.length > 0 && (
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 mb-3"> {sortedHistory.length} </h3>
<div className="space-y-2.5">
{sortedHistory.map((p: any) => (
<div key={p.id} className="flex items-center gap-1.5 min-w-0">
<span className="text-xs text-gray-400 w-12 flex-shrink-0">{p.month.slice(5)}</span>
<div className="flex-1 bg-gray-100 rounded-full h-6 relative overflow-hidden min-w-0">
<div
className="bg-gradient-to-r from-primary to-primary/70 h-full rounded-full transition-all flex items-center justify-end pr-2"
style={{ width: `${Math.max((Number(p.totalPay) / maxPay) * 100, 8)}%` }}
>
{Number(p.totalPay) / maxPay > 0.4 && (
<span className="text-xs text-white font-medium whitespace-nowrap">¥{fmt(Number(p.totalPay))}</span>
)}
</div> </div>
<span className="text-xs font-medium w-20 text-right">¥{fmt(Number(p.totalPay))}</span>
</div> </div>
))} {Number(p.totalPay) / maxPay <= 0.4 && (
<span className="text-xs font-medium text-gray-600 flex-shrink-0 text-right">¥{fmt(Number(p.totalPay))}</span>
)}
</div>
))}
</div>
</Card>
)}
{/* 工资条卡片 */}
{isLoading ? (
<Card className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-100 rounded-lg w-1/3" />
<div className="h-4 bg-gray-100 rounded w-full" />
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-10 bg-gray-100 rounded-lg w-full" />
</div>
</Card>
) : !data ? (
<Card className="p-6">
<EmptyState title="暂无工资条" description={`${month.replace('-', '年')}月暂无工资记录`} />
</Card>
) : (
<>
{/* 应发合计大卡片 */}
<Card className="p-5 bg-gradient-to-br from-primary to-primary/80 text-white">
<div className="flex items-center gap-2 mb-1">
<Wallet className="w-4 h-4 opacity-80 flex-shrink-0" />
<span className="text-xs opacity-80"></span>
</div>
<div className="text-2xl sm:text-3xl font-bold tracking-tight break-all">¥{fmt(Number(data.totalPay))}</div>
<div className="text-xs opacity-70 mt-1">{month.replace('-', '年')}</div>
</Card>
{/* 工资明细 */}
<Card className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2"></h3>
<div className="divide-y divide-gray-50">
<SalaryRow label="基本工资" value={Number(data.baseSalary)} />
<SalaryRow label="加班费" value={Number(data.overtimePay)} />
<SalaryRow label="津贴" value={Number(data.allowance)} />
<SalaryRow label="扣款" value={Number(data.deduction)} danger />
</div> </div>
</Card> </Card>
)}
<Card> {/* 确认状态 */}
{isLoading ? ( <Card className="p-4">
<div className="text-center py-8 text-gray-400">...</div> {data.confirmedAt ? (
) : !data ? ( <div className="flex items-center gap-2 text-sm text-green-600">
<EmptyState title="暂无工资条" description={`该月份(${month})暂无工资记录`} /> <div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
) : ( <Check className="w-4 h-4" />
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(Number(data.baseSalary))}</span>
</div>
{data.overtimePay > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(Number(data.overtimePay))}</span>
</div>
</div> </div>
)} <div>
{data.allowance > 0 && ( <div className="font-medium"></div>
<div className="flex justify-between text-sm"> <div className="text-xs text-gray-400">{new Date(data.confirmedAt).toLocaleString()}</div>
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(Number(data.allowance))}</span>
</div>
)}
{data.deduction > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium text-danger">-¥{fmt(Number(data.deduction))}</span>
</div>
)}
<div className="border-t pt-3">
<div className="flex justify-between">
<span className="font-medium"></span>
<span className="text-base font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
</div> </div>
</div> </div>
) : (
{data.confirmedAt ? ( <Button
<div className="flex items-center gap-2 text-sm text-safe"> className="w-full"
<Check className="w-4 h-4" /> {new Date(data.confirmedAt).toLocaleString()} onClick={() => confirmMutation.mutate(data.id)}
</div> disabled={confirmMutation.isPending}
) : ( >
<Button {confirmMutation.isPending ? '确认中...' : '确认已阅'}
className="w-full" </Button>
onClick={() => confirmMutation.mutate(data.id)} )}
disabled={confirmMutation.isPending} </Card>
> </>
{confirmMutation.isPending ? '确认中...' : '确认已阅'} )}
</Button>
)}
</div>
)}
</Card>
</div>
</div> </div>
) )
} }
+14 -14
View File
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Building2 } from 'lucide-react' import Logo from '../../components/ui/Logo'
import api from '../../lib/api' import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input' import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button' import Button from '../../components/ui/Button'
@@ -60,11 +60,11 @@ export default function PortalLogin() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4 pt-safe pb-safe">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8"> <div className="flex flex-col items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" /> <Logo className="w-10 h-10 text-primary" />
<span className="text-base font-bold"> </span> <span className="text-base font-bold text-center"> </span>
</div> </div>
<div className="card"> <div className="card">
@@ -82,32 +82,32 @@ export default function PortalLogin() {
{error && <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>} {error && <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
{mode === 'password' ? ( {mode === 'password' ? (
<div className="space-y-3"> <div className="space-y-4">
<div> <div>
<Label></Label> <Label></Label>
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} /> <Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} className="h-11" />
</div> </div>
<div> <div>
<Label></Label> <Label></Label>
<Input type="password" placeholder="请输入密码" value={password} onChange={(e) => setPassword(e.target.value)} /> <Input type="password" placeholder="请输入密码" value={password} onChange={(e) => setPassword(e.target.value)} className="h-11" />
</div> </div>
<Button className="w-full" onClick={handlePasswordLogin} disabled={loading || !phone || !password}> <Button className="w-full h-11" onClick={handlePasswordLogin} disabled={loading || !phone || !password}>
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-4">
<div> <div>
<Label></Label> <Label></Label>
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} /> <Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} className="h-11" />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex-1"> <div className="flex-1">
<Label></Label> <Label></Label>
<Input placeholder="6位验证码" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} /> <Input placeholder="6位验证码" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} className="h-11" />
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<Button variant="secondary" onClick={handleSendCode} disabled={!phone || codeSent}> <Button variant="secondary" onClick={handleSendCode} disabled={!phone || codeSent} className="h-11 whitespace-nowrap">
{codeSent ? '已发送' : '获取验证码'} {codeSent ? '已发送' : '获取验证码'}
</Button> </Button>
</div> </div>
@@ -117,7 +117,7 @@ export default function PortalLogin() {
{displayedCode} {displayedCode}
</div> </div>
)} )}
<Button className="w-full" onClick={handleCodeLogin} disabled={loading || !phone || !code}> <Button className="w-full h-11" onClick={handleCodeLogin} disabled={loading || !phone || !code}>
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</Button> </Button>
</div> </div>