fix: 全面优化安全、性能和代码质量
P0: 环境变量校验/事务保护/RBAC权限 P1: 花名册分页/密码重置验证码/ErrorBoundary/N+1查询优化 P2: 导出限制/自定义错误类/body大小限制/代码去重 P3: 自定义确认弹窗替换window.confirm
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4">
|
||||
<div className="text-6xl">😵</div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">页面出错了</h2>
|
||||
<p className="text-sm text-gray-500 text-center max-w-md">
|
||||
{this.state.error?.message || '发生了未知错误,请刷新页面重试'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog'
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
variant?: 'danger' | 'primary'
|
||||
}
|
||||
|
||||
interface ConfirmContextValue {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null)
|
||||
|
||||
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
|
||||
const ctx = useContext(ConfirmContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useConfirm must be used within ConfirmProvider')
|
||||
}
|
||||
return ctx.confirm
|
||||
}
|
||||
|
||||
export function useConfirmDialog() {
|
||||
return useConfirm()
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<{
|
||||
open: boolean
|
||||
options: ConfirmOptions
|
||||
resolve?: (value: boolean) => void
|
||||
}>({ open: false, options: { message: '' } })
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setState({ open: true, options, resolve })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
state.resolve?.(true)
|
||||
setState((s) => ({ ...s, open: false, resolve: undefined }))
|
||||
}, [state])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
state.resolve?.(false)
|
||||
setState((s) => ({ ...s, open: false, resolve: undefined }))
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<ConfirmDialog
|
||||
open={state.open}
|
||||
title={state.options.title || '确认操作'}
|
||||
message={state.options.message}
|
||||
confirmLabel={state.options.confirmLabel || '确认'}
|
||||
variant={state.options.variant || 'danger'}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</ConfirmContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import { ConfirmProvider } from './hooks/useConfirm'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -19,7 +21,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -62,6 +63,7 @@ export default function Money() {
|
||||
|
||||
function BatchManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [monthFrom, setMonthFrom] = useState('')
|
||||
const [monthTo, setMonthTo] = useState('')
|
||||
@@ -336,8 +338,8 @@ function BatchManager() {
|
||||
<SettingsIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除批次「${batch.name}」?此操作不可撤销。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
|
||||
deleteBatchMutation.mutate(batch.id)
|
||||
}
|
||||
}}
|
||||
@@ -366,6 +368,7 @@ function BatchManager() {
|
||||
|
||||
function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null)
|
||||
const [editValue, setEditValue] = useState<string>('')
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -554,8 +557,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm('确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。')) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '归档确认', message: '确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。', variant: 'primary' })) {
|
||||
archiveMutation.mutate()
|
||||
}
|
||||
}}
|
||||
@@ -567,8 +570,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除批次「${batch.name}」?此操作不可撤销。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
|
||||
deleteBatchMutation.mutate()
|
||||
}
|
||||
}}
|
||||
@@ -772,6 +775,7 @@ function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: ()
|
||||
|
||||
function TemplateManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<any>(null)
|
||||
const [form, setForm] = useState({
|
||||
@@ -917,8 +921,8 @@ function TemplateManager() {
|
||||
</button>
|
||||
{!item.isDefault && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除薪酬项「${item.name}」?`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '删除薪酬项', message: `确认删除薪酬项「${item.name}」?` })) {
|
||||
deleteMutation.mutate(item.id)
|
||||
}
|
||||
}}
|
||||
@@ -1447,6 +1451,7 @@ function OvertimeCalculator() {
|
||||
|
||||
function PayslipManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
@@ -1517,8 +1522,8 @@ function PayslipManager() {
|
||||
税率试算
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '生成工资条', message: `确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`, variant: 'primary' })) {
|
||||
generateFromBatchMutation.mutate({ month })
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import api from '../lib/api'
|
||||
@@ -27,6 +28,7 @@ type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary'
|
||||
|
||||
export default function Roster() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebouncedValue(search, 300)
|
||||
@@ -476,9 +478,12 @@ export default function Roster() {
|
||||
title="撤回"
|
||||
aria-label={`撤回${e.name}的${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录`}
|
||||
className="rounded-md p-1.5 text-gray-400 transition hover:bg-danger/10 hover:text-danger"
|
||||
onClick={(ev) => {
|
||||
onClick={async (ev) => {
|
||||
ev.stopPropagation()
|
||||
if (e.latestTerminationId && window.confirm(`确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`)) {
|
||||
if (e.latestTerminationId && await confirm({
|
||||
title: '撤回确认',
|
||||
message: `确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`,
|
||||
})) {
|
||||
revokeMutation.mutate(e.latestTerminationId)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } 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 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -12,6 +13,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [base, setBase] = useState(8000)
|
||||
@@ -303,8 +305,8 @@ export default function SocialInsurance() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '重置确认', message: `确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`, variant: 'primary' })) {
|
||||
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -19,6 +19,7 @@ const REASONS = [
|
||||
{ value: 'LAYOFF', label: '公司裁员(经营困难/技术调整等)', legalBasis: '《劳动合同法》第41条' },
|
||||
{ value: 'EXPIRED', label: '合同到期不续签', legalBasis: '《劳动合同法》第44条、第46条' },
|
||||
{ value: 'ILLEGAL', label: '违法解除(赔偿金×2)', legalBasis: '《劳动合同法》第87条' },
|
||||
{ value: 'RESIGNATION', label: '员工主动离职', legalBasis: '《劳动合同法》第37条' },
|
||||
]
|
||||
|
||||
const STEPS = ['选择员工', '解聘方式', '合规检查', '费用结算', '工作交接', '确认提交']
|
||||
@@ -667,6 +668,7 @@ export default function Termination() {
|
||||
onClick={() => handleEditDraft(item)}
|
||||
className="p-1 text-gray-500 hover:text-primary"
|
||||
aria-label="编辑"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -677,6 +679,7 @@ export default function Termination() {
|
||||
onClick={() => { setDraftId(item.id); setView('detail') }}
|
||||
className="p-1 text-safe hover:opacity-70"
|
||||
aria-label="审批通过"
|
||||
title="审批通过"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -684,6 +687,7 @@ export default function Termination() {
|
||||
onClick={() => { setDraftId(item.id); setView('detail') }}
|
||||
className="p-1 text-danger hover:opacity-70"
|
||||
aria-label="驳回"
|
||||
title="驳回"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -694,6 +698,7 @@ export default function Termination() {
|
||||
onClick={() => { setDraftId(item.id); executeMutation.mutate() }}
|
||||
className="p-1 text-primary hover:opacity-70"
|
||||
aria-label="执行"
|
||||
title="执行解聘"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -703,6 +708,7 @@ export default function Termination() {
|
||||
onClick={() => { setDraftId(item.id); cancelMutation.mutate() }}
|
||||
className="p-1 text-gray-400 hover:text-danger"
|
||||
aria-label="撤销"
|
||||
title="撤销"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -711,6 +717,7 @@ export default function Termination() {
|
||||
onClick={() => handleViewDetail(item.id)}
|
||||
className="p-1 text-gray-500 hover:text-primary"
|
||||
aria-label="详情"
|
||||
title="详情"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user