feat: 社保AI建议、合同附件多文件上传预览、删除合同、扩展上传格式

This commit is contained in:
freedakgmail
2026-07-27 23:45:22 +08:00
parent 71d7ab2de5
commit 5e22a82163
21 changed files with 1246 additions and 178 deletions
+115 -14
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock } from 'lucide-react'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -16,6 +16,8 @@ export default function SocialInsurance() {
const confirm = useConfirm()
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
const [city, setCity] = useState<string>('北京')
const [showAddCity, setShowAddCity] = useState(false)
const [newCityName, setNewCityName] = useState('')
const [base, setBase] = useState(8000)
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
const [showNewVersion, setShowNewVersion] = useState(false)
@@ -161,14 +163,14 @@ export default function SocialInsurance() {
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/calculate', { base }) as any
const res = await api.post('/social/calculate', { base, city }) as any
return res.data
},
})
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/housing-calculate', { base }) as any
const res = await api.post('/social/housing-calculate', { base, city }) as any
return res.data
},
})
@@ -193,6 +195,45 @@ export default function SocialInsurance() {
},
})
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
const res = await api.post('/social/ai-suggest', vars) as any
return res.data
},
onSuccess: (data) => {
if (isHousing) {
activeSetNewVersion({
...activeNewVersion,
baseMin: data.baseMin ?? activeNewVersion.baseMin,
baseMax: data.baseMax ?? activeNewVersion.baseMax,
housingOrg: data.housingOrg ?? activeNewVersion.housingOrg,
housingEmp: data.housingEmp ?? activeNewVersion.housingEmp,
})
} else {
activeSetNewVersion({
...activeNewVersion,
baseMin: data.baseMin ?? activeNewVersion.baseMin,
baseMax: data.baseMax ?? activeNewVersion.baseMax,
medicalBaseMin: data.medicalBaseMin ?? 0,
medicalBaseMax: data.medicalBaseMax ?? 0,
pensionOrg: data.pensionOrg ?? activeNewVersion.pensionOrg,
pensionEmp: data.pensionEmp ?? activeNewVersion.pensionEmp,
medicalOrg: data.medicalOrg ?? activeNewVersion.medicalOrg,
medicalEmp: data.medicalEmp ?? activeNewVersion.medicalEmp,
unemploymentOrg: data.unemploymentOrg ?? activeNewVersion.unemploymentOrg,
unemploymentEmp: data.unemploymentEmp ?? activeNewVersion.unemploymentEmp,
injuryOrg: data.injuryOrg ?? activeNewVersion.injuryOrg,
maternityOrg: data.maternityOrg ?? activeNewVersion.maternityOrg,
extraInsurances: data.extraInsurances ?? [],
})
}
toast.success('AI建议已填入,请核对后保存')
},
onError: () => {
toast.error('AI建议获取失败,请手动填写')
},
})
const previewAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any
@@ -337,16 +378,52 @@ export default function SocialInsurance() {
{(tab === 'social' || tab === 'housing') && (
<div className="flex items-center gap-2 ml-auto">
<label className="text-sm text-gray-500">:</label>
<input
list="social-cities"
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="输入或选择城市"
/>
<datalist id="social-cities">
{cities.map((c) => <option key={c} value={c} />)}
</datalist>
{showAddCity ? (
<div className="flex items-center gap-1">
<input
className="h-9 w-24 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newCityName}
onChange={(e) => setNewCityName(e.target.value)}
placeholder="城市名"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
if (e.key === 'Escape') { setShowAddCity(false); setNewCityName('') }
}}
/>
<button className="h-9 px-2 text-xs text-primary" onClick={() => {
if (newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
}}></button>
<button className="h-9 px-2 text-xs text-gray-400" onClick={() => { setShowAddCity(false); setNewCityName('') }}></button>
</div>
) : (
<div className="flex items-center gap-1">
<select
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={city}
onChange={(e) => setCity(e.target.value)}
>
{cities.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<button
className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-400 hover:text-primary hover:border-primary transition"
title="添加新城市"
onClick={() => setShowAddCity(true)}
>
<MapPin className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
</div>
@@ -415,6 +492,17 @@ export default function SocialInsurance() {
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
<div key={idx} className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">{ins.name}(/)</span>
<span className="font-medium">
{ins.baseType === 'fixed'
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
: `${ins.orgRate}% / ${ins.empRate}%`
}
</span>
</div>
))}
</div>
)}
</Card>
@@ -573,9 +661,22 @@ export default function SocialInsurance() {
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>7</div>
</div>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
onClick={() => aiSuggestMut.mutate({ city: activeNewVersion.city, effectiveFrom: activeNewVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
disabled={aiSuggestMut.isPending}
>
<Sparkles className="w-4 h-4" />
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
</button>
<div className="grid md:grid-cols-3 gap-3">
<div><Label></Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
<div><Label></Label><Input value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} /></div>
<div><Label></Label>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })}>
{[...new Set([city, ...cities])].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
</div>