feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { PenTool, Check, AlertCircle } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function ContractConfirm() {
const [params] = useSearchParams()
const token = params.get('token') || ''
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [agreed, setAgreed] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [confirmed, setConfirmed] = useState(false)
const [verifyCode, setVerifyCode] = useState('')
const [sendingCode, setSendingCode] = useState(false)
const [codeSent, setCodeSent] = useState(false)
const [devCode, setDevCode] = useState('')
useEffect(() => {
if (token) {
api.get(`/portal/contract-confirm/${token}`).then((res: any) => {
setData(res.data)
}).catch((err: any) => {
setError(err.response?.data?.error?.message || '链接无效或已过期')
}).finally(() => setLoading(false))
} else {
setError('缺少 token 参数')
setLoading(false)
}
}, [token])
const handleSendCode = async () => {
setSendingCode(true)
setError('')
try {
const res = await api.post('/portal/contract-confirm/send-code', { token }) as any
setCodeSent(true)
setDevCode(res.data?.data?.code || '')
} catch (err: any) {
setError(err.response?.data?.error?.message || '验证码发送失败')
} finally {
setSendingCode(false)
}
}
const handleConfirm = async () => {
setSubmitting(true)
try {
await api.post('/portal/contract-confirm', { token, agreed: true, verifyCode })
setConfirmed(true)
} catch (err: any) {
setError(err.response?.data?.error?.message || '确认失败')
} finally {
setSubmitting(false)
}
}
if (confirmed) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-sm w-full text-center">
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
<h1 className="text-sm font-semibold mb-2"></h1>
<p className="text-sm text-gray-500"> IP </p>
</div>
</div>
)
}
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 gap-2 mb-6">
<PenTool className="w-6 h-6 text-primary" />
<h1 className="text-sm font-semibold"></h1>
</div>
{loading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : error ? (
<Card>
<div className="flex items-center gap-2 text-danger">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
</Card>
) : data ? (
<Card>
<div className="space-y-3">
<div className="text-sm text-gray-600">
{data.orgName} {data.employeeName}
</div>
{data.contract && (
<div className="space-y-2 text-sm">
<Row label="合同类型" value={data.contract.contractType === 'FIXED' ? '固定期限' : data.contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
{data.contract.contractYears > 0 && <Row label="合同期限" value={`${data.contract.contractYears}`} />}
<Row label="合同开始" value={new Date(data.contract.startDate).toISOString().slice(0, 10)} />
{data.contract.endDate && <Row label="合同结束" value={new Date(data.contract.endDate).toISOString().slice(0, 10)} />}
{data.contract.probationMonths > 0 && <Row label="试用期" value={`${data.contract.probationMonths}个月`} />}
{data.contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(data.contract.probationSalary))}`} />}
</div>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
</label>
{/* 验证码区域 */}
{agreed && (
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="请输入6位验证码"
maxLength={6}
className="flex-1 px-3 py-2 rounded-md border border-gray-300 text-sm"
/>
<button
onClick={handleSendCode}
disabled={sendingCode || codeSent}
className="px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50 whitespace-nowrap"
>
{sendingCode ? '发送中' : codeSent ? '已发送' : '发送验证码'}
</button>
</div>
{devCode && (
<div className="text-xs text-blue-500">{devCode}</div>
)}
</div>
)}
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting || !verifyCode}>
{submitting ? '确认中...' : '确认签署'}
</Button>
<div className="text-xs text-gray-400 text-center">📌 IP </div>
</div>
</Card>
) : null}
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<span className="text-gray-500">{label}</span>
<span className="font-medium">{value}</span>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
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 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' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export default function MyContract() {
const [resending, setResending] = useState(false)
const [resendMsg, setResendMsg] = useState('')
const { data, isLoading } = useQuery<any>({
queryKey: ['my-contract'],
queryFn: async () => {
const res = await portalApi.get('/contract') as any
return res.data?.data ?? null
},
})
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
const contract = data
const daysToExpire = contract?.endDate
? Math.floor((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null
const isConfirmed = contract?.attachmentName?.startsWith('confirmed:')
const handleResend = async () => {
setResending(true)
setResendMsg('')
try {
const res = await portalApi.post('/contract-confirm/resend', { contractId: contract?.id }) as any
setResendMsg(res.data?.data?.message || '重发成功')
} catch (err: any) {
setResendMsg(err.response?.data?.error?.message || '重发失败')
} finally {
setResending(false)
}
}
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>
<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>
</div>
)}
</Card>
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<span className="text-gray-500">{label}</span>
<span className="font-medium">{value}</span>
</div>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { useState, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ClipboardList, Check, FileText, X } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
const FILE_TYPES = [
{ key: 'ID_CARD_FRONT', label: '身份证正面' },
{ key: 'ID_CARD_BACK', label: '身份证反面' },
{ key: 'EDUCATION', label: '学历证明' },
{ key: 'BANK_CARD', label: '银行卡照片' },
{ key: 'OTHER', label: '其他材料' },
]
interface UploadedFile {
fileType: string
fileName: string
fileUrl: string
fileSize: number
}
export default function Onboarding() {
const [params] = useSearchParams()
const token = params.get('token') || ''
const [orgName, setOrgName] = useState('')
const [loading, setLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const [currentFileType, setCurrentFileType] = useState('ID_CARD_FRONT')
const [form, setForm] = useState({
name: '',
phone: '',
idCard: '',
emergencyContact: '',
emergencyPhone: '',
address: '',
bankCard: '',
bankName: '',
})
// 获取链接信息
useState(() => {
if (token) {
api.get(`/portal/onboarding/${token}`).then((res: any) => {
setOrgName(res.data.orgName)
}).catch((err: any) => {
setError(err.response?.data?.error?.message || '链接无效')
})
}
})
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setError('')
try {
const formData = new FormData()
formData.append('file', file)
formData.append('fileType', currentFileType)
const res = await api.post(`/portal/onboarding/${token}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}) as any
setUploadedFiles([...uploadedFiles, res.data.data])
} catch (err: any) {
setError(err.response?.data?.error?.message || '文件上传失败')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const removeFile = (idx: number) => {
setUploadedFiles(uploadedFiles.filter((_, i) => i !== idx))
}
const handleSubmit = async () => {
setError('')
setLoading(true)
try {
await api.post('/portal/onboarding', { ...form, token, attachments: uploadedFiles })
setSubmitted(true)
} catch (err: any) {
setError(err.response?.data?.error?.message || '提交失败')
} finally {
setLoading(false)
}
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-sm w-full text-center">
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
<h1 className="text-sm font-semibold mb-2"></h1>
<p className="text-sm text-gray-500">HR </p>
</div>
</div>
)
}
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 gap-2 mb-6">
<ClipboardList className="w-6 h-6 text-primary" />
<h1 className="text-sm font-semibold"></h1>
</div>
{orgName && (
<div className="mb-4 text-sm text-gray-600">
{orgName}
</div>
)}
{error && (
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
)}
<Card>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="请输入姓名" />
</div>
<div>
<Label> *</Label>
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="请输入手机号" maxLength={11} />
</div>
<div>
<Label> *</Label>
<Input value={form.idCard} onChange={(e) => setForm({ ...form, idCard: e.target.value })} placeholder="请输入身份证号" maxLength={18} />
</div>
<div>
<Label></Label>
<Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" />
</div>
<div>
<Label></Label>
<Input type="tel" value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" maxLength={11} />
</div>
<div>
<Label></Label>
<Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" />
</div>
<div>
<Label></Label>
<Input value={form.bankCard} onChange={(e) => setForm({ ...form, bankCard: e.target.value })} placeholder="选填" />
</div>
<div>
<Label></Label>
<Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" />
</div>
{/* 文件上传区域 */}
<div className="border-t pt-3">
<Label></Label>
<div className="flex flex-wrap gap-2 mb-2">
{FILE_TYPES.map((ft) => (
<button
key={ft.key}
type="button"
onClick={() => setCurrentFileType(ft.key)}
className={`px-2 py-1 rounded text-xs ${currentFileType === ft.key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600'}`}
>
{ft.label}
</button>
))}
</div>
<input
ref={fileInputRef}
type="file"
accept=".jpg,.jpeg,.png,.pdf,.bmp"
onChange={handleFileUpload}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-md text-xs text-gray-500 hover:border-primary"
>
{uploading ? '上传中...' : `点击上传${FILE_TYPES.find(f => f.key === currentFileType)?.label || ''}`}
</button>
{uploadedFiles.length > 0 && (
<div className="mt-2 space-y-1">
{uploadedFiles.map((f, i) => (
<div key={i} className="flex items-center justify-between px-2 py-1 bg-gray-50 rounded text-xs">
<div className="flex items-center gap-1 min-w-0">
<FileText className="w-3 h-3 flex-shrink-0 text-gray-400" />
<span className="truncate">{FILE_TYPES.find(ft => ft.key === f.fileType)?.label || f.fileType}: {f.fileName}</span>
</div>
<button onClick={() => removeFile(i)} className="text-gray-400 hover:text-danger flex-shrink-0">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
<Button className="w-full" onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.idCard}>
{loading ? '提交中...' : '提交'}
</Button>
<div className="text-xs text-gray-400 text-center">📌 HR </div>
</div>
</Card>
</div>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
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 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' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export default function Payslip() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [showHistory, setShowHistory] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['payslip', month],
queryFn: async () => {
const res = await portalApi.get('/payslip', { params: { month } }) as any
return res.data?.data ?? null
},
})
const { data: history } = useQuery<any[]>({
queryKey: ['payslip-history'],
queryFn: async () => {
const res = await portalApi.get('/payslip/history') as any
return res.data?.data ?? []
},
})
const confirmMutation = useMutation({
mutationFn: (id: string) => portalApi.post(`/payslip/${id}/confirm`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip'] }),
})
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
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.confirmedAt ? '已确认' : '未确认',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `工资条_${employee.name || '员工'}_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
const sortedHistory = [...(history || [])].sort((a: any, b: any) => a.month.localeCompare(b.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>
<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"
/>
<button
onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium"
>
<TrendingUp className="w-4 h-4" />
</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"
>
<Download className="w-4 h-4" />
</button>
</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>
<span className="text-xs font-medium w-20 text-right">¥{fmt(Number(p.totalPay))}</span>
</div>
))}
</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>
</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>
{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>
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Building2 } from 'lucide-react'
import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
type LoginMode = 'password' | 'code'
export default function PortalLogin() {
const navigate = useNavigate()
const [mode, setMode] = useState<LoginMode>('password')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [codeSent, setCodeSent] = useState(false)
const [displayedCode, setDisplayedCode] = useState('')
const handlePasswordLogin = async () => {
setError('')
setLoading(true)
try {
const res = await api.post('/portal/login', { phone, password }) as any
localStorage.setItem('portalToken', res.data.token)
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
navigate('/portal/payslip')
} catch (err: any) {
setError(err.response?.data?.error?.message || '登录失败')
} finally {
setLoading(false)
}
}
const handleSendCode = async () => {
setError('')
try {
const res = await api.post('/portal/send-code', { phone }) as any
setCodeSent(true)
setDisplayedCode(res.data.code)
} catch (err: any) {
setError(err.response?.data?.error?.message || '发送失败')
}
}
const handleCodeLogin = async () => {
setError('')
setLoading(true)
try {
const res = await api.post('/portal/verify-code', { phone, code }) as any
localStorage.setItem('portalToken', res.data.token)
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
navigate('/portal/payslip')
} catch (err: any) {
setError(err.response?.data?.error?.message || '登录失败')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 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" />
<span className="text-base font-bold"> </span>
</div>
<div className="card">
<div className="flex border-b mb-4">
<button
onClick={() => { setMode('password'); setError('') }}
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'password' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
></button>
<button
onClick={() => { setMode('code'); setError('') }}
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'code' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
></button>
</div>
{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>
<Label></Label>
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
</div>
<div>
<Label></Label>
<Input type="password" placeholder="请输入密码" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<Button className="w-full" onClick={handlePasswordLogin} disabled={loading || !phone || !password}>
{loading ? '登录中...' : '登录'}
</Button>
</div>
) : (
<div className="space-y-3">
<div>
<Label></Label>
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={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} />
</div>
<div className="flex items-end">
<Button variant="secondary" onClick={handleSendCode} disabled={!phone || codeSent}>
{codeSent ? '已发送' : '获取验证码'}
</Button>
</div>
</div>
{codeSent && displayedCode && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
{displayedCode}
</div>
)}
<Button className="w-full" onClick={handleCodeLogin} disabled={loading || !phone || !code}>
{loading ? '登录中...' : '登录'}
</Button>
</div>
)}
</div>
</div>
</div>
)
}