Files
TurboHR/frontend/src/pages/AuditLog.tsx
T
selfrelease f523f84c18 ux: 全部页面添加操作说明 PageGuide
为以下17个缺少操作说明的页面添加 PageGuide 组件:
- OrgChart: 组织架构管理
- SupportDashboard: 客服工作台
- Money: 薪酬管理
- AIAssistant: AI 智能助手
- Settings: 系统设置
- Calendar: 人事日历
- Templates: 模板管理
- AuditLog: 审计日志
- Notifications: 通知中心
- MedicalPeriodCalculator: 医疗期计算器
- HealthCheck: 用工健康检查
- AnnualValueReport: 年度价值报表
- CompanyFiles: 公司文件管理
- LeaveApproval: 请假审批
- TrainingRecords: 培训记录
- PerformanceRecords: 绩效记录
- DisciplinaryRecords: 违纪记录

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:26:02 +08:00

249 lines
9.6 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 } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery } from '@tanstack/react-query'
import { ScrollText } from 'lucide-react'
import { auditApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
const ACTION_LABELS: Record<string, string> = {
CREATE: '创建',
UPDATE: '更新',
DELETE: '删除',
CORRECT: '更正',
CREATE_EMPLOYEE: '创建员工',
UPDATE_EMPLOYEE: '更新员工',
DELETE_EMPLOYEE: '删除员工',
IMPORT_EMPLOYEES: '导入员工',
BATCH_RENEW: '批量续签',
ADD_CONTRACT: '添加合同',
CREATE_CONTRACT: '创建合同',
SIGN_CONTRACT: '签署合同',
TERMINATE: '解聘',
CREATE_TERMINATION: '创建解聘记录',
APPROVE_TERMINATION: '审批解聘',
REJECT_TERMINATION: '驳回解聘',
EXECUTE_TERMINATION: '执行解聘',
CANCEL_TERMINATION: '撤销解聘',
SUBMIT_TERMINATION: '提交解聘审批',
CREATE_DRAFT: '创建草稿',
REHIRE: '重新入职',
PAYROLL_GENERATE: '生成工资条',
PAYROLL_ARCHIVE: '归档发薪批次',
PAYROLL_PUBLISH: '发布工资条',
EXPORT_PAYROLL: '导出薪税',
AI_CHAT: 'AI 对话',
AI_REVIEW: 'AI 审查',
UPDATE_SETTINGS: '更新设置',
POLICY_ADVANCE: '制度流程推进',
POLICY_PUBLISH: '制度发布',
EVIDENCE_CREATE: '创建证据链',
ATTENDANCE_CONFIRM: '考勤确认',
}
const ENTITY_LABELS: Record<string, string> = {
EMPLOYEE: '员工',
CONTRACT: '合同',
TERMINATION: '解聘',
TERMINATION_RECORD: '解聘记录',
PAYROLL: '薪税',
PAYSLIP: '工资条',
SETTINGS: '设置',
POLICY: '制度',
POLICY_DOCUMENT: '制度文件',
EVIDENCE: '证据链',
AI: 'AI',
ATTENDANCE: '考勤',
DISCIPLINARY: '违纪',
EmployeeSocialInsRecord: '社保记录',
EmployeeHousingFundRecord: '公积金记录',
SocialMonthlyProcess: '社保月度流程',
RetirementPolicy: '退休政策',
NotificationSetting: '通知设置',
}
/** 审计日志 detail 字段中文标签映射 */
const DETAIL_LABELS: Record<string, string> = {
employeeName: '员工',
department: '部门',
name: '姓名',
reason: '原因',
type: '类型',
terminationDate: '离职日期',
compensation: '补偿金',
contractType: '合同类型',
startDate: '开始日期',
endDate: '结束日期',
employeeId: '员工ID',
count: '数量',
years: '续签年限',
hireDate: '入职日期',
oldSalary: '原薪资',
newSalary: '新薪资',
oldDepartment: '原部门',
newDepartment: '新部门',
comment: '备注',
resignationReason: '离职原因',
batch: '批量',
violationType: '违纪类型',
severity: '严重程度',
action: '处理措施',
topic: '培训主题',
trainer: '培训师',
duration: '时长',
period: '考核周期',
score: '得分',
grade: '等级',
reviewer: '考评人',
}
/** 将 detail 对象格式化为可读字符串 */
function formatDetail(detail: any): string {
if (!detail || typeof detail !== 'object') return ''
const entries = Object.entries(detail).filter(([, v]) => v !== '' && v !== null && v !== undefined)
if (entries.length === 0) return ''
return entries.map(([k, v]) => {
const label = DETAIL_LABELS[k] || k
let val = String(v)
if (k === 'compensation' && val && val !== '0') val = `¥${val}`
if (k === 'startDate' || k === 'endDate' || k === 'terminationDate' || k === 'hireDate') {
val = String(v).slice(0, 10)
}
return `${label}: ${val}`
}).join(' | ')
}
/**
* 系统操作日志页面
*/
export default function AuditLog() {
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [action, setAction] = useState('')
const [entity, setEntity] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const { data, isLoading } = useQuery<any>({
queryKey: ['audit-logs', page, pageSize, action, entity, dateFrom, dateTo],
queryFn: async () => {
const p: any = { page, pageSize }
if (action) p.action = action
if (entity) p.entity = entity
if (dateFrom) p.dateFrom = dateFrom
if (dateTo) p.dateTo = dateTo
return await auditApi.list(p)
},
})
const { data: stats } = useQuery<any>({
queryKey: ['audit-stats'],
queryFn: async () => {
return await auditApi.stats()
},
})
return (
<div className="space-y-3">
<PageGuide></PageGuide>
<div className="flex items-center gap-2">
<ScrollText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="text-sm text-gray-500">6</p>
{/* 统计概览 */}
{stats && stats.length > 0 && (
<Card>
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="flex flex-wrap gap-2">
{stats.slice(0, 10).map((s: any) => (
<div key={s.action} className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-gray-50 text-xs">
<span className="text-gray-600">{ACTION_LABELS[s.action] || s.action}</span>
<span className="font-bold text-primary">{s._count.action}</span>
</div>
))}
</div>
</Card>
)}
{/* 筛选栏 */}
<Card>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="text-xs text-gray-500"></label>
<select value={action} onChange={e => { setAction(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary">
<option value=""></option>
{Object.entries(ACTION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-500"></label>
<select value={entity} onChange={e => { setEntity(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary">
<option value=""></option>
{Object.entries(ENTITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="date" value={dateFrom} onChange={e => { setDateFrom(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="date" value={dateTo} onChange={e => { setDateTo(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" />
</div>
{(action || entity || dateFrom || dateTo) && (
<Button size="sm" variant="secondary" onClick={() => { setAction(''); setEntity(''); setDateFrom(''); setDateTo(''); setPage(1) }}></Button>
)}
</div>
</Card>
{/* 日志列表 */}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data?.items || data.items.length === 0 ? (
<EmptyState title="暂无操作日志" description="系统操作将自动记录在此" />
) : (
<>
<Card>
<div className="space-y-1.5">
{data.items.map((log: any) => (
<div key={log.id} className="flex items-start gap-3 px-2 py-2 rounded-md hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/10 text-primary flex-shrink-0">
<ScrollText className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{ACTION_LABELS[log.action] || log.action}</span>
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{ENTITY_LABELS[log.entity] || log.entity}</span>
</div>
{log.detail && (() => { const formatted = formatDetail(log.detail); return formatted ? (
<div className="text-xs text-gray-500 mt-0.5 truncate">
{formatted}
</div>
) : null; })()}
</div>
<div className="text-xs text-gray-400 flex-shrink-0 text-right">
<div>{new Date(log.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
{log.ip && <div className="text-gray-300">{log.ip}</div>}
</div>
</div>
))}
</div>
</Card>
<Pagination
page={page}
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
</div>
)
}