Files
TurboHR/frontend/src/pages/Settings.tsx
T

1390 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool } from 'lucide-react'
import { settingsApi, notificationsApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import { useConfirm } from '../hooks/useConfirm'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'import' | 'export'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: async () => {
return await settingsApi.org()
},
})
const { data: usersData } = useQuery<any>({
queryKey: ['users'],
queryFn: async () => {
return await settingsApi.users()
},
})
const updateOrgMutation = useMutation({
mutationFn: (data: any) => settingsApi.updateOrg(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['org-settings'] }),
})
const sections = [
{ key: 'org' as const, label: '企业信息', icon: Building2 },
{ key: 'users' as const, label: '用户管理', icon: Users },
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
{ key: 'export' as const, label: '数据导出', icon: Download },
]
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
<div className="flex gap-1 border-b">
{sections.map((s) => {
const Icon = s.icon
return (
<button
key={s.key}
onClick={() => setActiveSection(s.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeSection === s.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{s.label}
</button>
)
})}
</div>
{activeSection === 'org' && (
<OrgSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} saving={updateOrgMutation.isPending} />
)}
{activeSection === 'users' && <UserSettings usersData={usersData} />}
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
{activeSection === 'notifications' && <NotificationSettings />}
{activeSection === 'retirement' && (
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
)}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && <ExportSettings />}
</div>
)
}
function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: any) => void; saving: boolean }) {
const [pageSize, setPageSize] = useState(getPageSize())
const [form, setForm] = useState({
name: '',
contactName: '',
contactPhone: '',
payrollDays: [5] as number[],
payrollReminderDays: 3,
retirementReminderEnabled: false,
esignPolicyEnabled: false,
esignPayslipEnabled: false,
esignOnboardingEnabled: false,
esignTrainingEnabled: false,
esignPerformanceEnabled: false,
esignDisciplinaryEnabled: false,
})
useEffect(() => {
if (orgData) {
setForm({
name: orgData.name || '',
contactName: orgData.contactName || '',
contactPhone: orgData.contactPhone || '',
payrollDays: Array.isArray(orgData.payrollDays) && orgData.payrollDays.length > 0 ? orgData.payrollDays : [5],
payrollReminderDays: orgData.payrollReminderDays ?? 3,
retirementReminderEnabled: orgData.retirementReminderEnabled || false,
esignPolicyEnabled: orgData.esignPolicyEnabled || false,
esignPayslipEnabled: orgData.esignPayslipEnabled || false,
esignOnboardingEnabled: orgData.esignOnboardingEnabled || false,
esignTrainingEnabled: orgData.esignTrainingEnabled || false,
esignPerformanceEnabled: orgData.esignPerformanceEnabled || false,
esignDisciplinaryEnabled: orgData.esignDisciplinaryEnabled || false,
})
}
}, [orgData])
return (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<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 value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} placeholder="联系人姓名" />
</div>
<div>
<Label></Label>
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
</div>
<div>
<Label></Label>
<p className="text-xs text-gray-500 mt-1 mb-2"></p>
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<button
key={day}
type="button"
onClick={() => {
const days = form.payrollDays.includes(day)
? form.payrollDays.filter(d => d !== day)
: [...form.payrollDays, day].sort((a, b) => a - b)
setForm({ ...form, payrollDays: days })
}}
className={`w-9 h-9 rounded-md text-xs font-medium border transition-colors ${
form.payrollDays.includes(day)
? 'bg-primary text-white border-primary'
: 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'
}`}
>
{day}
</button>
))}
</div>
{form.payrollDays.length > 0 && (
<p className="text-xs text-gray-500 mt-2"> {form.payrollDays.map(d => `${d}`).join('、')} </p>
)}
</div>
<div>
<Label></Label>
<div className="flex items-center gap-3">
<Input type="number" min={0} max={30} value={form.payrollReminderDays} onChange={(e) => setForm({ ...form, payrollReminderDays: Number(e.target.value) })} className="!w-24" />
<span className="text-xs text-gray-500"></span>
</div>
</div>
<Button onClick={() => onSave(form)} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</div>
{/* 电子签署设置 */}
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-4">
<PenTool className="w-5 h-5 text-primary" />
<h3 className="font-medium"></h3>
</div>
<div className="grid grid-cols-2 gap-4">
{([
{ key: 'esignPolicyEnabled', label: '规章制度电子签', desc: '开启后,员工阅读规章制度时需电子签署;关闭时保持阅读确认' },
{ key: 'esignPayslipEnabled', label: '工资条电子签', desc: '开启后,员工确认工资条时需电子签署;关闭时保持点击确认' },
{ key: 'esignOnboardingEnabled', label: '入职文件电子签', desc: '开启后,HR审批通过入职流程时自动创建入职文件签署;关闭时不签' },
{ key: 'esignTrainingEnabled', label: '培训记录电子签', desc: '开启后,员工签收培训记录时需电子签署;关闭时保持点击签收' },
{ key: 'esignPerformanceEnabled', label: '绩效考核电子签', desc: '开启后,员工签字确认绩效时需电子签署;关闭时保持点击确认' },
{ key: 'esignDisciplinaryEnabled', label: '违纪记录电子签', desc: '开启后,员工签字确认违纪记录时需电子签署;关闭时保持点击确认' },
] as const).map((item) => (
<div key={item.key} className="flex items-center justify-between border rounded-lg p-3">
<div className="flex-1 mr-3">
<div className="text-sm font-medium">{item.label}</div>
<p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>
</div>
<button
type="button"
onClick={() => {
const next = !form[item.key]
setForm({ ...form, [item.key]: next })
onSave({ [item.key]: next })
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${form[item.key] ? 'bg-primary' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${form[item.key] ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
))}
</div>
</div>
{/* 显示设置 */}
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-4">
<LayoutGrid className="w-5 h-5 text-primary" />
<h3 className="font-medium"></h3>
</div>
<div className="space-y-4">
<div>
<Label></Label>
<p className="text-xs text-gray-500 mt-1 mb-2"></p>
<div className="flex items-center gap-3">
<Select value={String(pageSize)} onChange={(e) => setPageSize(parseInt(e.target.value, 10))} className="!w-32">
<option value="10">10 /</option>
<option value="20">20 /</option>
<option value="50">50 /</option>
<option value="100">100 /</option>
</Select>
<Button size="sm" onClick={() => { setGlobalPageSize(pageSize); toast.success(`分页大小已设置为 ${pageSize} 条/页`) }}></Button>
</div>
</div>
</div>
</div>
</Card>
)
}
function RetirementSettings({ orgData, onSave }: { orgData: any; onSave: (data: any) => void }) {
const queryClient = useQueryClient()
const [confirming, setConfirming] = useState(false)
const enabled = orgData?.retirementReminderEnabled || false
const { data: policyData, isLoading } = useQuery<any>({
queryKey: ['retirement-policy'],
queryFn: async () => {
return await settingsApi.retirementPolicy()
},
enabled,
})
const confirmMutation = useMutation({
mutationFn: (id: string) => settingsApi.confirmRetirementPolicy(id),
onSuccess: () => {
toast.success('退休政策已确认生效')
setConfirming(false)
queryClient.invalidateQueries({ queryKey: ['retirement-policy'] })
},
onError: () => toast.error('确认失败'),
})
const confirmed = policyData?.confirmed
const pending = policyData?.pending
const renderPolicyRules = () => (
<div className="space-y-2">
<div className="text-xs font-medium text-gray-600">退 退</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
<div className="text-xs text-gray-500"></div>
<div className="text-sm font-semibold text-gray-700">60 63</div>
<div className="text-xs text-gray-400">41</div>
</div>
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
<div className="text-xs text-gray-500"></div>
<div className="text-sm font-semibold text-gray-700">55 58</div>
<div className="text-xs text-gray-400">41</div>
</div>
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
<div className="text-xs text-gray-500"></div>
<div className="text-sm font-semibold text-gray-700">50 55</div>
<div className="text-xs text-gray-400">21</div>
</div>
</div>
<p className="text-xs text-gray-400">退退</p>
</div>
)
return (
<Card>
<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>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-sm text-gray-500">{enabled ? '已开启' : '未开启'}</span>
<button
type="button"
onClick={() => onSave({ retirementReminderEnabled: !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>
{renderPolicyRules()}
</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>
{renderPolicyRules()}
</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>}
</div>
)}
</Card>
)
}
function UserSettings({ usersData }: { usersData: any }) {
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)
const [editingUser, setEditingUser] = useState<any>(null)
const users = usersData || []
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => settingsApi.updateUser(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
const toggleDisableMutation = useMutation({
mutationFn: (id: string) => settingsApi.toggleDisable(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
return (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
</tr>
</thead>
<tbody>
{users.map((u: any) => (
<tr key={u.id} className="border-b last:border-0">
<td className="py-3 px-3 font-medium">{u.name}</td>
<td className="py-3 px-3 text-gray-600">{u.phone}</td>
<td className="py-3 px-3">
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{u.role === 'ADMIN' ? '管理员' : u.role === 'HR' ? 'HR' : '查看者'}
</span>
</td>
<td className="py-3 px-3">
<span className={u.disabled ? 'text-danger' : 'text-safe'}>
{u.disabled ? '已禁用' : '正常'}
</span>
</td>
<td className="py-3 px-3 text-gray-500">
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
</td>
<td className="py-3 px-3">
<div className="flex gap-2">
<button className="text-primary hover:underline" onClick={() => setEditingUser(u)}></button>
<button
className={u.disabled ? 'text-safe hover:underline' : 'text-danger hover:underline'}
onClick={() => toggleDisableMutation.mutate(u.id)}
>
{u.disabled ? '启用' : '禁用'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<AddUserModal open={showAddModal} onClose={() => setShowAddModal(false)} />
<EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSave={(data) => { updateMutation.mutate({ id: editingUser.id, data }); setEditingUser(null) }} />
</Card>
)
}
function EditUserModal({ user, onClose, onSave }: { user: any; onClose: () => void; onSave: (data: any) => void }) {
const [form, setForm] = useState({ name: '', phone: '', role: 'HR' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (user) {
setForm({ name: user.name || '', phone: user.phone || '', role: user.role || 'HR' })
setError('')
}
}, [user])
const handleSubmit = async () => {
setLoading(true)
setError('')
try {
onSave(form)
} catch (err: any) {
setError(err.response?.data?.error?.message || '保存失败')
} finally {
setLoading(false)
}
}
if (!user) return null
return (
<Modal open={!!user} onClose={onClose} title="编辑用户">
<div className="space-y-3">
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} maxLength={11} />
</div>
<div>
<Label></Label>
<Select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="HR">HR</option>
<option value="ADMIN"></option>
<option value="VIEWER"></option>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.phone}>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
function AddUserModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [form, setForm] = useState({ name: '', phone: '', password: '', role: 'HR' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async () => {
setLoading(true)
setError('')
try {
await settingsApi.addUser(form)
onClose()
} catch (err: any) {
setError(err.response?.data?.error?.message || '添加失败')
} finally {
setLoading(false)
}
}
return (
<Modal open={open} onClose={onClose} title="添加用户">
<div className="space-y-3">
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} maxLength={11} />
</div>
<div>
<Label> *</Label>
<Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
</div>
<div>
<Label></Label>
<Select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="HR">HR</option>
<option value="ADMIN"></option>
<option value="VIEWER"></option>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.password}>
{loading ? '添加中...' : '添加'}
</Button>
</div>
</div>
</Modal>
)
}
function ExportSettings() {
const [format, setFormat] = useState<'json' | 'excel'>('json')
const [mask, setMask] = useState(false)
const [gzip, setGzip] = useState(true)
const [exporting, setExporting] = useState(false)
const [selectedModules, setSelectedModules] = useState<Record<string, boolean>>({
employees: true, contracts: true, terminations: true, payrollBatches: true,
payslips: true, socialRecords: true, housingRecords: true, riskItems: true,
})
const moduleLabels: Record<string, string> = {
employees: '员工信息', contracts: '劳动合同', terminations: '离职记录',
payrollBatches: '发薪批次', payslips: '工资条', socialRecords: '社保记录',
housingRecords: '公积金记录', riskItems: '风险项',
}
const handleExport = async () => {
setExporting(true)
try {
const token = useAuthStore.getState().accessToken
const modules = Object.keys(selectedModules).filter(k => selectedModules[k]).join(',')
const params = new URLSearchParams({ format, mask: String(mask), modules })
if (format === 'json' && !gzip) params.set('gzip', 'false')
const res = await fetch(`/api/v1/export/all?${params}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const ext = format === 'excel' ? 'xlsx' : (gzip ? 'json.gz' : 'json')
a.download = `export-${new Date().toISOString().slice(0, 10)}.${ext}`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('导出失败')
}
}
return (
<div className="space-y-3">
<p className="text-xs text-gray-500"></p>
<div className="grid grid-cols-2 gap-2">
{Object.keys(moduleLabels).map(key => (
<label key={key} className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={selectedModules[key]}
onChange={(e) => setSelectedModules({ ...selectedModules, [key]: e.target.checked })}
/>
{moduleLabels[key]}
</label>
))}
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-xs">
<input type="radio" checked={format === 'json'} onChange={() => setFormat('json')} />
JSON
</label>
<label className="flex items-center gap-2 text-xs">
<input type="radio" checked={format === 'excel'} onChange={() => setFormat('excel')} />
Excel
</label>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={mask} onChange={(e) => setMask(e.target.checked)} />
</label>
{format === 'json' && (
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={gzip} onChange={(e) => setGzip(e.target.checked)} />
Gzip
</label>
)}
</div>
<Button variant="secondary" size="sm" onClick={handleExport} disabled={exporting}>
<Download className="w-4 h-4 mr-1" />{exporting ? '导出中...' : '导出选中数据'}
</Button>
{/* 验收测试结果导出 */}
<div className="border-t border-gray-200 pt-3 mt-3">
<div className="flex items-center gap-2 mb-2">
<ClipboardList className="w-4 h-4 text-primary" />
<h3 className="text-sm font-medium"></h3>
</div>
<p className="text-xs text-gray-500 mb-2"></p>
<Button variant="secondary" size="sm" onClick={handleAcceptanceExport}>
<Download className="w-4 h-4 mr-1" />MD
</Button>
</div>
</div>
)
}
/** 导出验收测试结果为 Markdown 文档 */
function handleAcceptanceExport() {
try {
const PREFIX = 'turbohr_acceptance_'
const keys = Object.keys(localStorage).filter(k => k.startsWith(PREFIX))
if (keys.length === 0) {
toast.info('暂无已保存的验收结果,请先在顶部「验收测试」中填写并保存')
return
}
const resultMap: Record<string, string> = { pass: '✅通过', fail: '❌失败', partial: '⚠️部分通过', untested: '🚫未测试' }
let md = `# TurboHR 验收测试结果\n\n`
md += `> **导出时间:** ${new Date().toLocaleString('zh-CN')}\n`
md += `> **验收人数:** ${keys.length}\n\n`
for (const key of keys) {
const raw = localStorage.getItem(key)
if (!raw) continue
const data = JSON.parse(raw)
const name = data.verifier || key.substring(PREFIX.length)
const results: Record<string, string> = data.results || {}
const remarks: Record<string, string> = data.remarks || {}
const values = Object.values(results)
const pass = values.filter(v => v === 'pass').length
const fail = values.filter(v => v === 'fail').length
const partial = values.filter(v => v === 'partial').length
const untested = values.filter(v => v === 'untested' || !v).length
const total = values.length
const tested = pass + fail + partial
const rate = total > 0 ? Math.round(tested / total * 100) : 0
md += `## 验收人:${name}\n\n`
if (data.savedAt) md += `> 保存时间:${new Date(data.savedAt).toLocaleString('zh-CN')}\n`
md += `\n### 统计\n\n`
md += `| 指标 | 数值 |\n|------|------|\n`
md += `| 总项数 | ${total} |\n`
md += `| 通过 | ${pass} |\n`
md += `| 失败 | ${fail} |\n`
md += `| 部分通过 | ${partial} |\n`
md += `| 未测试 | ${untested} |\n`
md += `| 完成率 | ${rate}% |\n\n`
if (data.conclusionResult) {
const crText: Record<string, string> = { pass: '通过', conditional: '有条件通过', fail: '不通过' }
md += `### 验收结论\n\n`
md += `- **总体结论:** ${crText[data.conclusionResult] || '未选择'}\n`
if (data.conclusionDate) md += `- **验收日期:** ${data.conclusionDate}\n`
if (data.conclusionIssues) md += `- **遗留问题:**\n${data.conclusionIssues}\n`
md += `\n`
}
md += `### 明细\n\n`
md += `| # | 结果 | 备注 |\n|---|:----:|------|\n`
for (const [fid, result] of Object.entries(results)) {
md += `| ${fid} | ${resultMap[result] || '🚫未测试'} | ${remarks[fid] || ''} |\n`
}
md += `\n`
}
md += `\n---\n*由企业用工专家系统自动生成*\n`
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `acceptance-results-${new Date().toISOString().slice(0, 10)}.md`
a.click()
URL.revokeObjectURL(url)
toast.success(`已导出 ${keys.length} 位验收人的测试结果`)
} catch {
toast.error('导出验收结果失败')
}
}
function PlanSettings({ orgData }: { orgData: any }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const plan = orgData?.plan || 'FREE'
const { data: usageData } = useQuery<any>({
queryKey: ['usage'],
queryFn: async () => {
return await settingsApi.usage()
},
})
const planMutation = useMutation({
mutationFn: (newPlan: string) => settingsApi.updatePlan(newPlan),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['org'] })
queryClient.invalidateQueries({ queryKey: ['usage'] })
},
})
const plans = [
{ key: 'FREE', label: '免费版', price: '¥0/月', features: ['10人以内', '基础风险检测', '10次AI问答/月'] },
{ key: 'PRO', label: '专业版', price: '¥299/月', features: ['100人以内', '全功能风险检测', '100次AI问答/月', '合同审查'] },
{ key: 'ENTERPRISE', label: '企业版', price: '联系客服', features: ['无限人数', '无限AI问答', '专属客服', 'API接入'] },
]
return (
<div className="space-y-4">
{usageData && (
<Card>
<h3 className="text-xs font-medium text-gray-700 mb-3"></h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs">
<div>
<div className="text-gray-500"></div>
<div className="font-medium text-base">{usageData.employeeCount}<span className="text-gray-500 text-xs">/{usageData.maxEmployees}</span></div>
</div>
<div>
<div className="text-gray-500">AI </div>
<div className="font-medium text-base">{usageData.aiConversations}</div>
</div>
<div>
<div className="text-gray-500"></div>
<div className="font-medium text-base">{usageData.contracts}</div>
</div>
</div>
</Card>
)}
<div className="grid md:grid-cols-3 gap-4">
{plans.map((p) => (
<Card key={p.key}>
<div className={`px-4 py-3 rounded-t-lg ${plan === p.key ? 'bg-primary text-white' : 'bg-gray-50'}`}>
<div className="font-medium">{p.label}</div>
<div className={`text-base font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
</div>
<div className="p-4 space-y-2">
{p.features.map((f, i) => (
<div key={i} className="text-xs text-gray-600 flex items-center gap-2">
<span className="text-safe"></span> {f}
</div>
))}
<div className="pt-2">
{plan === p.key ? (
<div className="text-xs text-center text-primary font-medium"></div>
) : (
<Button
variant="secondary"
className="w-full"
size="sm"
onClick={async () => {
if (await confirm({ title: '切换套餐', message: `确定切换到${p.label}`, variant: 'primary' })) planMutation.mutate(p.key)
}}
disabled={planMutation.isPending}
>
{planMutation.isPending ? '切换中...' : '升级'}
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</div>
)
}
function NotificationSettings() {
const queryClient = useQueryClient()
const [form, setForm] = useState<any>({})
const { data: setting } = useQuery<any>({
queryKey: ['notification-settings'],
queryFn: async () => {
return await notificationsApi.settings()
},
})
useEffect(() => {
if (setting) setForm(setting)
}, [setting])
const updateMutation = useMutation({
mutationFn: (data: any) => notificationsApi.updateSettings(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-settings'] }),
})
const testWechatMutation = useMutation({
mutationFn: () => notificationsApi.test('wechat') as any,
onSuccess: (res: any) => {
toast.success(res.success ? res.data.message : (res.error?.message || '测试失败'))
},
})
const testEmailMutation = useMutation({
mutationFn: () => notificationsApi.test('email') as any,
onSuccess: (res: any) => {
toast.success(res.success ? res.data.message : (res.error?.message || '测试失败'))
},
})
return (
<div className="space-y-3">
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-3">
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.contractExpiry ?? true} onChange={(e) => setForm({ ...form, contractExpiry: e.target.checked })} />
</label>
<div>
<Label></Label>
<Input type="number" value={form.expiryDays ?? 30} onChange={(e) => setForm({ ...form, expiryDays: Number(e.target.value) })} />
</div>
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.contractUnsigned ?? true} onChange={(e) => setForm({ ...form, contractUnsigned: e.target.checked })} />
</label>
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.overtimeAlert ?? true} onChange={(e) => setForm({ ...form, overtimeAlert: e.target.checked })} />
</label>
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.payslipReady ?? true} onChange={(e) => setForm({ ...form, payslipReady: e.target.checked })} />
</label>
<div className="border-t pt-3 space-y-3">
<div className="text-xs font-medium"></div>
<div className="text-xs text-gray-500"></div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.socialInsDay ?? 15} onChange={(e) => setForm({ ...form, socialInsDay: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.housingFundDay ?? 15} onChange={(e) => setForm({ ...form, housingFundDay: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.taxDay ?? 15} onChange={(e) => setForm({ ...form, taxDay: Number(e.target.value) })} />
</div>
</div>
</div>
<div className="border-t pt-3">
<Label> Webhook</Label>
<div className="flex gap-2">
<Input value={form.wechatWebhook || ''} onChange={(e) => setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
<Button variant="secondary" size="sm" onClick={() => testWechatMutation.mutate()} disabled={testWechatMutation.isPending || !form.wechatWebhook}>
{testWechatMutation.isPending ? '测试中...' : '测试'}
</Button>
</div>
</div>
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.emailNotify ?? false} onChange={(e) => setForm({ ...form, emailNotify: e.target.checked })} />
</label>
{form.emailNotify && (
<div>
<Label></Label>
<div className="flex gap-2">
<Input value={form.email || ''} onChange={(e) => setForm({ ...form, email: e.target.value || null })} placeholder="hr@example.com" />
<Button variant="secondary" size="sm" onClick={() => testEmailMutation.mutate()} disabled={testEmailMutation.isPending || !form.email}>
{testEmailMutation.isPending ? '测试中...' : '测试'}
</Button>
</div>
</div>
)}
<Button onClick={() => updateMutation.mutate(form)} disabled={updateMutation.isPending}>
{updateMutation.isPending ? '保存中...' : '保存设置'}
</Button>
</div>
</Card>
</div>
)
}
export function ImportSettings() {
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
return (
<div className="space-y-3">
<div className="flex gap-1 border-b">
<button
onClick={() => setImportType('init')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${importType === 'init' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setImportType('monthly')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${importType === 'monthly' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
</button>
</div>
{importType === 'init' ? <InitImport /> : <MonthlyImport />}
</div>
)
}
function InitImport() {
const queryClient = useQueryClient()
const [file, setFile] = useState<File | null>(null)
const [result, setResult] = useState<any>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [preview, setPreview] = useState<any>(null)
const [previewing, setPreviewing] = useState(false)
const [previewTab, setPreviewTab] = useState('employees')
const handlePreview = async () => {
if (!file) return
setPreviewing(true)
setError('')
setPreview(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/v1/import/excel/preview', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '预览失败')
} else {
setPreview(data.data)
}
} catch (e: any) {
setError(e?.message || '预览失败')
} finally {
setPreviewing(false)
}
}
const handleExportErrors = async () => {
if (!preview?.errors?.length) return
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/excel/error-log', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ errors: preview.errors }),
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `import-errors-${Date.now()}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('导出错误日志失败')
}
}
const handleUpload = async () => {
if (!file) return
setUploading(true)
setError('')
setResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/v1/import/excel', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '导入失败')
toast.error(data.error?.message || '导入失败')
} else {
setResult(data.data)
if (data.data.errors?.length > 0) {
toast.warning(`导入完成,但有 ${data.data.errors.length} 条错误,请查看详情`)
} else {
toast.success(`成功导入员工 ${data.data.employees}`)
}
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
} catch (e: any) {
setError(e?.message || '上传失败')
} finally {
setUploading(false)
}
}
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '员工导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('下载模板失败')
}
}
return (
<div className="space-y-3">
<Card>
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-gray-700 mb-1"></h3>
<p className="text-xs text-gray-500">使//</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={handlePreview} disabled={!file || previewing}>
{previewing ? '预览中...' : '预览数据'}
</Button>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError(''); setPreview(null) }} className="hidden" id="import-file-init" />
<label htmlFor="import-file-init" className="cursor-pointer text-xs text-primary hover:underline">
{file ? file.name : '点击选择 Excel 文件'}
</label>
</div>
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
{preview && (
<div className="border rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-medium">
{preview.summary?.totalRows || 0} {preview.summary?.normalRows || 0} {preview.summary?.errorRows || 0}
</div>
{preview.errors?.length > 0 && (
<Button variant="secondary" size="sm" onClick={handleExportErrors}>
<Download className="w-4 h-4 mr-1" />
</Button>
)}
</div>
<div className="flex gap-1 border-b">
{['employees', 'contracts', 'overtime', 'disciplinary', 'attendance'].map(tab => {
const labels: any = { employees: '员工信息', contracts: '劳动合同', overtime: '加班记录', disciplinary: '违纪记录', attendance: '考勤记录' }
const rows = preview[tab] || []
if (rows.length === 0) return null
return (
<button key={tab} onClick={() => setPreviewTab(tab)}
className={`px-2 py-1 text-xs border-b-2 ${previewTab === tab ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}>
{labels[tab]} ({rows.length})
</button>
)
})}
</div>
<div className="overflow-x-auto max-h-60 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-1 px-2"></th>
<th className="py-1 px-2"></th>
<th className="py-1 px-2"></th>
<th className="py-1 px-2">/</th>
</tr>
</thead>
<tbody>
{(preview[previewTab] || []).map((row: any, i: number) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 px-2 text-gray-500">{row.rowNo}</td>
<td className="py-1 px-2">{row.name || row.idCard || '—'}</td>
<td className="py-1 px-2">
<span className={row.status === 'error' ? 'text-danger' : row.status === 'warning' ? 'text-amber-600' : 'text-safe'}>
{row.status === 'error' ? '错误' : row.status === 'warning' ? '警告' : '正常'}
</span>
</td>
<td className="py-1 px-2 text-gray-500">
{row.errors?.join('; ') || row.warnings?.join('; ') || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{result && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium"></div>
<div className="flex gap-4">
<span> {result.employees} {result.contracts} </span>
{result.overtime > 0 && <span> {result.overtime} </span>}
{result.disciplinary > 0 && <span> {result.disciplinary} </span>}
{result.attendance > 0 && <span> {result.attendance} </span>}
</div>
{result.skipped > 0 && (
<div className="text-amber-600"> {result.skipped} </div>
)}
{result.duplicates > 0 && (
<div className="text-amber-600"> {result.duplicates} </div>
)}
{result.errors?.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="font-medium text-amber-600 flex items-center justify-between">
<span>{result.errors.length}</span>
<button className="text-xs text-primary hover:underline" onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const errorList = result.errors.map((e: string, i: number) => {
const detail = result.details?.[i] || {}
return { sheet: detail.sheet || '员工信息', row: detail.row || i + 2, name: detail.name || '', errors: [e] }
})
const res = await fetch('/api/v1/import/excel/error-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ errors: errorList }),
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '导入错误日志.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('导出错误日志失败')
}
}}>
</button>
</div>
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
{result.errors.length > 10 && <div className="text-amber-600">... {result.errors.length - 10} </div>}
</div>
)}
{/* 导入明细 */}
{result.details?.filter((d: any) => d.status === 'success').length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="font-medium text-gray-600 mb-1"></div>
<div className="max-h-40 overflow-y-auto">
{result.details.filter((d: any) => d.status === 'success').map((d: any, i: number) => (
<div key={i} className="flex gap-3 text-xs text-gray-500">
<span>{d.row}</span>
<span>{d.name}</span>
{d.employeeId && <span className="text-gray-400">ID: {d.employeeId.slice(0, 8)}...</span>}
<span className="text-safe"> {d.message}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
<div className="flex justify-end">
<Button onClick={handleUpload} disabled={!file || uploading}>
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
</Button>
</div>
</div>
</Card>
<Card>
<div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div className="mt-2 text-gray-500"> Sheet </div>
</div>
</Card>
</div>
)
}
function MonthlyImport() {
const queryClient = useQueryClient()
const [file, setFile] = useState<File | null>(null)
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [result, setResult] = useState<any>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const handleUpload = async () => {
if (!file) return
setUploading(true)
setError('')
setResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
formData.append('month', month)
const res = await fetch('/api/v1/import/monthly', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '导入失败')
toast.error(data.error?.message || '导入失败')
} else {
setResult(data.data)
if (data.data.errors?.length > 0) {
toast.warning(`导入完成,但有 ${data.data.errors.length} 条错误,请查看详情`)
} else {
toast.success('月度数据导入成功')
}
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
} catch (e: any) {
setError(e?.message || '上传失败')
} finally {
setUploading(false)
}
}
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/monthly-template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '月度增减员导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('下载模板失败')
}
}
return (
<div className="space-y-3">
<Card>
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-gray-700 mb-1"></h3>
<p className="text-xs text-gray-500">/</p>
</div>
<div className="flex items-center gap-3">
<div>
<Label></Label>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-40" />
</div>
<div className="pt-5">
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError('') }} className="hidden" id="import-file-monthly" />
<label htmlFor="import-file-monthly" className="cursor-pointer text-xs text-primary hover:underline">
{file ? file.name : '点击选择 Excel 文件'}
</label>
</div>
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
{result && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium">{result.month} </div>
{result.attendance > 0 && <div>{result.attendance} </div>}
{result.overtime > 0 && <div>{result.overtime} </div>}
{result.salaryChanges > 0 && <div>{result.salaryChanges} </div>}
{result.socialInsChanges > 0 && <div>{result.socialInsChanges} </div>}
{result.housingFundChanges > 0 && <div>{result.housingFundChanges} </div>}
{result.strategies && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="font-medium text-gray-600"></div>
{Object.entries(result.strategies).map(([k, v]) => (
<div key={k} className="text-gray-500">{k}{v as string}</div>
))}
</div>
)}
{result.errors?.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="flex items-center justify-between">
<div className="font-medium text-amber-600">{result.errors.length}</div>
<button className="text-xs text-primary hover:underline" onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const errorList = result.errors.map((e: string, i: number) => ({ sheet: '月度导入', row: i + 2, name: '', errors: [e] }))
const res = await fetch('/api/v1/import/excel/error-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ errors: errorList }),
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `import-errors-${Date.now()}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}}></button>
</div>
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
{result.errors.length > 10 && <div className="text-amber-600">... {result.errors.length - 10} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end">
<Button onClick={handleUpload} disabled={!file || uploading}>
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
</Button>
</div>
</div>
</Card>
<Card>
<div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b>//</div>
<div>- <b></b>//</div>
<div className="mt-2 text-gray-500"> Sheet </div>
</div>
</Card>
</div>
)
}