|
| 1 | +# Inventory.tsx 代码优化分析报告 |
| 2 | + |
| 3 | +## 一、问题确认 |
| 4 | + |
| 5 | +根据代码分析,确认以下问题确实存在: |
| 6 | + |
| 7 | +### 1.1 TanStack Table 重渲染问题 ✅ |
| 8 | + |
| 9 | +**当前代码** ([`Inventory.tsx:452-560`](frontend/src/pages/Inventory.tsx:452)): |
| 10 | +```typescript |
| 11 | +const columns = useMemo(() => [ |
| 12 | + // ... |
| 13 | + cell: info => ( |
| 14 | + <HighlightText text={info.getValue() || ''} highlight={displayFilter} fuzzy={fuzzySearch} /> |
| 15 | + ), |
| 16 | + // ... |
| 17 | +], [displayFilter, handleEditClick, loadInventory, fuzzySearch]) |
| 18 | +``` |
| 19 | + |
| 20 | +**问题**: |
| 21 | +- `displayFilter`、`fuzzySearch`、`handleEditClick`、`loadInventory` 都在 columns 的依赖数组中 |
| 22 | +- 每当用户输入一个字符,`displayFilter` 更新,整个 columns 定义被重新创建 |
| 23 | +- 导致大量 DOM 节点销毁和重建,搜索时出现明显卡顿 |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +### 1.2 分页类型确认 ✅ |
| 28 | + |
| 29 | +**当前实现** ([`Inventory.tsx:218-221`](frontend/src/pages/Inventory.tsx:218)): |
| 30 | +```typescript |
| 31 | +const params: Record<string, unknown> = { |
| 32 | + skip: pageParam, |
| 33 | + limit: 50, |
| 34 | +} |
| 35 | +``` |
| 36 | + |
| 37 | +**结论**:使用的是 **Offset 分页**,不是游标分页。 |
| 38 | + |
| 39 | +**后端实现** ([`app/api/inventory.py:756`](app/api/inventory.py:756)): |
| 40 | +```python |
| 41 | +items = db.exec(base.order_by(order_expr).offset(skip).limit(limit)).all() |
| 42 | +``` |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +### 1.3 冗余状态 displayFilter ✅ |
| 47 | + |
| 48 | +**当前代码** ([`Inventory.tsx:194-203`](frontend/src/pages/Inventory.tsx:194)): |
| 49 | +```typescript |
| 50 | +const [globalFilter, setGlobalFilter] = useState('') |
| 51 | +const [displayFilter, setDisplayFilter] = useState('') |
| 52 | + |
| 53 | +useEffect(() => { |
| 54 | + setDisplayFilter(globalFilter) |
| 55 | +}, [globalFilter]) |
| 56 | +``` |
| 57 | + |
| 58 | +**问题**: |
| 59 | +- `displayFilter` 只是简单复制 `globalFilter`,没有实际作用 |
| 60 | +- 每次输入字符会触发两次状态更新和重渲染 |
| 61 | +- 没有实现防抖 (Debounce) 功能 |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +### 1.4 LoadingButton 颜色跳变 ✅ |
| 66 | + |
| 67 | +**当前代码** ([`Inventory.tsx:973-980`](frontend/src/pages/Inventory.tsx:973)): |
| 68 | +```typescript |
| 69 | +className={cn( |
| 70 | + "h-8 text-sm/4 px-3 border-0", |
| 71 | + isConfirming |
| 72 | + ? isLoading |
| 73 | + ? "text-destructive-foreground opacity-100 cursor-wait bg-destructive/70 transition-none" |
| 74 | + : "bg-destructive text-destructive-foreground hover:bg-destructive/70 transition-none" |
| 75 | + : "bg-primary hover:bg-primary/80" |
| 76 | +)} |
| 77 | +``` |
| 78 | + |
| 79 | +**问题**: |
| 80 | +- `isLoading` 时虽然设置了 `bg-destructive/70`,但没有禁用 disabled 默认样式 |
| 81 | +- 可能导致 loading 状态和其他状态颜色不一致 |
| 82 | + |
| 83 | +--- |
| 84 | + |
| 85 | +### 1.5 表单配置重复 ✅ |
| 86 | + |
| 87 | +**编辑表单** ([`Inventory.tsx:742-754`](frontend/src/pages/Inventory.tsx:742)): |
| 88 | +```typescript |
| 89 | +fields={[ |
| 90 | + { name: 'name', label: '试剂名称', type: 'input', required: true, colSpan: 2 }, |
| 91 | + { name: 'cas_number', label: 'CAS号', type: 'input', readOnly: true, colSpan: 1 }, |
| 92 | + // ... |
| 93 | +]} |
| 94 | +``` |
| 95 | + |
| 96 | +**入库表单** ([`Inventory.tsx:783-795`](frontend/src/pages/Inventory.tsx:783)): |
| 97 | +```typescript |
| 98 | +fields={[ |
| 99 | + { name: 'name', label: '试剂名称', type: 'input', required: true, colSpan: 2 }, |
| 100 | + { name: 'cas_number', label: 'CAS号', type: 'input', required: true, colSpan: 1 }, |
| 101 | + // ... |
| 102 | +]} |
| 103 | +``` |
| 104 | +
|
| 105 | +**问题**: |
| 106 | +- 两个表单的字段配置大量重复 |
| 107 | +- 维护困难,添加一个字段需要改4个地方 |
| 108 | +
|
| 109 | +--- |
| 110 | +
|
| 111 | +## 二、改进方案 |
| 112 | +
|
| 113 | +### 2.1 表格列定义优化(优先级:P0) |
| 114 | +
|
| 115 | +**目标**:消除 columns 的外部依赖,解决搜索卡顿 |
| 116 | +
|
| 117 | +**问题根因**: |
| 118 | +- 每次用户输入字符,`displayFilter` 更新 |
| 119 | +- columns 的 useMemo 依赖了 `displayFilter`, `fuzzySearch`, `handleEditClick`, `loadInventory` |
| 120 | +- 导致整个列定义被重新创建,触发表格 DOM 树完全重绘 |
| 121 | +
|
| 122 | +**影响程度**:100%(每次搜索都触发) |
| 123 | +
|
| 124 | +**方案**:利用 `table.meta` 注入状态和方法 |
| 125 | +
|
| 126 | +```typescript |
| 127 | +// 类型定义 |
| 128 | +interface TableMeta { |
| 129 | + fuzzySearch: boolean |
| 130 | + onEdit: (item: InventoryItem) => void |
| 131 | + onBorrowSuccess: () => void |
| 132 | +} |
| 133 | + |
| 134 | +// 使用 |
| 135 | +const table = useReactTable({ |
| 136 | + data, |
| 137 | + columns, |
| 138 | + meta: { |
| 139 | + fuzzySearch, |
| 140 | + onEdit: handleEditClick, |
| 141 | + onBorrowSuccess: loadInventory, |
| 142 | + } |
| 143 | +}) |
| 144 | + |
| 145 | +// 列定义 - 依赖项清空 |
| 146 | +const columns = useMemo(() => [ |
| 147 | + columnHelper.accessor('name', { |
| 148 | + cell: info => { |
| 149 | + const filterValue = info.table.getState().globalFilter |
| 150 | + const isFuzzy = info.table.options.meta?.fuzzySearch |
| 151 | + return <HighlightText text={info.getValue()} highlight={filterValue} fuzzy={isFuzzy} /> |
| 152 | + }, |
| 153 | + }), |
| 154 | +], []) // 空依赖数组 |
| 155 | +``` |
| 156 | +
|
| 157 | +--- |
| 158 | +
|
| 159 | +### 2.2 移除冗余状态(优先级:P1) |
| 160 | +
|
| 161 | +**目标**:删除无用的 `displayFilter` 状态 |
| 162 | +
|
| 163 | +**问题根因**: |
| 164 | +- `displayFilter` 只是简单复制 `globalFilter`,没有任何实际作用 |
| 165 | +- 每次输入触发两次状态更新和重渲染 |
| 166 | +
|
| 167 | +**影响程度**:100%(每次搜索都触发额外渲染) |
| 168 | +
|
| 169 | +**方案**:删除 `displayFilter`,直接使用 `globalFilter` |
| 170 | +
|
| 171 | +```typescript |
| 172 | +// 删除这两行 |
| 173 | +const [displayFilter, setDisplayFilter] = useState('') |
| 174 | +useEffect(() => { |
| 175 | + setDisplayFilter(globalFilter) |
| 176 | +}, [globalFilter]) |
| 177 | + |
| 178 | +// 在 columns 中直接使用 table.getState().globalFilter |
| 179 | +``` |
| 180 | +
|
| 181 | +--- |
| 182 | +
|
| 183 | +### 2.3 表单配置抽离(优先级:P1) |
| 184 | +
|
| 185 | +**目标**:消除 editForm 和 addForm 的字段配置重复 |
| 186 | +
|
| 187 | +**问题根因**: |
| 188 | +- 两个表单的字段配置几乎相同 |
| 189 | +- 添加一个字段需要改4个地方 |
| 190 | +
|
| 191 | +**影响程度**:100%(每次维护都遇到) |
| 192 | +
|
| 193 | +**方案**:抽离表单字段工厂函数 |
| 194 | +
|
| 195 | +```typescript |
| 196 | +// 定义在组件外部 |
| 197 | +const INVENTORY_FORM_FIELDS = { |
| 198 | + common: [ |
| 199 | + { name: 'name', label: '试剂名称', type: 'input', required: true, colSpan: 2 }, |
| 200 | + { name: 'english_name', label: '英文名称', type: 'input', colSpan: 2 }, |
| 201 | + { name: 'alias', label: '别名', type: 'input' }, |
| 202 | + { name: 'storage_location', label: '存放位置', type: 'input' }, |
| 203 | + { name: 'brand', label: '品牌', type: 'input' }, |
| 204 | + { name: 'category', label: '分类', type: 'input' }, |
| 205 | + { name: 'is_hazardous', label: '危险品', type: 'checkbox' }, |
| 206 | + { name: 'notes', label: '备注', type: 'textarea', colSpan: 3 }, |
| 207 | + ], |
| 208 | + edit: [ |
| 209 | + { name: 'cas_number', label: 'CAS号', type: 'input', readOnly: true, colSpan: 1 }, |
| 210 | + { name: 'remaining_quantity', label: '剩余量', type: 'number', required: true }, |
| 211 | + { name: 'specification', label: '规格', type: 'input', required: true }, |
| 212 | + ], |
| 213 | + add: [ |
| 214 | + { name: 'cas_number', label: 'CAS号', type: 'input', required: true, colSpan: 1 }, |
| 215 | + { name: 'specification', label: '规格', type: 'input', required: true }, |
| 216 | + { name: 'quantity_bottles', label: '瓶数', type: 'number', required: true }, |
| 217 | + ], |
| 218 | +} |
| 219 | + |
| 220 | +function getFormFields(mode: 'edit' | 'add') { |
| 221 | + return [ |
| 222 | + ...INVENTORY_FORM_FIELDS.common, |
| 223 | + ...INVENTORY_FORM_FIELDS[mode], |
| 224 | + ] |
| 225 | +} |
| 226 | +``` |
| 227 | +
|
| 228 | +--- |
| 229 | +
|
| 230 | +### 2.4 LoadingButton 修复(优先级:P2) |
| 231 | +
|
| 232 | +**目标**:修复状态切换时的颜色跳变问题 |
| 233 | +
|
| 234 | +**问题根因**: |
| 235 | +- `isLoading` 时未强制锁定 disabled 样式 |
| 236 | +
|
| 237 | +**影响程度**:100%(每次点击借用都发生) |
| 238 | +
|
| 239 | +**方案**:强制锁定 disabled 样式 |
| 240 | +
|
| 241 | +```typescript |
| 242 | +className={cn( |
| 243 | + "h-8 text-sm/4 px-3 border-0 transition-none", |
| 244 | + isConfirming |
| 245 | + ? isLoading |
| 246 | + ? "bg-destructive/70 text-destructive-foreground cursor-wait disabled:bg-destructive/70 disabled:text-destructive-foreground disabled:opacity-100" |
| 247 | + : "bg-destructive text-destructive-foreground hover:bg-destructive/70" |
| 248 | + : "bg-primary hover:bg-primary/80" |
| 249 | +)} |
| 250 | +``` |
| 251 | +
|
| 252 | +--- |
| 253 | +
|
| 254 | +### 2.5 游标分页改造(优先级:未来优化项) |
| 255 | +
|
| 256 | +**状态**:⚠️ **文档与代码不一致** |
| 257 | +
|
| 258 | +- 文档 [`docs/done/[DONE]-inventory_performance_optimization.md`](docs/done/[DONE]-inventory_performance_optimization.md) 声称"后端游标分页 API ✅ 已完成" |
| 259 | +- 实际代码:后端 API 仍使用 `skip`/`limit`,未实现 `cursor` 参数 |
| 260 | +
|
| 261 | +**影响分析**: |
| 262 | +- 发生概率:< 1%(实验室小规模并发极其罕见) |
| 263 | +- 影响程度:中(滚动时偶现重复或遗漏数据) |
| 264 | +- 修复成本:高(前后端联动重构) |
| 265 | +
|
| 266 | +**结论**:维持 Offset 分页,暂不需要改造 |
| 267 | +
|
| 268 | +--- |
| 269 | +
|
| 270 | +## 三、实施优先级(最终版) |
| 271 | +
|
| 272 | +| 优先级 | 改进项 | 发生概率 | 影响程度 | 修复成本 | |
| 273 | +|--------|--------|----------|----------|----------| |
| 274 | +| **P0** | TanStack Table 列定义重构 | 100% | 高(输入卡顿) | 低 | |
| 275 | +| **P1** | 提取通用表单字段配置 | 100% | 中(可维护性) | 低 | |
| 276 | +| **P2** | 修复 LoadingButton 状态跳变 | 100% | 低(视觉) | 极低 | |
| 277 | +| **未来优化项** | 游标分页改造 | < 1% | 中 | 高 | |
| 278 | +
|
| 279 | +--- |
| 280 | +
|
| 281 | +## 四、总结 |
| 282 | +
|
| 283 | +当前 Inventory.tsx 代码质量总体良好,主要性能瓶颈在表格列定义的依赖项过多。核心改进方向: |
| 284 | +
|
| 285 | +1. **P0 - 立即可做**:优化 columns 依赖,解决搜索卡顿 |
| 286 | +2. **P1 - 建议做**:表单配置抽离 + 移除冗余状态 |
| 287 | +3. **P2 - 顺手修**:LoadingButton 颜色修复 |
| 288 | +4. **未来优化项**:游标分页,当前方案已满足需求 |
0 commit comments