feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全
- 面包屑导航组件,集成至TopNav header - 侧边栏菜单分组间距增大,分组间分隔线 - 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计 - 修复Policies.tsx民主程序推进bug(字段名/API路径/参数) - 用工文本模板变量名英文转中文显示 - 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY) - 通知示例数据补充 - h2标题统一为text-sm font-medium - 新增run.md
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ScrollText, Search, Filter } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
|
||||
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: '通知设置',
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统操作日志页面
|
||||
*/
|
||||
export default function AuditLog() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
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 params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (action) params.set('action', action)
|
||||
if (entity) params.set('entity', entity)
|
||||
if (dateFrom) params.set('dateFrom', dateFrom)
|
||||
if (dateTo) params.set('dateTo', dateTo)
|
||||
const res = await api.get(`/audit?${params}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery<any>({
|
||||
queryKey: ['audit-stats'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/audit/stats') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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 && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{typeof log.detail === 'object' ? JSON.stringify(log.detail).slice(0, 100) : String(log.detail).slice(0, 100)}
|
||||
</div>
|
||||
)}
|
||||
</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={(s) => { setPageSize(s); setPage(1) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user