feat: 完成优化1-6全部功能 — Portal安全/AI增强/Settings导入导出/社保公积金版本化
- 优化-1: 社保公积金独立配置+版本化缴费记录+多城市支持+迁移脚本 - 优化-2: AI流式输出/RAG集成/风险角标/审计日志/二维码/批量续签/忘记密码/语音输入/PDF导出/速率限制/套餐人数上限 - 优化-3: 批次重命名/费用实时预览/模拟版本管理/续签合规预检/社保重置/搜索分页/批量解聘/到期预警/税率试算 - 优化-4: 会话历史/待办批量/结果关联档案/风险下钻/预测上下文/附件校验/Tab级联/薪税导出 - 优化-5: 表单回填/用户编辑禁用/导入预览/选择性导出/通知测试/错误日志导出/脱敏导出/gzip压缩 - 优化-6: 工资条确认通知HR/AI上下文增强/电子签名/用量限制修复/入职文件上传/RAG管理/工资趋势/用量事务/验证码加固/审查结构化/链接撤回/超时机制/确认重发/案例转待办
This commit is contained in:
@@ -17,6 +17,10 @@ export default function ContractConfirm() {
|
||||
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) {
|
||||
@@ -31,10 +35,24 @@ export default function ContractConfirm() {
|
||||
}
|
||||
}, [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 })
|
||||
await api.post('/portal/contract-confirm', { token, agreed: true, verifyCode })
|
||||
setConfirmed(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '确认失败')
|
||||
@@ -95,10 +113,36 @@ export default function ContractConfirm() {
|
||||
我已阅读合同内容,确认签署
|
||||
</label>
|
||||
|
||||
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting}>
|
||||
{/* 验证码区域 */}
|
||||
{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 className="text-xs text-gray-400 text-center">📌 确认后将记录签署时间、IP 地址和设备信息</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FileText, AlertCircle, Check } from 'lucide-react'
|
||||
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'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
@@ -16,6 +18,9 @@ portalApi.interceptors.request.use((config: any) => {
|
||||
})
|
||||
|
||||
export default function MyContract() {
|
||||
const [resending, setResending] = useState(false)
|
||||
const [resendMsg, setResendMsg] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['my-contract'],
|
||||
queryFn: async () => {
|
||||
@@ -31,6 +36,21 @@ export default function MyContract() {
|
||||
? 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">
|
||||
@@ -74,13 +94,22 @@ export default function MyContract() {
|
||||
{/* 签署确认记录 */}
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-sm mb-2">签署记录</h3>
|
||||
{contract.attachmentName?.startsWith('confirmed:') ? (
|
||||
{isConfirmed ? (
|
||||
<div className="flex items-center gap-2 text-sm text-safe">
|
||||
<Check className="w-4 h-4" />
|
||||
已确认签署({new Date(contract.attachmentName.slice(10)).toLocaleString()})
|
||||
已确认签署({new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()})
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">暂无签署确认记录</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>
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ClipboardList, Check } from 'lucide-react'
|
||||
import { ClipboardList, Check, Upload, 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') || ''
|
||||
@@ -13,6 +28,10 @@ export default function Onboarding() {
|
||||
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: '',
|
||||
@@ -35,11 +54,36 @@ export default function Onboarding() {
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
await api.post('/portal/onboarding', { ...form, token, attachments: uploadedFiles })
|
||||
setSubmitted(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '提交失败')
|
||||
@@ -113,6 +157,53 @@ export default function Onboarding() {
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { DollarSign, Check } from 'lucide-react'
|
||||
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'
|
||||
@@ -20,6 +20,7 @@ portalApi.interceptors.request.use((config: any) => {
|
||||
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],
|
||||
@@ -29,6 +30,14 @@ export default function Payslip() {
|
||||
},
|
||||
})
|
||||
|
||||
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'] }),
|
||||
@@ -36,6 +45,31 @@ export default function 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">
|
||||
@@ -50,15 +84,48 @@ export default function Payslip() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user