feat: 员工门户UI优化 - PortalLayout/Logo组件/QR扫码登录/合同与政策页面重构
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+22
-12
@@ -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 (
|
||||
<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 (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<main className="max-w-md mx-auto py-6 px-4">
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
<PortalLayout>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</PortalLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,12 +113,13 @@ export default function App() {
|
||||
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
{/* 员工端 */}
|
||||
<Route path="/portal/login" element={<PortalLayout><PortalLogin /></PortalLayout>} />
|
||||
<Route path="/portal/payslip" element={<PortalLayout><Payslip /></PortalLayout>} />
|
||||
<Route path="/portal/contract" element={<PortalLayout><MyContract /></PortalLayout>} />
|
||||
<Route path="/portal/onboarding" element={<PortalLayout><Onboarding /></PortalLayout>} />
|
||||
<Route path="/portal/contract-confirm" element={<PortalLayout><ContractConfirm /></PortalLayout>} />
|
||||
<Route path="/portal/policies" element={<PortalLayout><MyPolicies /></PortalLayout>} />
|
||||
<Route path="/portal/login" element={<PortalLayoutWrapper showNav={false}><PortalLogin /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/payslip" element={<PortalLayoutWrapper><Payslip /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/contract" element={<PortalLayoutWrapper><MyContract /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/onboarding" element={<PortalLayoutWrapper showNav={false}><Onboarding /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/contract-confirm" element={<PortalLayoutWrapper showNav={false}><ContractConfirm /></PortalLayoutWrapper>} />
|
||||
<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 />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -8,11 +8,20 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
* {
|
||||
@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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
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 { Input, Label } from '../../components/ui/Input'
|
||||
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="w-full max-w-sm">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
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 { zodResolver } from '@hookform/resolvers/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="w-full max-w-sm">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
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 { zodResolver } from '@hookform/resolvers/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="w-full max-w-sm">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FileText, AlertCircle, Check, RefreshCw } from 'lucide-react'
|
||||
import { FileText, AlertCircle, Check, RefreshCw, Calendar, Briefcase, Clock } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
/** 金额格式化:保留两位小数 + 千分位 */
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
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 daysToExpire = contract?.endDate
|
||||
@@ -52,79 +50,110 @@ export default function MyContract() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-sm font-semibold">我的劳动合同</h1>
|
||||
<div className="space-y-4">
|
||||
{/* 页面标题 */}
|
||||
<h1 className="text-lg font-bold text-gray-900">我的劳动合同</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<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 className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||||
<Link to="/portal/payslip" className="text-sm text-primary">工资条</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !contract ? (
|
||||
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
|
||||
) : (
|
||||
<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>
|
||||
</Card>
|
||||
) : !contract ? (
|
||||
<Card className="p-6">
|
||||
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* 到期提醒横幅 */}
|
||||
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
|
||||
<div className="flex items-start gap-2 px-4 py-3 rounded-xl bg-amber-50 text-amber-700 text-sm">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<span>您的合同还有 <strong>{daysToExpire}</strong> 天到期,请关注续签事宜</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
function InfoRow({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
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 Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
import PortalNav from './PortalNav'
|
||||
|
||||
const portalApi = api.create({ baseURL: '/api/v1/portal' })
|
||||
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) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<div className="max-w-md mx-auto py-6 px-4">
|
||||
<PortalNav />
|
||||
<button
|
||||
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>
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />返回列表
|
||||
</button>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : detail ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{detailLoading ? (
|
||||
<Card className="p-6">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-6 bg-gray-100 rounded w-2/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" />
|
||||
<h1 className="text-base font-semibold">{detail.title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs text-gray-500">
|
||||
<div className="flex-1 min-w-0">
|
||||
<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) || '-'}
|
||||
</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>
|
||||
{detail.hasRead ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 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-1 px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700">
|
||||
<Clock className="w-3 h-3" />待签收
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed mb-6">
|
||||
{detail.content || '暂无内容'}
|
||||
</div>
|
||||
<div className="border-t pt-4">
|
||||
{detail.hasRead ? (
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
您已于 {detail.readAt?.slice(0, 19).replace('T', ' ')} 签收确认
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => readMutation.mutate(detail.id)}
|
||||
disabled={readMutation.isPending}
|
||||
>
|
||||
{readMutation.isPending ? '确认中...' : '我已阅读并理解,确认签收'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs bg-amber-100 text-amber-700">
|
||||
<Clock className="w-3.5 h-3.5" />待签收
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 制度正文 */}
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed mb-6">
|
||||
{detail.content || '暂无内容'}
|
||||
</div>
|
||||
|
||||
{/* 签收操作 */}
|
||||
<div className="border-t pt-4">
|
||||
{detail.hasRead ? (
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
您已于 {detail.readAt?.slice(0, 19).replace('T', ' ')} 签收确认
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => readMutation.mutate(detail.id)}
|
||||
disabled={readMutation.isPending}
|
||||
>
|
||||
{readMutation.isPending ? '确认中...' : '我已阅读并理解,确认签收'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<EmptyState title="制度不存在" description="该制度可能已被撤回" />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 列表页 */
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<div className="max-w-md mx-auto py-6 px-4">
|
||||
<PortalNav />
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">规章制度</h1>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<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 className="space-y-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold text-gray-900">规章制度</h1>
|
||||
{pendingCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs bg-amber-100 text-amber-700">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{pendingCount} 项待签收
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { DollarSign, Check, TrendingUp, Download } from 'lucide-react'
|
||||
import { Check, TrendingUp, Download, Wallet, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
/** 金额格式化:保留两位小数 + 千分位 */
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
/** 员工端 API 实例(自动携带 portalToken) */
|
||||
const portalApi = api.create({ baseURL: '/api/v1/portal' })
|
||||
portalApi.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('portalToken')
|
||||
@@ -45,16 +45,19 @@ export default function Payslip() {
|
||||
|
||||
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 = () => {
|
||||
if (!history || history.length === 0) return
|
||||
const headers = ['月份', '基本工资', '加班费', '津贴', '扣款', '应发合计', '确认状态']
|
||||
const rows = history.map((p: any) => [
|
||||
p.month,
|
||||
p.baseSalary,
|
||||
p.overtimePay,
|
||||
p.allowance,
|
||||
p.deduction,
|
||||
p.totalPay,
|
||||
p.month, p.baseSalary, p.overtimePay, p.allowance, p.deduction, p.totalPay,
|
||||
p.confirmedAt ? '已确认' : '未确认',
|
||||
])
|
||||
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
|
||||
@@ -67,120 +70,145 @@ export default function Payslip() {
|
||||
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)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-sm font-semibold">我的工资条</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||||
<Link to="/portal/contract" className="text-sm text-primary">我的合同</Link>
|
||||
</div>
|
||||
</div>
|
||||
/** 工资明细行 */
|
||||
const SalaryRow = ({ label, value, danger }: { label: string; value: number; danger?: boolean }) => {
|
||||
if (!value || value === 0) return null
|
||||
return (
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<span className={`text-sm font-medium ${danger ? 'text-red-500' : 'text-gray-800'}`}>
|
||||
{danger ? '-' : ''}¥{fmt(Math.abs(Number(value)))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-lg font-bold text-gray-900 flex-shrink-0">我的工资条</h1>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
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
|
||||
onClick={handleExport}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory && sortedHistory.length > 0 && (
|
||||
<Card className="mb-4">
|
||||
<h3 className="text-xs font-medium mb-3">近 {sortedHistory.length} 个月工资趋势</h3>
|
||||
<div className="space-y-2">
|
||||
{sortedHistory.map((p: any) => (
|
||||
<div key={p.id} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 w-16 flex-shrink-0">{p.month}</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-5 relative overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all"
|
||||
style={{ width: `${(Number(p.totalPay) / maxPay) * 100}%` }}
|
||||
/>
|
||||
{/* 月份选择器 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-xl px-3 py-3 shadow-sm">
|
||||
<button onClick={() => handleMonthChange(-1)} className="p-2 -ml-1 rounded-md hover:bg-gray-100 active:bg-gray-200">
|
||||
<ChevronLeft className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
<span className="text-base font-semibold text-gray-900">{month.replace('-', '年')}月</span>
|
||||
<button onClick={() => handleMonthChange(1)} className="p-2 -mr-1 rounded-md hover:bg-gray-100 active:bg-gray-200">
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
{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>
|
||||
<span className="text-xs font-medium w-20 text-right">¥{fmt(Number(p.totalPay))}</span>
|
||||
</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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !data ? (
|
||||
<EmptyState title="暂无工资条" description={`该月份(${month})暂无工资记录`} />
|
||||
) : (
|
||||
<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>
|
||||
{/* 确认状态 */}
|
||||
<Card className="p-4">
|
||||
{data.confirmedAt ? (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
{data.allowance > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<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 className="font-medium">已确认查收</div>
|
||||
<div className="text-xs text-gray-400">{new Date(data.confirmedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.confirmedAt ? (
|
||||
<div className="flex items-center gap-2 text-sm text-safe">
|
||||
<Check className="w-4 h-4" /> 已确认({new Date(data.confirmedAt).toLocaleString()})
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => confirmMutation.mutate(data.id)}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{confirmMutation.isPending ? '确认中...' : '确认已阅'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => confirmMutation.mutate(data.id)}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{confirmMutation.isPending ? '确认中...' : '确认已阅'}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import Logo from '../../components/ui/Logo'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
@@ -60,11 +60,11 @@ export default function PortalLogin() {
|
||||
}
|
||||
|
||||
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="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-base font-bold">企业用工专家 — 员工端</span>
|
||||
<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>
|
||||
|
||||
<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>}
|
||||
|
||||
{mode === 'password' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<Button className="w-full" onClick={handlePasswordLogin} disabled={loading || !phone || !password}>
|
||||
<Button className="w-full h-11" onClick={handlePasswordLogin} disabled={loading || !phone || !password}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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 className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<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 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 ? '已发送' : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export default function PortalLogin() {
|
||||
验证码:{displayedCode}(开发阶段直接显示,生产环境将发送短信)
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleCodeLogin} disabled={loading || !phone || !code}>
|
||||
<Button className="w-full h-11" onClick={handleCodeLogin} disabled={loading || !phone || !code}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user