feat: 退休提醒功能 - 身份证提取出生日期、渐进式退休年龄计算、AI流式获取政策、用户确认生效

This commit is contained in:
freedakgmail
2026-07-25 01:21:07 +08:00
parent 169a0e1117
commit b892df1d52
10 changed files with 659 additions and 5 deletions
+3
View File
@@ -905,6 +905,9 @@ function BasicInfo({ profile }: { profile: any }) {
{ label: '开户行', value: profile.bankName || '未填写' },
{ label: '银行账号', value: profile.bankAccount || '未填写' },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
? [{ label: '距退休', value: profile.retirementDaysLeft > 0 ? `${profile.retirementDaysLeft}` : '已到退休年龄' }]
: []),
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
? [{ label: '离职日期', value: profile.terminations
.map((t: any) => t.terminationDate?.toString().slice(0, 10))
+173 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet } from 'lucide-react'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
@@ -94,6 +94,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
contactName: '',
contactPhone: '',
payrollFrequency: 1,
retirementReminderEnabled: false,
})
useEffect(() => {
@@ -103,6 +104,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
contactName: orgData.contactName || '',
contactPhone: orgData.contactPhone || '',
payrollFrequency: orgData.payrollFrequency || 1,
retirementReminderEnabled: orgData.retirementReminderEnabled || false,
})
}
}, [orgData])
@@ -138,10 +140,180 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
</Button>
</div>
<RetirementSection enabled={form.retirementReminderEnabled} onToggle={(v) => { setForm({ ...form, retirementReminderEnabled: v }); onSave({ ...form, retirementReminderEnabled: v }) }} />
</Card>
)
}
function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle: (v: boolean) => void }) {
const queryClient = useQueryClient()
const [confirming, setConfirming] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [streamContent, setStreamContent] = useState('')
const { data: policyData, isLoading } = useQuery<any>({
queryKey: ['retirement-policy'],
queryFn: async () => {
const res = await api.get('/settings/retirement-policy') as any
return res.data
},
enabled,
})
const confirmMutation = useMutation({
mutationFn: (id: string) => api.post(`/settings/retirement-policy/${id}/confirm`),
onSuccess: () => {
toast.success('退休政策已确认生效')
setConfirming(false)
queryClient.invalidateQueries({ queryKey: ['retirement-policy'] })
},
onError: () => toast.error('确认失败'),
})
const handleRefresh = async () => {
setRefreshing(true)
setStreamContent('')
try {
const token = useAuthStore.getState().accessToken
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseUrl}/settings/retirement-policy/refresh`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
})
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buf = ''
while (reader) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const msg = JSON.parse(line.slice(6))
if (msg.type === 'chunk') {
setStreamContent(prev => prev + msg.content)
} else if (msg.type === 'done') {
toast.success(msg.message || '已获取最新政策')
setRefreshing(false)
setStreamContent('')
queryClient.invalidateQueries({ queryKey: ['retirement-policy'] })
} else if (msg.type === 'error') {
toast.error(msg.message || '获取失败')
setRefreshing(false)
setStreamContent('')
}
} catch {}
}
}
}
} catch {
toast.error('获取失败')
setRefreshing(false)
setStreamContent('')
}
}
const confirmed = policyData?.confirmed
const pending = policyData?.pending
return (
<div className="mt-6 pt-6 border-t">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-primary" />
<h3 className="font-medium">退</h3>
{enabled && (
<div className="flex gap-2 ml-4">
{pending && (
<Button size="sm" onClick={() => { setConfirming(true); confirmMutation.mutate(pending.id) }} disabled={confirming}>
{confirming ? '确认中...' : '确认生效'}
</Button>
)}
<Button size="sm" variant="secondary" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className="w-3 h-3 mr-1" />{refreshing ? '获取中...' : '重新获取'}
</Button>
</div>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-sm text-gray-500">{enabled ? '已开启' : '未开启'}</span>
<button
type="button"
onClick={() => onToggle(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
</div>
{enabled && (
<div className="space-y-3">
{/* 当前生效政策 */}
{confirmed && (
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-sm font-medium text-green-800">v{confirmed.version}</span>
<span className="text-xs text-gray-500 ml-auto">
{new Date(confirmed.confirmedAt).toLocaleDateString('zh-CN')}
</span>
</div>
<div className="text-xs text-gray-600 space-y-1">
<div>退<b>{confirmed.maleRetireAge}</b> <b>{confirmed.femaleRetireAge}</b> <b>{confirmed.femaleWorkerAge}</b></div>
<details className="mt-1">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700"></summary>
<div className="mt-2 whitespace-pre-wrap text-xs max-h-48 overflow-y-auto bg-white rounded p-2 border">{confirmed.content}</div>
</details>
</div>
</div>
)}
{/* 待确认政策 */}
{pending && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-orange-600" />
<span className="text-sm font-medium text-orange-800">v{pending.version}</span>
</div>
<div className="text-xs text-gray-600 space-y-1">
<div>退<b>{pending.maleRetireAge}</b> <b>{pending.femaleRetireAge}</b> <b>{pending.femaleWorkerAge}</b></div>
<details className="mt-1">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700"></summary>
<div className="mt-2 whitespace-pre-wrap text-xs max-h-48 overflow-y-auto bg-white rounded p-2 border">{pending.content}</div>
</details>
</div>
</div>
)}
{/* 无政策 */}
{!confirmed && !pending && !isLoading && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-center">
<p className="text-sm text-gray-500">退"重新获取"</p>
</div>
)}
{isLoading && <p className="text-sm text-gray-400 text-center">...</p>}
{/* 流式输出 */}
{streamContent && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center gap-2 mb-2">
<RefreshCw className="w-4 h-4 text-blue-600 animate-spin" />
<span className="text-sm font-medium text-blue-800">AI ...</span>
</div>
<div className="whitespace-pre-wrap text-xs max-h-64 overflow-y-auto bg-white rounded p-2 border">{streamContent}</div>
</div>
)}
</div>
)}
</div>
)
}
function UserSettings({ usersData }: { usersData: any }) {
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)