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({ queryKey: ['org-settings'], queryFn: async () => { return await settingsApi.org() }, }) const { data: usersData } = useQuery({ 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 (

系统设置

配置企业资料、用户权限、套餐与通知偏好

{sections.map((s) => { const Icon = s.icon return ( ) })}
{activeSection === 'org' && ( updateOrgMutation.mutate(data)} saving={updateOrgMutation.isPending} /> )} {activeSection === 'users' && } {activeSection === 'plan' && } {activeSection === 'notifications' && } {activeSection === 'retirement' && ( updateOrgMutation.mutate(data)} /> )} {activeSection === 'import' && } {activeSection === 'export' && }
) } 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 (

企业信息

setForm({ ...form, name: e.target.value })} placeholder="企业名称" />
setForm({ ...form, contactName: e.target.value })} placeholder="联系人姓名" />
setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />

设置每月发薪日期(可多选),系统将在工作日历中显示,并提前提醒

{Array.from({ length: 28 }, (_, i) => i + 1).map(day => ( ))}
{form.payrollDays.length > 0 && (

已选:每月 {form.payrollDays.map(d => `${d}号`).join('、')} 发薪

)}
setForm({ ...form, payrollReminderDays: Number(e.target.value) })} className="!w-24" /> 天前在工作台提醒发薪
{/* 电子签署设置 */}

电子签署设置

{([ { 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) => (
{item.label}

{item.desc}

))}
{/* 显示设置 */}

显示设置

设置所有列表页面每页显示的记录条数,保存后立即生效

) } 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({ 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 = () => (
改革规则(基准退休年龄 → 目标退休年龄)
男性职工
60岁 → 63岁
每4个月延1个月
女性干部
55岁 → 58岁
每4个月延1个月
女性工人
50岁 → 55岁
每2个月延1个月

个人退休年龄根据出生年月逐人计算,达到基准退休年龄的时间点不同,延迟月数也不同。

) return (

退休提醒

{enabled && (
{pending && ( )}
)}
{enabled && (
{/* 当前生效政策 */} {confirmed && (
当前生效政策(v{confirmed.version}) 确认于 {new Date(confirmed.confirmedAt).toLocaleDateString('zh-CN')}
{renderPolicyRules()}
)} {/* 待确认政策 */} {pending && (
检测到新政策版本(v{pending.version}),请确认后生效
{renderPolicyRules()}
)} {/* 无政策 */} {!confirmed && !pending && !isLoading && (

暂无退休政策数据,系统将自动获取最新政策

)} {isLoading &&

加载中...

}
)}
) } function UserSettings({ usersData }: { usersData: any }) { const queryClient = useQueryClient() const [showAddModal, setShowAddModal] = useState(false) const [editingUser, setEditingUser] = useState(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 (

用户管理

{users.map((u: any) => ( ))}
姓名 手机号 角色 状态 最近登录 操作
{u.name} {u.phone} {u.role === 'ADMIN' ? '管理员' : u.role === 'HR' ? 'HR' : '查看者'} {u.disabled ? '已禁用' : '正常'} {u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
setShowAddModal(false)} /> setEditingUser(null)} onSave={(data) => { updateMutation.mutate({ id: editingUser.id, data }); setEditingUser(null) }} />
) } 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 (
{error &&
{error}
}
setForm({ ...form, name: e.target.value })} />
setForm({ ...form, phone: e.target.value })} maxLength={11} />
) } 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 (
{error &&
{error}
}
setForm({ ...form, name: e.target.value })} />
setForm({ ...form, phone: e.target.value })} maxLength={11} />
setForm({ ...form, password: e.target.value })} />
) } 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>({ employees: true, contracts: true, terminations: true, payrollBatches: true, payslips: true, socialRecords: true, housingRecords: true, riskItems: true, }) const moduleLabels: Record = { 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 (

选择需要导出的数据模块和格式

{Object.keys(moduleLabels).map(key => ( ))}
{format === 'json' && ( )}
{/* 验收测试结果导出 */}

验收测试结果导出

导出验收测试清单中各验收人的填写结果,包含每项功能的验收结果和备注

) } /** 导出验收测试结果为 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 = { 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 = data.results || {} const remarks: Record = 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 = { 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({ 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 (
{usageData && (

当前用量

员工数
{usageData.employeeCount}/{usageData.maxEmployees}
AI 对话
{usageData.aiConversations}
合同数
{usageData.contracts}
)}
{plans.map((p) => (
{p.label}
{p.price}
{p.features.map((f, i) => (
{f}
))}
{plan === p.key ? (
当前套餐
) : ( )}
))}
) } function NotificationSettings() { const queryClient = useQueryClient() const [form, setForm] = useState({}) const { data: setting } = useQuery({ 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 (

通知设置

setForm({ ...form, expiryDays: Number(e.target.value) })} />
月度事务提醒
设置每月截止日,到期后自动生成待办提醒
setForm({ ...form, socialInsDay: Number(e.target.value) })} />
setForm({ ...form, housingFundDay: Number(e.target.value) })} />
setForm({ ...form, taxDay: Number(e.target.value) })} />
setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
{form.emailNotify && (
setForm({ ...form, email: e.target.value || null })} placeholder="hr@example.com" />
)}
) } export function ImportSettings() { const [importType, setImportType] = useState<'init' | 'monthly'>('init') return (
{importType === 'init' ? : }
) } function InitImport() { const queryClient = useQueryClient() const [file, setFile] = useState(null) const [result, setResult] = useState(null) const [uploading, setUploading] = useState(false) const [error, setError] = useState('') const [preview, setPreview] = useState(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 (

初始化数据导入

首次使用系统时,批量导入已有员工、合同、历史考勤/加班/违纪记录

{ setFile(e.target.files?.[0] || null); setResult(null); setError(''); setPreview(null) }} className="hidden" id="import-file-init" />
{error &&
{error}
} {preview && (
预览:共 {preview.summary?.totalRows || 0} 行,正常 {preview.summary?.normalRows || 0} 行,错误 {preview.summary?.errorRows || 0} 行
{preview.errors?.length > 0 && ( )}
{['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 ( ) })}
{(preview[previewTab] || []).map((row: any, i: number) => ( ))}
行号 姓名 状态 错误/警告
{row.rowNo} {row.name || row.idCard || '—'} {row.status === 'error' ? '错误' : row.status === 'warning' ? '警告' : '正常'} {row.errors?.join('; ') || row.warnings?.join('; ') || '—'}
)} {result && (
导入完成
成功导入:员工 {result.employees} 人、合同 {result.contracts} 份 {result.overtime > 0 && 加班 {result.overtime} 条} {result.disciplinary > 0 && 违纪 {result.disciplinary} 条} {result.attendance > 0 && 考勤 {result.attendance} 条}
{result.skipped > 0 && (
跳过 {result.skipped} 条(数据不完整或格式错误)
)} {result.duplicates > 0 && (
重复 {result.duplicates} 条(身份证号已存在)
)} {result.errors?.length > 0 && (
错误详情({result.errors.length}条):
{result.errors.slice(0, 10).map((e: string, i: number) => (
{e}
))} {result.errors.length > 10 &&
...还有 {result.errors.length - 10} 条,请导出错误日志查看全部
}
)} {/* 导入明细 */} {result.details?.filter((d: any) => d.status === 'success').length > 0 && (
导入明细:
{result.details.filter((d: any) => d.status === 'success').map((d: any, i: number) => (
第{d.row}行 {d.name} {d.employeeId && ID: {d.employeeId.slice(0, 8)}...} ✓ {d.message}
))}
)}
)}
Sheet 页说明
- 员工信息:姓名、部门、性别、手机号、身份证号、入职日期、月工资、社保基数、公积金基数等
- 劳动合同:身份证号(优先匹配)、姓名(备选)、合同类型、签订日期、起止日期、试用期等
- 加班记录:身份证号(优先匹配)、姓名(备选)、日期、加班时长、加班类型
- 违纪记录:身份证号(优先匹配)、姓名(备选)、日期、违纪类型、描述、处罚
- 考勤记录:身份证号(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间
各 Sheet 优先用「身份证号」精确匹配员工,未填身份证号时用「姓名」兑底(重名可能匹配错误)
) } function MonthlyImport() { const queryClient = useQueryClient() const [file, setFile] = useState(null) const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const [result, setResult] = useState(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 (

月度数据导入

每月定期导入当月考勤、加班、薪资调整、社保/公积金增减员变动

setMonth(e.target.value)} className="!w-40" />
{ setFile(e.target.files?.[0] || null); setResult(null); setError('') }} className="hidden" id="import-file-monthly" />
{error &&
{error}
} {result && (
{result.month} 月度导入完成
{result.attendance > 0 &&
考勤记录:{result.attendance} 条
} {result.overtime > 0 &&
加班记录:{result.overtime} 条
} {result.salaryChanges > 0 &&
薪资调整:{result.salaryChanges} 人
} {result.socialInsChanges > 0 &&
社保变动:{result.socialInsChanges} 人
} {result.housingFundChanges > 0 &&
公积金变动:{result.housingFundChanges} 人
} {result.strategies && (
覆盖策略:
{Object.entries(result.strategies).map(([k, v]) => (
{k}:{v as string}
))}
)} {result.errors?.length > 0 && (
部分错误({result.errors.length}条):
{result.errors.slice(0, 10).map((e: string, i: number) => (
{e}
))} {result.errors.length > 10 &&
...还有 {result.errors.length - 10} 条
}
)}
)}
Sheet 页说明
- 考勤记录:身份证号(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间(同日重复导入会覆盖)
- 加班记录:身份证号(优先匹配)、姓名(备选)、日期、加班时长、加班类型(同月重复导入会累加)
- 薪资调整:身份证号(优先匹配)、姓名(备选)、调整后月薪、生效日期、调薪原因(自动关闭旧薪资记录)
- 社保变动:身份证号(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数
- 公积金变动:身份证号(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数
所有 Sheet 优先用「身份证号」精确匹配员工,未填时用「姓名」兑底(重名可能匹配错误)
) }