feat: 员工门户UI优化 - PortalLayout/Logo组件/QR扫码登录/合同与政策页面重构
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user