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
+3 -2
View File
@@ -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>
+3 -2
View File
@@ -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>
+3 -2
View File
@@ -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>
+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 { 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>
)
}
+131 -90
View File
@@ -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>
)
}
+129 -101
View File
@@ -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>
)
}
+14 -14
View File
@@ -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>