fix: 批次表格锁定列偏移量修正 - table-layout:fixed 确保列宽精确

- 使用 table-layout: fixed 防止列宽被内容压缩
- 动态计算表格总宽: 固定列宽 + 动态列数×90 + 右侧锁定列总宽
- 右侧锁定列偏移量从常量计算,不再硬编码
- 操作列宽度调整为60px,确保移除按钮可见
This commit is contained in:
freedakgmail
2026-08-19 10:14:35 +08:00
parent 41be262dee
commit 81e136b707
+29 -16
View File
@@ -753,6 +753,19 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
// 预置字段集合(固定列存储) // 预置字段集合(固定列存储)
const PRESET_FIELDS = new Set(['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'socialOrg', 'housingEmp', 'housingOrg', 'positionSalary', 'senioritySalary', 'transportAllowance', 'mealAllowance', 'housingAllowance', 'communicationAllowance', 'otherDeduction']) const PRESET_FIELDS = new Set(['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'socialOrg', 'housingEmp', 'housingOrg', 'positionSalary', 'senioritySalary', 'transportAllowance', 'mealAllowance', 'housingAllowance', 'communicationAllowance', 'otherDeduction'])
// 表格列宽计算(table-layout: fixed 需要精确宽度)
const COL_W = { employee: 140, contract: 100, dynamic: 90, netPay: 110, deferred: 110, risk: 50, action: 60 }
const dynamicCols = (templateItems || []).filter((t: any) => (!isBonus || t.code === 'bonus') && t.code !== 'netPay')
const rightLockedWidth = COL_W.netPay + COL_W.deferred + COL_W.risk + (isArchived ? 0 : COL_W.action)
const tableMinWidth = COL_W.employee + COL_W.contract + dynamicCols.length * COL_W.dynamic + rightLockedWidth
// 右侧锁定列偏移量(从右到左累加)
const rightOffsets = {
action: 0,
risk: isArchived ? 0 : COL_W.action,
deferred: isArchived ? COL_W.risk : COL_W.action + COL_W.risk,
netPay: isArchived ? COL_W.risk + COL_W.deferred : COL_W.action + COL_W.risk + COL_W.deferred,
}
// 点击单元格进入编辑 // 点击单元格进入编辑
const startEdit = (employeeId: string, field: string, currentValue: number) => { const startEdit = (employeeId: string, field: string, currentValue: number) => {
if (isArchived || !editableFields.includes(field)) return if (isArchived || !editableFields.includes(field)) return
@@ -1235,27 +1248,27 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
{/* 人员表格 */} {/* 人员表格 */}
<Card> <Card>
<div className="overflow-x-auto" style={{ maxWidth: '100%' }}> <div className="overflow-x-auto" style={{ maxWidth: '100%' }}>
<table className="text-sm" style={{ borderCollapse: 'separate', borderSpacing: 0, minWidth: 1200 }}> <table className="text-sm" style={{ borderCollapse: 'separate', borderSpacing: 0, tableLayout: 'fixed', width: tableMinWidth }}>
<thead> <thead>
<tr className="text-left text-xs text-gray-500"> <tr className="text-left text-xs text-gray-500">
<th className="py-2 px-3 whitespace-nowrap sticky left-0 z-20 bg-white border-r border-gray-200" style={{ width: 140 }}></th> <th className="py-2 px-3 whitespace-nowrap sticky left-0 z-20 bg-white border-r border-gray-200" style={{ width: COL_W.employee }}></th>
<th className="py-2 px-2 whitespace-nowrap sticky z-20 bg-white border-r border-gray-200" style={{ width: 100, left: 140 }}></th> <th className="py-2 px-2 whitespace-nowrap sticky z-20 bg-white border-r border-gray-200" style={{ width: COL_W.contract, left: COL_W.employee }}></th>
{/* 动态生成模板项列(排除 netPay,由固定实发列显示) */} {/* 动态生成模板项列(排除 netPay,由固定实发列显示) */}
{(templateItems || []).filter((t: any) => (!isBonus || t.code === 'bonus') && t.code !== 'netPay').map((t: any) => ( {dynamicCols.map((t: any) => (
<th key={t.code} className={`py-2 px-2 text-right whitespace-nowrap ${t.type === 'CALCULATED' ? 'text-gray-500' : ''}`} style={{ minWidth: 80 }}> <th key={t.code} className={`py-2 px-2 text-right whitespace-nowrap ${t.type === 'CALCULATED' ? 'text-gray-500' : ''}`} style={{ width: COL_W.dynamic }}>
{t.name} {t.name}
</th> </th>
))} ))}
<th className="py-2 px-2 text-right text-gray-500 whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: 110, right: isArchived ? 160 : 210 }}></th> <th className="py-2 px-2 text-right text-gray-500 whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: COL_W.netPay, right: rightOffsets.netPay }}></th>
<th className="py-2 px-2 text-right text-gray-500 whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: 110, right: isArchived ? 50 : 100 }}></th> <th className="py-2 px-2 text-right text-gray-500 whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: COL_W.deferred, right: rightOffsets.deferred }}></th>
<th className="py-2 px-2 text-center whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: 50, right: isArchived ? 0 : 50 }}></th> <th className="py-2 px-2 text-center whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: COL_W.risk, right: rightOffsets.risk }}></th>
{!isArchived && <th className="py-2 px-2 text-center whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: 50, right: 0 }}></th>} {!isArchived && <th className="py-2 px-2 text-center whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: COL_W.action, right: rightOffsets.action }}></th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => ( {batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => (
<tr key={entry.id} className="group border-b last:border-0"> <tr key={entry.id} className="group border-b last:border-0">
<td className="py-2 px-3 sticky left-0 z-10 bg-white group-hover:bg-gray-50 border-r border-gray-200" style={{ width: 140 }}> <td className="py-2 px-3 sticky left-0 z-10 bg-white group-hover:bg-gray-50 border-r border-gray-200" style={{ width: COL_W.employee }}>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<div className="text-xs font-medium">{entry.employee.name}</div> <div className="text-xs font-medium">{entry.employee.name}</div>
{entry.employee.idCardNumber && <span className="text-gray-400 text-[11px] font-mono">{entry.employee.idCardNumber}</span>} {entry.employee.idCardNumber && <span className="text-gray-400 text-[11px] font-mono">{entry.employee.idCardNumber}</span>}
@@ -1265,7 +1278,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)} )}
</div> </div>
</td> </td>
<td className="py-2 px-2 sticky z-10 bg-white group-hover:bg-gray-50 border-r border-gray-200" style={{ width: 100, left: 140 }}> <td className="py-2 px-2 sticky z-10 bg-white group-hover:bg-gray-50 border-r border-gray-200" style={{ width: COL_W.contract, left: COL_W.employee }}>
{(() => { {(() => {
const ct = entry.employee.contracts?.[0]?.contractType const ct = entry.employee.contracts?.[0]?.contractType
const cfg: Record<string, { label: string; style: string }> = { const cfg: Record<string, { label: string; style: string }> = {
@@ -1289,7 +1302,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
})()} })()}
</td> </td>
{/* 动态生成模板项单元格(排除 netPay) */} {/* 动态生成模板项单元格(排除 netPay) */}
{(templateItems || []).filter((t: any) => (!isBonus || t.code === 'bonus') && t.code !== 'netPay').map((t: any) => { {dynamicCols.map((t: any) => {
// 绩效工资:保留特殊渲染(显示绩效等级) // 绩效工资:保留特殊渲染(显示绩效等级)
if (t.code === 'performanceSalary') { if (t.code === 'performanceSalary') {
const perfVal = entry.performanceSalary || 0 const perfVal = entry.performanceSalary || 0
@@ -1354,12 +1367,12 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
return renderCell(entry, t.code, t.type === 'CALCULATED' ? 'font-medium text-gray-600' : '') return renderCell(entry, t.code, t.type === 'CALCULATED' ? 'font-medium text-gray-600' : '')
})} })}
{/* 实发:最低工资保护触发时高亮显示 */} {/* 实发:最低工资保护触发时高亮显示 */}
<td className={`py-2 px-2 text-right font-bold sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200 ${(entry.minWageApplied || 0) > 0 ? 'text-amber-600' : 'text-safe'}`} style={{ width: 110, right: isArchived ? 160 : 210 }} title={(entry.minWageApplied || 0) > 0 ? `最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发补齐到 ¥${fmt(entry.minWage)}\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)} + 免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)} + 额外补齐 ¥${fmt(entry.deferredMinWage || 0)} = 递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))}(次月补扣)` : ''}> <td className={`py-2 px-2 text-right font-bold sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200 ${(entry.minWageApplied || 0) > 0 ? 'text-amber-600' : 'text-safe'}`} style={{ width: COL_W.netPay, right: rightOffsets.netPay }} title={(entry.minWageApplied || 0) > 0 ? `最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发补齐到 ¥${fmt(entry.minWage)}\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)} + 免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)} + 额外补齐 ¥${fmt(entry.deferredMinWage || 0)} = 递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))}(次月补扣)` : ''}>
{fmt(entry.netPay)} {fmt(entry.netPay)}
{(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1"></span>} {(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1"></span>}
</td> </td>
{/* 递延扣款明细 */} {/* 递延扣款明细 */}
<td className="py-2 px-2 text-right text-xs sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: 110, right: isArchived ? 50 : 100 }}> <td className="py-2 px-2 text-right text-xs sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: COL_W.deferred, right: rightOffsets.deferred }}>
{(() => { {(() => {
const deferred = (entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0) const deferred = (entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0)
const prevDeferred = (entry.prevDeferredSocialEmp || 0) + (entry.prevDeferredHousingEmp || 0) + (entry.prevDeferredMinWage || 0) const prevDeferred = (entry.prevDeferredSocialEmp || 0) + (entry.prevDeferredHousingEmp || 0) + (entry.prevDeferredMinWage || 0)
@@ -1372,7 +1385,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
return <span className="text-gray-300"></span> return <span className="text-gray-300"></span>
})()} })()}
</td> </td>
<td className="py-2 px-2 text-center sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: 50, right: isArchived ? 0 : 50 }}> <td className="py-2 px-2 text-center sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: COL_W.risk, right: rightOffsets.risk }}>
{entry.riskWarnings && entry.riskWarnings.length > 0 ? ( {entry.riskWarnings && entry.riskWarnings.length > 0 ? (
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}> <span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
<AlertTriangle className="w-4 h-4" /> <AlertTriangle className="w-4 h-4" />
@@ -1386,7 +1399,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)} )}
</td> </td>
{!isArchived && ( {!isArchived && (
<td className="py-2 px-2 text-center sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: 50, right: 0 }}> <td className="py-2 px-2 text-center sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: COL_W.action, right: rightOffsets.action }}>
<button <button
onClick={() => removeEmployeeMutation.mutate(entry.employeeId)} onClick={() => removeEmployeeMutation.mutate(entry.employeeId)}
className="text-gray-500 hover:text-danger p-1" className="text-gray-500 hover:text-danger p-1"