Procházet zdrojové kódy

物资管理模块内容迁移

wyn před 3 týdny
rodič
revize
62aa03b798
43 změnil soubory, kde provedl 9027 přidání a 17 odebrání
  1. 183 17
      src/Dashboard.tsx
  2. 3 0
      src/api/index.ts
  3. 39 0
      src/api/material/blacklist.ts
  4. 55 0
      src/api/material/checkRecord.ts
  5. 20 0
      src/api/material/doorException.ts
  6. 19 0
      src/api/material/exception.ts
  7. 49 0
      src/api/material/index.ts
  8. 63 0
      src/api/material/information.ts
  9. 41 0
      src/api/material/instructions.ts
  10. 45 0
      src/api/material/loan.ts
  11. 50 0
      src/api/material/lockers.ts
  12. 47 0
      src/api/material/manualException.ts
  13. 68 0
      src/api/material/plan.ts
  14. 45 0
      src/api/material/propertyValue.ts
  15. 54 0
      src/api/material/replace.ts
  16. 37 0
      src/api/material/standard.ts
  17. 88 0
      src/api/material/statistics.ts
  18. 42 0
      src/api/material/type.ts
  19. 6 0
      src/components/IsolationWorkListTable.css
  20. 58 0
      src/components/material/MaterialManagement.tsx
  21. 266 0
      src/components/material/MaterialPageLayout.tsx
  22. 243 0
      src/components/material/materialListUtils.ts
  23. 29 0
      src/components/material/materialPvNav.ts
  24. 43 0
      src/components/material/materialStatisticsRangeUtils.ts
  25. 162 0
      src/components/material/materialSubMenu.ts
  26. 339 0
      src/components/material/pages/MaterialBaseDataStatisticsPage.tsx
  27. 356 0
      src/components/material/pages/MaterialBlacklistPage.tsx
  28. 346 0
      src/components/material/pages/MaterialClaimReturnStatisticsPage.tsx
  29. 972 0
      src/components/material/pages/MaterialInformationPage.tsx
  30. 1178 0
      src/components/material/pages/MaterialInspectionPlanPage.tsx
  31. 641 0
      src/components/material/pages/MaterialInspectionRecordPage.tsx
  32. 641 0
      src/components/material/pages/MaterialInstructionsPage.tsx
  33. 140 0
      src/components/material/pages/MaterialInventoryPage.tsx
  34. 563 0
      src/components/material/pages/MaterialLoanPage.tsx
  35. 130 0
      src/components/material/pages/MaterialLockerDetailPage.tsx
  36. 561 0
      src/components/material/pages/MaterialLockersPage.tsx
  37. 507 0
      src/components/material/pages/MaterialReplacementRecordPage.tsx
  38. 285 0
      src/components/material/pages/MaterialStandardPage.tsx
  39. 394 0
      src/components/material/pages/MaterialStandardPropertyValuesPage.tsx
  40. 162 0
      src/components/material/pages/MaterialTypePage.tsx
  41. 16 0
      src/locales/en.json
  42. 16 0
      src/locales/zh.json
  43. 25 0
      src/utils/permission.ts

+ 183 - 17
src/Dashboard.tsx

@@ -1,8 +1,9 @@
 import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
 import { useNavigate, useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
-import { Shield, Settings, Users, Cpu, MapPin, Layers, Bell, User, LogOut, ChevronDown, Activity, Radio, Lock, AlertCircle, CheckCircle, Clock, Menu, Building2, UserCog, BookOpen, Server, Globe, Gauge, Briefcase, MessageSquare, FileText, FolderTree, KeyRound, HardDrive, Database, Network, Workflow, ClipboardList, Wrench, Archive, Package, Box, Grid3x3, List, LayoutGrid, FolderOpen, FileCheck, FileEdit, FileSearch } from 'lucide-react';
+import { Shield, Settings, Users, Cpu, MapPin, Layers, Bell, User, LogOut, ChevronDown, Activity, Radio, Lock, AlertCircle, CheckCircle, Clock, Menu, Building2, UserCog, BookOpen, Server, Globe, Gauge, Briefcase, MessageSquare, FileText, FolderTree, KeyRound, HardDrive, Database, Network, Workflow, ClipboardList, Wrench, Archive, Package, Box, Grid3x3, List, LayoutGrid, FolderOpen, FileCheck, FileEdit, FileSearch, PieChart, BarChart3 } from 'lucide-react';
 import SystemConfig from './components/SystemConfig';
+import MaterialManagement from './components/material/MaterialManagement';
 import UserManagement from './components/UserManagement';
 import HardwareManagement from './components/HardwareManagement';
 import SegregationPointManagement from './components/SegregationPointManagement';
@@ -21,6 +22,10 @@ import { Toaster } from 'sonner';
 import { env } from './utils/env';
 import { clearAuth, stopTokenCheck } from './utils/auth';
 import { getMenus, hasMenuPathPermission, mapMenuPathToKey, getPermissionUser, hasRole } from './utils/permission';
+import { mapMaterialRoutePathToSubKey } from './components/material/materialSubMenu';
+
+/** 仅能从业务页跳转进入,不出现在顶部「物资管理」下拉菜单 */
+const MATERIAL_SUBMENU_ENTRY_ONLY_KEYS = new Set(['materialStandardPropertyValues']);
 import { Dropdown } from 'antd';
 import type { MenuProps } from 'antd';
 
@@ -200,10 +205,26 @@ export default function Dashboard() {
       if (path === 'taskmanagement' || path === 'taskManagement') return 'taskManagement';
       if (path === 'mytask' || path === 'myTask') return 'myTask';
       if (path === 'sop') return 'sopManagement';
+      if (path === 'materialManagement' || path === 'material') return 'materialManagement';
+      const matShort = mapMaterialRoutePathToSubKey(path);
+      if (matShort) return matShort;
     }
     
     // 客户端系统路径(支持 /clientSystem/xxx 格式)
     if (path.startsWith('/clientSystem')) {
+      // get-permission-info 物资数据统计:/clientSystem/MaterialDataStatistics(子:statistics/basicData、statistics/collectionReturn)
+      if (/MaterialDataStatistics/i.test(path)) {
+        const mat = mapMaterialRoutePathToSubKey(path);
+        if (mat) return mat;
+        return 'materialManagement';
+      }
+      if (/\/material\//i.test(path) || /Material\//i.test(path)) {
+        const mat = mapMaterialRoutePathToSubKey(path);
+        if (mat) return mat;
+      }
+      if (/\/material$/i.test(path) || /\/Material$/i.test(path) || /materialmanagement/i.test(path)) {
+        return 'materialManagement';
+      }
       // 隔离作业子菜单路径(优先处理)
       if (path.startsWith('/clientSystem/IsolationWork/')) {
         if (path.includes('/processDesign') || path.endsWith('/processDesign')) return 'processDesign';
@@ -284,6 +305,13 @@ export default function Dashboard() {
     if (path.includes('/my-task') || path.includes('/myTask') || path.endsWith('/my-task') || path.endsWith('/myTask')) {
       return 'myTask';
     }
+
+    // 物资管理
+    if (path === '/material') return 'materialManagement';
+    if (path.startsWith('/material/')) {
+      const mat = mapMaterialRoutePathToSubKey(path);
+      if (mat) return mat;
+    }
     
     // 点位管理
     if (path === '/points' || path.startsWith('/points') || path === '/Basicdata' || path.startsWith('/Basicdata')) {
@@ -337,6 +365,7 @@ export default function Dashboard() {
           { key: 'hardwareManagement', icon: Cpu, path: '/hardware', name: '硬件管理' },
           { key: 'locationManagement', icon: MapPin, path: '/points', name: '点位管理' },
           { key: 'isolationWork', icon: Layers, path: '/isolation', name: '隔离作业' },
+          { key: 'materialManagement', icon: Package, path: '/material', name: '物资管理' },
         ],
         subMenuConfig: {
           systemConfig: [
@@ -365,6 +394,21 @@ export default function Dashboard() {
             { key: 'processDesign', icon: Workflow, path: '/jobTicket/design', name: '流程设计' },
             { key: 'formManagement', icon: FileText, path: '/jobTicket/form', name: '表单管理' },
           ],
+          materialManagement: [
+            { key: 'materialLockers', icon: Archive, path: '/material/lockers', name: '物资柜' },
+            { key: 'materialInventory', icon: ClipboardList, path: '/material/inventory', name: '物资盘点' },
+            { key: 'materialType', icon: Package, path: '/material/type', name: '物资类型' },
+            { key: 'materialStandard', icon: Cpu, path: '/material/standard', name: '物资规格种类' },
+            { key: 'materialInformation', icon: Box, path: '/material/information', name: '物资清单' },
+            { key: 'materialLoan', icon: Wrench, path: '/material/coll', name: '物资领取归还' },
+            { key: 'materialBaseDataStatistics', icon: PieChart, path: '/material/statistics/base-data', name: '基础数据统计' },
+            { key: 'materialClaimReturnStatistics', icon: BarChart3, path: '/material/statistics/claim-return', name: '领取归还统计' },
+            { key: 'materialInspectionPlan', icon: FileText, path: '/material/inspectionplan', name: '物资检查计划' },
+            { key: 'materialInspectionRecord', icon: FileEdit, path: '/material/inspectionrecords', name: '物资检查记录' },
+            { key: 'materialReplacementRecord', icon: List, path: '/material/replacementrecords', name: '物资更换记录' },
+            { key: 'materialBlacklist', icon: User, path: '/material/blacklist', name: '物资柜黑名单' },
+            { key: 'materialInstructions', icon: BookOpen, path: '/material/instructions', name: '物资使用说明' },
+          ],
         }
       };
     }
@@ -406,6 +450,21 @@ export default function Dashboard() {
         'workManagement': Activity,
         'processDesign': Workflow,
         'formManagement': FileText,
+        'materialManagement': Package,
+        'materialLockers': Archive,
+        'materialInventory': ClipboardList,
+        'materialType': Package,
+        'materialStandard': Cpu,
+        'materialStandardPropertyValues': List,
+        'materialInformation': Box,
+        'materialLoan': Wrench,
+        'materialInspectionPlan': FileText,
+        'materialInspectionRecord': FileEdit,
+        'materialReplacementRecord': List,
+        'materialBlacklist': User,
+        'materialInstructions': BookOpen,
+        'materialBaseDataStatistics': PieChart,
+        'materialClaimReturnStatistics': BarChart3,
       };
       
       if (iconMap[key]) {
@@ -555,10 +614,12 @@ export default function Dashboard() {
               return;
             }
             let childKey: string = mapBackendPathToFrontendKey(child.path) || '';
+            const matResolved = mapMaterialRoutePathToSubKey(child.path);
+            if (matResolved) {
+              childKey = matResolved;
+            }
             console.log('处理子菜单:', { path: child.path, mappedKey: childKey });
             if (!childKey) {
-              // 如果无法映射,尝试根据路径推断
-              // 优先处理 clientSystem/IsolationWork 路径
               if (child.path.startsWith('/clientSystem/IsolationWork/')) {
                 if (child.path.includes('/processDesign') || child.path.endsWith('/processDesign')) {
                   childKey = 'processDesign';
@@ -624,6 +685,9 @@ export default function Dashboard() {
                 childKey = 'myTask';
               } else if (child.path === 'sop' || child.path.includes('/sop') || child.path.endsWith('/sop')) {
                 childKey = 'sopManagement';
+              } else if (child.path.includes('/material/') || child.path === '/material' || child.path.startsWith('/material')) {
+                const matFallback = mapMaterialRoutePathToSubKey(child.path);
+                childKey = matFallback || 'materialLockers';
               } else if (child.path.includes('/notification') || child.path.startsWith('/clientSystem/notification')) {
                 // 通知管理子菜单
                 if (child.path.includes('appNotification') || child.path.includes('app-notify') || child.path.includes('app_notify')) {
@@ -815,6 +879,38 @@ export default function Dashboard() {
     if (config.isolationWork) {
       config.isolationWork = config.isolationWork.filter(item => item.key !== 'sopManagement');
     }
+    // 无后端物资子菜单时 mat 为 undefined,也必须能补全「规格设置/统计」等子项,否则下拉里看不到入口
+    if (!Array.isArray(config.materialManagement)) {
+      config.materialManagement = [];
+    }
+    const mat = config.materialManagement;
+    const keys = new Set(mat.map((i: { key: string }) => i.key));
+    const extra: { key: string; icon: any; path: string; name: string }[] = [];
+    if (!keys.has('materialStandardPropertyValues')) {
+      extra.push({
+        key: 'materialStandardPropertyValues',
+        icon: List,
+        path: '/material/standard/property-values',
+        name: '规格设置',
+      });
+    }
+    if (!keys.has('materialBaseDataStatistics')) {
+      extra.push({
+        key: 'materialBaseDataStatistics',
+        icon: PieChart,
+        path: '/material/statistics/base-data',
+        name: '基础数据统计',
+      });
+    }
+    if (!keys.has('materialClaimReturnStatistics')) {
+      extra.push({
+        key: 'materialClaimReturnStatistics',
+        icon: BarChart3,
+        path: '/material/statistics/claim-return',
+        name: '领取归还统计',
+      });
+    }
+    if (extra.length) config.materialManagement = [...mat, ...extra];
     return config;
   }, [enhancedSubMenuConfig]);
 
@@ -909,6 +1005,25 @@ export default function Dashboard() {
         // 硬件管理的子菜单
         setActiveMenu('hardwareManagement');
         setActiveSubMenu(menuKey);
+      } else if (
+        menuKey === 'materialManagement' ||
+        menuKey === 'materialLockers' ||
+        menuKey === 'materialInventory' ||
+        menuKey === 'materialType' ||
+        menuKey === 'materialStandard' ||
+        menuKey === 'materialStandardPropertyValues' ||
+        menuKey === 'materialInformation' ||
+        menuKey === 'materialLoan' ||
+        menuKey === 'materialInspectionPlan' ||
+        menuKey === 'materialInspectionRecord' ||
+        menuKey === 'materialReplacementRecord' ||
+        menuKey === 'materialBlacklist' ||
+        menuKey === 'materialInstructions' ||
+        menuKey === 'materialBaseDataStatistics' ||
+        menuKey === 'materialClaimReturnStatistics'
+      ) {
+        setActiveMenu('materialManagement');
+        setActiveSubMenu(menuKey === 'materialManagement' ? 'materialLockers' : menuKey);
       } else if (menuKey === 'myTask') {
         // 我的任务
         console.log('设置我的任务菜单:', { menuKey: 'myTask' });
@@ -1062,6 +1177,25 @@ export default function Dashboard() {
         // 硬件管理的子菜单
         setActiveMenu('hardwareManagement');
         setActiveSubMenu(menuKey);
+      } else if (
+        menuKey === 'materialManagement' ||
+        menuKey === 'materialLockers' ||
+        menuKey === 'materialInventory' ||
+        menuKey === 'materialType' ||
+        menuKey === 'materialStandard' ||
+        menuKey === 'materialStandardPropertyValues' ||
+        menuKey === 'materialInformation' ||
+        menuKey === 'materialLoan' ||
+        menuKey === 'materialInspectionPlan' ||
+        menuKey === 'materialInspectionRecord' ||
+        menuKey === 'materialReplacementRecord' ||
+        menuKey === 'materialBlacklist' ||
+        menuKey === 'materialInstructions' ||
+        menuKey === 'materialBaseDataStatistics' ||
+        menuKey === 'materialClaimReturnStatistics'
+      ) {
+        setActiveMenu('materialManagement');
+        setActiveSubMenu(menuKey === 'materialManagement' ? 'materialLockers' : menuKey);
       } else if (menuKey === 'processDesign' || menuKey === 'sopManagement' || menuKey === 'workManagement' || menuKey === 'formManagement') {
         // 隔离作业的子菜单
         console.log('设置隔离作业子菜单:', { menuKey: 'isolationWork', subMenuKey: menuKey });
@@ -1100,6 +1234,14 @@ export default function Dashboard() {
     };
   }, [handleMenuChange]);
 
+  /** 与下方主内容区对齐:仅在有顶部二级 Tab 时保留 pt-6,避免物资/用户管理等「顶栏无 Tab」时出现双倍上留白(与隔离作业一级区到二级 Tab 的间距一致) */
+  const showDashboardSubMenuTabs =
+    !showProfileSettings &&
+    (filteredSubMenuConfig[activeMenu]?.length ?? 0) >= 1 &&
+    activeMenu !== 'systemConfig' &&
+    activeMenu !== 'materialManagement' &&
+    activeMenu !== 'userManagement';
+
   return (
     <div className="h-screen flex flex-col bg-gradient-to-br from-gray-50 via-blue-50/30 to-gray-50 overflow-hidden">
       {/* 顶部导航栏 */}
@@ -1127,8 +1269,14 @@ export default function Dashboard() {
                   const Icon = item.icon;
                   const isActive = activeMenu === item.key;
                   
-                  // 只有系统配置显示下拉菜单,其他菜单的二级菜单在页面内用 tab 标签显示
-                  if (item.key === 'systemConfig' && filteredSubMenuConfig[item.key] && filteredSubMenuConfig[item.key].length > 0) {
+                  // 系统配置、物资管理:顶栏下拉(悬停/点击)展示二级菜单,样式与系统配置一致
+                  const isDropdownMainMenu =
+                    (item.key === 'systemConfig' || item.key === 'materialManagement') &&
+                    filteredSubMenuConfig[item.key] &&
+                    filteredSubMenuConfig[item.key].length > 0;
+                  if (isDropdownMainMenu) {
+                    const dropdownWidth = item.key === 'materialManagement' ? 440 : 340;
+                    const subMenuI18nPrefix = item.key === 'systemConfig' ? 'systemConfig' : 'materialManagement';
                     const isDropdownOpen = showDropdownMenu === item.key;
                     const buttonRef = useRef<HTMLButtonElement>(null);
                     const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
@@ -1174,6 +1322,15 @@ export default function Dashboard() {
                         >
                           <button
                             ref={buttonRef}
+                            type="button"
+                            onClick={
+                              item.key === 'materialManagement'
+                                ? () => {
+                                    if (dropdownTimer) clearTimeout(dropdownTimer);
+                                    setShowDropdownMenu((k) => (k === item.key ? null : item.key));
+                                  }
+                                : undefined
+                            }
                             className={`flex items-center gap-2 px-4 py-2.5 rounded-xl transition-all duration-300 ${
                               isActive
                                 ? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg shadow-blue-400/40'
@@ -1197,7 +1354,7 @@ export default function Dashboard() {
                               style={{
                                 top: `${dropdownPosition.top - 8}px`,
                                 left: `${dropdownPosition.left}px`,
-                                width: '340px',
+                                width: `${dropdownWidth}px`,
                                 height: '8px',
                               }}
                               onMouseEnter={() => {
@@ -1216,7 +1373,7 @@ export default function Dashboard() {
                               style={{
                                 top: `${dropdownPosition.top}px`,
                                 left: `${dropdownPosition.left}px`,
-                                width: '340px',
+                                width: `${dropdownWidth}px`,
                               }}
                               onMouseEnter={() => {
                                 // 鼠标进入悬浮框时保持显示
@@ -1230,7 +1387,12 @@ export default function Dashboard() {
                               }}
                             >
                             <div className="grid grid-cols-2 gap-2">
-                              {(filteredSubMenuConfig[item.key] || []).map((subItem) => {
+                              {(filteredSubMenuConfig[item.key] || [])
+                                .filter(
+                                  (subItem) =>
+                                    !(item.key === 'materialManagement' && MATERIAL_SUBMENU_ENTRY_ONLY_KEYS.has(subItem.key)),
+                                )
+                                .map((subItem) => {
                                 const IconComponent = subItem.icon;
                                 const isActive = activeMenu === item.key && activeSubMenu === subItem.key;
                                 return (
@@ -1267,7 +1429,7 @@ export default function Dashboard() {
                                       <IconComponent className="w-4 h-4" strokeWidth={2.5} />
                                     </div>
                                     <span>
-                                      {t(`systemConfig.${subItem.key}`, subItem.name || subItem.key)}
+                                      {t(`${subMenuI18nPrefix}.${subItem.key}`, subItem.name || subItem.key)}
                                     </span>
                                   </button>
                                 );
@@ -1464,14 +1626,11 @@ export default function Dashboard() {
         </div>
       </nav>
 
-      {/* 主内容区 */}
-      <div className="flex-1 flex flex-col overflow-hidden">
-        <div className="px-6 pt-6 flex-shrink-0">
+      {/* 主内容区:min-h-0 避免 flex 子项高度被压成 0,导致子页面(含物资柜详情 Tab)整片空白 */}
+      <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
+        <div className={`px-6 flex-shrink-0${showDashboardSubMenuTabs ? ' pt-6' : ''}`}>
           {/* 二级菜单 Tab - 只要有子菜单(>=1)且不是系统配置时显示,并且不在个人资料页面时显示 */}
-          {!showProfileSettings && 
-           filteredSubMenuConfig[activeMenu]?.length >= 1 && 
-           activeMenu !== 'systemConfig' && 
-           activeMenu !== 'userManagement' && (
+          {showDashboardSubMenuTabs && (
             <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm p-2 mb-6">
               <div className="flex items-center gap-2 overflow-x-auto min-w-0">
                 {filteredSubMenuConfig[activeMenu]?.map((item) => {
@@ -1488,6 +1647,8 @@ export default function Dashboard() {
                     menuKey = 'notificationManagement';
                   } else if (activeMenu === 'systemConfig') {
                     menuKey = 'systemConfig';
+                  } else if (activeMenu === 'materialManagement') {
+                    menuKey = 'materialManagement';
                   } else {
                     // 默认尝试使用 activeMenu 作为翻译键前缀
                     menuKey = activeMenu;
@@ -1541,7 +1702,7 @@ export default function Dashboard() {
         </div>
 
         {/* 主内容区域 - 可滚动 */}
-        <div className="flex-1 px-6 pb-6 overflow-auto">
+        <div className="min-h-0 flex-1 overflow-auto px-6 pb-6">
         {showProfileSettings ? (
           <ProfileSettings onBack={() => setShowProfileSettings(false)} onProfileUpdated={refreshUserInfo} />
         ) : activeMenu === 'dashboard' ? (
@@ -1552,6 +1713,11 @@ export default function Dashboard() {
           <UserManagement subMenu={activeSubMenu} />
         ) : activeMenu === 'hardwareManagement' ? (
           <HardwareManagement subMenu={activeSubMenu} />
+        ) : activeMenu === 'materialManagement' ? (
+          <MaterialManagement
+            subMenu={activeSubMenu}
+            routePath={filteredSubMenuConfig.materialManagement?.find((i) => i.key === activeSubMenu)?.path}
+          />
         ) : activeMenu === 'locationManagement' ? (
           <SegregationPointManagement />
         ) : activeMenu === 'isolationWork' ? (

+ 3 - 0
src/api/index.ts

@@ -27,6 +27,7 @@ import { workHandleApi } from './WorkHandle';
 import { managerHomeApi } from './managerHome';
 import { in_site } from './notification/in_site';
 import { app_message } from './notification/app_message';
+import { materialApi } from './material';
 
 // API 响应类型
 export interface ApiResponse<T = any> {
@@ -64,6 +65,7 @@ export { workHandleApi } from './WorkHandle';
 export { managerHomeApi } from './managerHome';
 export { in_site, type NotifyMessageVO, type NotifyMessageQueryParams } from './notification/in_site';
 export { app_message } from './notification/app_message';
+export { materialApi } from './material';
 export type { NotifyMessageVO as AppNotifyMessageVO, NotifyMessageQueryParams as AppNotifyMessageQueryParams } from './notification/app_message';
 
 // 为了兼容旧代码,导出 authApi 作为 loginApi 的别名
@@ -102,5 +104,6 @@ export default {
   managerHome: managerHomeApi,
   in_site: in_site,
   app_message: app_message,
+  material: materialApi,
 };
 

+ 39 - 0
src/api/material/blacklist.ts

@@ -0,0 +1,39 @@
+import axiosInstance from '../../utils/axios';
+
+export interface BlacklistQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  userId?: string;
+  userName?: string;
+  nickName?: string;
+  [key: string]: any;
+}
+
+export interface BlacklistVO {
+  recordId?: number;
+  userId: string;
+  userName?: string;
+  nickName?: string;
+  /** 与旧版 BlacklistForm 提交一致,后端常据此写入业务模块 */
+  module?: string;
+  [key: string]: any;
+}
+
+/** 旧版 insertBlacklist 实际提交为数组项:{ userId, module: '2' }[] */
+export type BlacklistInsertPayload = Array<{ userId: string | number; module?: string }>;
+
+export const materialBlacklistApi = {
+  listBlacklist: (params: BlacklistQuery) =>
+    axiosInstance.get('/iscs/blacklist/getBlacklistPage', { params }),
+  getBlacklistInfo: (id: number) =>
+    axiosInstance.get('/iscs/blacklist/selectBlacklistById', { params: { id } }),
+  addBlacklist: (data: BlacklistVO | BlacklistInsertPayload) =>
+    axiosInstance.post('/iscs/blacklist/insertBlacklist', data),
+  updateBlacklist: (data: BlacklistVO) => axiosInstance.put('/iscs/blacklist/updateBlacklist', data),
+  delBlacklist: (ids: number | string) =>
+    axiosInstance.delete('/iscs/blacklist/deleteBlacklistList', { params: { ids } }),
+  listWhitelist: (params: BlacklistQuery) =>
+    axiosInstance.get('/iscs/blacklist/getWhiteUserPage', { params }),
+};

+ 55 - 0
src/api/material/checkRecord.ts

@@ -0,0 +1,55 @@
+import axiosInstance from '../../utils/axios';
+
+export interface CheckRecordQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  /** 检查计划维度筛选(若后端支持) */
+  planId?: number;
+  materialsCheckPlanId?: number;
+  planName?: string;
+  cabinetId?: number;
+  materialsId?: number;
+  materialsName?: string;
+  materialsTypeId?: number;
+  materialsRfid?: string;
+  /** 检查结果(旧版 dict CHECKS_STATUS) */
+  status?: number | string;
+  /** 异常原因(旧版 dict EXCEPTIONS_STATUS) */
+  reason?: number | string;
+  startTime?: string;
+  endTime?: string;
+  checkStatus?: number;
+  checkTime?: Date[] | string[];
+  [key: string]: any;
+}
+
+export interface CheckRecordVO {
+  recordId?: number;
+  cabinetId: number;
+  materialsId: number;
+  checkStatus: number;
+  checkTime?: Date | string;
+  checkResult: string;
+  remark?: string;
+  [key: string]: any;
+}
+
+export const materialCheckRecordApi = {
+  listCheckRecord: (params: CheckRecordQuery) =>
+    axiosInstance.get('/iscs/materials-check-record/getMaterialsCheckRecordPage', { params }),
+  getCheckRecordInfo: (id: number) =>
+    axiosInstance.get('/iscs/materials-check-record/selectMaterialsCheckRecordById', { params: { id } }),
+  addCheckRecord: (data: CheckRecordVO) =>
+    axiosInstance.post('/iscs/materials-check-record/insertMaterialsCheckRecord', data),
+  updateCheckRecord: (data: CheckRecordVO) =>
+    axiosInstance.post('/iscs/materials-check-record/updateMaterialsCheckRecord', data),
+  deleteCheckRecord: (ids: number) =>
+    axiosInstance.post(`/iscs/materials-check-record/deleteMaterialsCheckRecordList?ids=${ids}`),
+  exportCheckRecord: (params: Record<string, any>) =>
+    axiosInstance.get('/iscs/materials-check-record/exportMaterialsCheckRecordExcel', {
+      params,
+      responseType: 'blob',
+    }),
+};

+ 20 - 0
src/api/material/doorException.ts

@@ -0,0 +1,20 @@
+import axiosInstance from '../../utils/axios';
+
+export interface DoorExceptionQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  cabinetId?: number;
+  cabinetName?: string;
+  exceptionType?: number;
+  exceptionStatus?: number;
+  startTime?: Date | string;
+  endTime?: Date | string;
+  [key: string]: any;
+}
+
+export const materialDoorExceptionApi = {
+  doorExceptionPage: (params: DoorExceptionQuery) =>
+    axiosInstance.get('/iscs/exception/getExceptionPage', { params }),
+};

+ 19 - 0
src/api/material/exception.ts

@@ -0,0 +1,19 @@
+import axiosInstance from '../../utils/axios';
+
+export interface ExceptionQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  materialsName?: string;
+  exceptionType?: number;
+  exceptionStatus?: number;
+  startTime?: Date | string;
+  endTime?: Date | string;
+  [key: string]: any;
+}
+
+export const materialExceptionApi = {
+  returnExceptionPage: (params: ExceptionQuery) =>
+    axiosInstance.get('/iscs/exception-misplace/getExceptionMisplacePage', { params }),
+};

+ 49 - 0
src/api/material/index.ts

@@ -0,0 +1,49 @@
+export { materialTypeApi } from './type';
+export { materialLockerApi } from './lockers';
+export { materialInformationApi } from './information';
+export { materialLoanApi } from './loan';
+export { materialStandardApi } from './standard';
+export { materialPropertyValueApi } from './propertyValue';
+export { materialPlanApi } from './plan';
+export { materialCheckRecordApi } from './checkRecord';
+export { materialReplaceApi } from './replace';
+export { materialBlacklistApi } from './blacklist';
+export { materialInstructionsApi } from './instructions';
+export { materialStatisticsApi } from './statistics';
+export { materialExceptionApi } from './exception';
+export { materialDoorExceptionApi } from './doorException';
+export { materialManualExceptionApi } from './manualException';
+
+import { materialTypeApi } from './type';
+import { materialLockerApi } from './lockers';
+import { materialInformationApi } from './information';
+import { materialLoanApi } from './loan';
+import { materialStandardApi } from './standard';
+import { materialPropertyValueApi } from './propertyValue';
+import { materialPlanApi } from './plan';
+import { materialCheckRecordApi } from './checkRecord';
+import { materialReplaceApi } from './replace';
+import { materialBlacklistApi } from './blacklist';
+import { materialInstructionsApi } from './instructions';
+import { materialStatisticsApi } from './statistics';
+import { materialExceptionApi } from './exception';
+import { materialDoorExceptionApi } from './doorException';
+import { materialManualExceptionApi } from './manualException';
+
+export const materialApi = {
+  type: materialTypeApi,
+  locker: materialLockerApi,
+  information: materialInformationApi,
+  loan: materialLoanApi,
+  standard: materialStandardApi,
+  propertyValue: materialPropertyValueApi,
+  plan: materialPlanApi,
+  checkRecord: materialCheckRecordApi,
+  replace: materialReplaceApi,
+  blacklist: materialBlacklistApi,
+  instructions: materialInstructionsApi,
+  statistics: materialStatisticsApi,
+  exception: materialExceptionApi,
+  doorException: materialDoorExceptionApi,
+  manualException: materialManualExceptionApi,
+};

+ 63 - 0
src/api/material/information.ts

@@ -0,0 +1,63 @@
+import axiosInstance from '../../utils/axios';
+
+export interface MaterialsQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  materialsName?: string;
+  materialsTypeId?: number;
+  cabinetId?: number;
+  loanState?: number;
+  [key: string]: any;
+}
+
+export interface MaterialsVO {
+  materialsId?: number;
+  materialsName: string;
+  materialsTypeId: number;
+  materialsIcon?: string;
+  materialsPicture?: string;
+  loanState?: number;
+  loanUserId?: string;
+  loanTime?: Date | string;
+  restitutionToId?: string;
+  restitutionUserId?: string;
+  restitutionTime?: Date | string;
+  remark?: string;
+  [key: string]: any;
+}
+
+export interface MaterialsBindingVO {
+  materialsId: number;
+  cabinetId: number;
+}
+
+export interface MaterialsLoanVO {
+  materialsId: number;
+  loanState: number;
+  loanUserId?: string;
+  restitutionToId?: string;
+  restitutionUserId?: string;
+}
+
+export const materialInformationApi = {
+  listMaterials: (params: MaterialsQuery) =>
+    axiosInstance.get('/iscs/materials/getMaterialsPage', { params }),
+  getMaterialsInfo: (id: number) =>
+    axiosInstance.get('/iscs/materials/selectMaterialsById', { params: { id } }),
+  addMaterials: (data: MaterialsVO) => axiosInstance.post('/iscs/materials/insertMaterials', data),
+  updateMaterials: (data: MaterialsVO) => axiosInstance.put('/iscs/materials/updateMaterials', data),
+  deleteMaterials: (ids: number | string) =>
+    axiosInstance.delete('/iscs/materials/deleteMaterialsList', { params: { ids } }),
+  getMaterialsCabinets: (params: MaterialsQuery) =>
+    axiosInstance.get('/iscs/materials-cabinet/getMaterialsCabinetPage', { params }),
+  updateMaterialsBinding: (data: MaterialsBindingVO) =>
+    axiosInstance.post('/iscs/materials/updateMaterialsBinding', data),
+  updateMaterialsLoan: (data: MaterialsLoanVO[]) =>
+    axiosInstance.post('/iscs/materials/updateIsMaterialById', { list: data }),
+  getExMaterials: (params: MaterialsQuery) =>
+    axiosInstance.get('/iscs/materials/getExMaterials', { params }),
+  getExMaterialType: (params: { materialsId: number }) =>
+    axiosInstance.get('/iscs/hardware/material-api/selectExMaterialTypeById', { params }),
+};

+ 41 - 0
src/api/material/instructions.ts

@@ -0,0 +1,41 @@
+import axiosInstance from '../../utils/axios';
+
+export interface InstructionsQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  instructionsTitle?: string;
+  materialsTypeId?: number;
+  fileType?: string;
+  [key: string]: any;
+}
+
+export interface InstructionsVO {
+  instructionsId?: number;
+  instructionsTitle: string;
+  materialsTypeId: number;
+  fileType: string;
+  fileUrl: string;
+  /** 与旧版前端一致,列表列 prop 为 orderNum */
+  orderNum?: number;
+  remark?: string;
+  [key: string]: any;
+}
+
+export const materialInstructionsApi = {
+  listInstructions: (params: InstructionsQuery) =>
+    axiosInstance.get('/iscs/materials-instructions/getMaterialsInstructionsPage', { params }),
+  getInstructionsInfo: (id: number) =>
+    axiosInstance.get('/iscs/materials-instructions/selectMaterialsInstructionsById', {
+      params: { id },
+    }),
+  addInstructions: (data: InstructionsVO) =>
+    axiosInstance.post('/iscs/materials-instructions/insertMaterialsInstructions', data),
+  updateInstructions: (data: InstructionsVO) =>
+    axiosInstance.put('/iscs/materials-instructions/updateMaterialsInstructions', data),
+  delInstructions: (ids: number | string) =>
+    axiosInstance.delete('/iscs/materials-instructions/deleteMaterialsInstructionsList', {
+      params: { ids },
+    }),
+};

+ 45 - 0
src/api/material/loan.ts

@@ -0,0 +1,45 @@
+import axiosInstance from '../../utils/axios';
+
+export interface LoanQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  /** 物资柜(与旧版 coll 一致:loanFromId;部分环境亦认 cabinetId) */
+  loanFromId?: number;
+  cabinetId?: number;
+  materialsName?: string;
+  materialsTypeId?: number;
+  loanUserName?: string;
+  loanUserId?: string;
+  loanTimeStart?: string;
+  loanTimeEnd?: string;
+  restitutionUserName?: string;
+  restitutionTimeStart?: string;
+  restitutionTimeEnd?: string;
+  /** 领取记录状态(旧版字典字符串,如「超时未还」传 '2') */
+  status?: string;
+  loanState?: number;
+  startTime?: Date | string;
+  endTime?: Date | string;
+  [key: string]: any;
+}
+
+export interface LoanVO {
+  materialsLoanId?: number;
+  materialsId: number;
+  materialsName?: string;
+  loanUserId?: string;
+  loanUserName?: string;
+  loanState?: number;
+  loanTime?: Date | string;
+  expectedReturnTime?: Date | string;
+  actualReturnTime?: Date | string;
+  remark?: string;
+  [key: string]: any;
+}
+
+export const materialLoanApi = {
+  listLoan: (params: LoanQuery) =>
+    axiosInstance.get('/iscs/materials-loan/getMaterialsLoanPage', { params }),
+};

+ 50 - 0
src/api/material/lockers.ts

@@ -0,0 +1,50 @@
+import axiosInstance from '../../utils/axios';
+
+export interface CabinetQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  cabinetName?: string;
+  cabinetCode?: string;
+  status?: number;
+  /** 所属区域/岗位,与列表左侧区域树联动 */
+  workstationId?: number;
+  [key: string]: any;
+}
+
+export interface CabinetVO {
+  cabinetId?: number;
+  cabinetName: string;
+  cabinetCode: string;
+  cabinetType?: string;
+  cabinetIcon?: string;
+  cabinetPicture?: string;
+  status?: number;
+  remark?: string;
+  workstationId?: number;
+  workstationCode?: string;
+  workstationName?: string;
+  /** 异常类型(接口字段 exReason,可为字符串):0 正常 1 物资异常 2 物资柜异常 3 错还柜子 4 物资借出 5 超时未关门 */
+  exReason?: number | string;
+  exceptionType?: number;
+  /** 异常类型描述(若后端返回) */
+  exceptionTypeName?: string;
+  exceptionName?: string;
+  exceptionDesc?: string;
+  abnormalTypeName?: string;
+  [key: string]: any;
+}
+
+export const materialLockerApi = {
+  listMaterialsCabinet: (params: CabinetQuery) =>
+    axiosInstance.get('/iscs/materials-cabinet/getMaterialsCabinetPage', { params }),
+  getMaterialsCabinetInfo: (id: number) =>
+    axiosInstance.get('/iscs/materials-cabinet/selectMaterialsCabinetById', { params: { id } }),
+  addMaterialsCabinet: (data: CabinetVO) =>
+    axiosInstance.post('/iscs/materials-cabinet/insertMaterialsCabinet', data),
+  updateMaterialsCabinet: (data: CabinetVO) =>
+    axiosInstance.put('/iscs/materials-cabinet/updateMaterialsCabinet', data),
+  deleteMaterialsCabinet: (ids: number) =>
+    axiosInstance.delete('/iscs/materials-cabinet/deleteMaterialsCabinetList', { params: { ids } }),
+};

+ 47 - 0
src/api/material/manualException.ts

@@ -0,0 +1,47 @@
+import axiosInstance from '../../utils/axios';
+
+export interface ManualExceptionQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  exceptionCategory?: string;
+  exceptionDescription?: string;
+  exceptionLevel?: string;
+  exceptionType?: string;
+  status?: string;
+  [key: string]: any;
+}
+
+export interface ManualExceptionVO {
+  exceptionId?: number;
+  exceptionCategory: string;
+  exceptionDescription?: string;
+  exceptionLevel: string;
+  exceptionLevelName?: string;
+  exceptionType: string;
+  exceptionTypeName?: string;
+  raiseTime?: string;
+  raiser?: string;
+  raiserName?: string;
+  sourceId?: string;
+  sourceName?: string;
+  handleTime?: string;
+  status: string;
+  [key: string]: any;
+}
+
+export const materialManualExceptionApi = {
+  listManualException: (params: ManualExceptionQuery) =>
+    axiosInstance.get('/iscs/exception/getExceptionPage', { params }),
+  getExceptionInfo: (id: number) =>
+    axiosInstance.get('/iscs/exception/selectExceptionById', { params: { id } }),
+  addException: (data: ManualExceptionVO) =>
+    axiosInstance.post('/iscs/exception/insertIsException', data),
+  updateException: (data: ManualExceptionVO) =>
+    axiosInstance.post('/iscs/exception/updateIsException', data),
+  deleteException: (id: number) =>
+    axiosInstance.post('/iscs/exception/deleteIsExceptionByExceptionIds', null, {
+      params: { exceptionIds: id },
+    }),
+};

+ 68 - 0
src/api/material/plan.ts

@@ -0,0 +1,68 @@
+import axiosInstance from '../../utils/axios';
+
+export interface PlanQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  /** 物资柜维度筛选(若后端支持) */
+  cabinetId?: number;
+  planName?: string;
+  /** 与旧版 inspectionplan 一致:检察员昵称 */
+  checkUserName?: string;
+  /** 检查计划状态(字典 CHECKING_STATUS,多为字符串) */
+  status?: string;
+  planStatus?: number;
+  startTime?: Date | string;
+  endTime?: Date | string;
+  [key: string]: any;
+}
+
+export interface PlanVO {
+  planId?: number;
+  planName: string;
+  planStatus?: number;
+  startTime?: Date | string;
+  endTime?: Date | string;
+  checkCycle?: number;
+  checkCycleUnit?: string;
+  remark?: string;
+  [key: string]: any;
+}
+
+export interface AutoConfigVO {
+  configCode: string;
+  configValue: string;
+  configName: string;
+  configType?: number;
+  status?: number;
+}
+
+/** 自动创建检查计划配置(POST /updateAutomaticConfig,与旧版 inspectionplan 表单字段一致) */
+export interface PlanAutomaticCreateConfig {
+  planFrequency?: string | null;
+  planDate?: string | number | null;
+  checkUserId?: number | string | null;
+  startStatus?: number;
+  templateCode?: string;
+}
+
+export const materialPlanApi = {
+  listPlan: (params: PlanQuery) =>
+    axiosInstance.get('/iscs/materials-check-plan/getMaterialsCheckPlanPage', { params }),
+  getPlanInfo: (id: number) =>
+    axiosInstance.get('/iscs/materials-check-plan/selectMaterialsCheckPlanById', { params: { id } }),
+  addPlan: (data: PlanVO) =>
+    axiosInstance.post('/iscs/materials-check-plan/insertMaterialsCheckPlan', data),
+  updatePlan: (data: PlanVO) =>
+    axiosInstance.put('/iscs/materials-check-plan/updateMaterialsCheckPlan', data),
+  deletePlan: (ids: number) =>
+    axiosInstance.delete('/iscs/materials-check-plan/deleteMaterialsCheckPlanList', { params: { ids } }),
+  getCheckPlanCabinetList: (params: PlanQuery) =>
+    axiosInstance.get('/iscs/materials-plan-cabinet/getMaterialsPlanCabinetPage', { params }),
+  updateAutomaticConfig: (data: PlanAutomaticCreateConfig | AutoConfigVO) =>
+    axiosInstance.post('/iscs/mail-notify-config/updateAutomaticConfig', data),
+  /** 旧版使用 templateCode(如 test_01);部分环境认 configCode,可同时传 */
+  selectIsMailNotifyConfigByCode: (params: { configCode?: string; templateCode?: string }) =>
+    axiosInstance.get('/iscs/mail-notify-config/selectMailNotifyConfigByCode', { params }),
+};

+ 45 - 0
src/api/material/propertyValue.ts

@@ -0,0 +1,45 @@
+import axiosInstance from '../../utils/axios';
+
+export interface PropertyValueQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  propertyId?: number;
+  /** 物资规格种类名称(与老系统查询条件一致) */
+  propertyName?: string;
+  propertyValue?: string;
+  /** 物资规格筛选;可与 propertyName 同时传,空字符串表示不按规格值过滤 */
+  valueName?: string;
+  status?: number;
+  [key: string]: any;
+}
+
+export interface PropertyValueVO {
+  recordId?: number;
+  propertyId: number;
+  propertyName?: string;
+  propertyValue: string;
+  valueName?: string;
+  propertyUnit?: string;
+  status?: number;
+  remark?: string;
+  [key: string]: any;
+}
+
+export const materialPropertyValueApi = {
+  PropertyValuePage: (params: PropertyValueQuery) =>
+    axiosInstance.get('/iscs/materials-property-value/getMaterialsPropertyValuePage', { params }),
+  selectPropertyValueById: (id: number) =>
+    axiosInstance.get('/iscs/materials-property-value/selectMaterialsPropertyValueById', {
+      params: { id },
+    }),
+  addPropertyValue: (data: PropertyValueVO) =>
+    axiosInstance.post('/iscs/materials-property-value/insertMaterialsPropertyValue', data),
+  updatePropertyValue: (data: PropertyValueVO) =>
+    axiosInstance.put('/iscs/materials-property-value/updateMaterialsPropertyValue', data),
+  deletePropertyValue: (ids: number) =>
+    axiosInstance.delete('/iscs/materials-property-value/deleteMaterialsPropertyValueList', {
+      params: { ids },
+    }),
+};

+ 54 - 0
src/api/material/replace.ts

@@ -0,0 +1,54 @@
+import axiosInstance from '../../utils/axios';
+
+export interface ChangeRecordQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  materialsId?: number;
+  /** 从检查记录「查看」带入时筛选(若后端支持) */
+  recordId?: number;
+  checkRecordId?: number;
+  materialsCheckRecordId?: number;
+  /** 涉及该柜的更换(若后端支持聚合参数) */
+  cabinetId?: number;
+  oldCabinetId?: number;
+  newCabinetId?: number;
+  changeTime?: Date | string;
+  /** 与旧版 replacementrecords/index.vue 查询参数一致 */
+  materialsTypeId?: number;
+  oldMaterialsId?: number | string;
+  oldMaterialsRfid?: string;
+  newMaterialsId?: number | string;
+  newMaterialsRfid?: string;
+  changeUserName?: string;
+  startTime?: string;
+  endTime?: string;
+  [key: string]: any;
+}
+
+export interface ChangeRecordVO {
+  changeId?: number;
+  materialsId: number;
+  oldCabinetId: number;
+  newCabinetId: number;
+  changeReason: string;
+  changeTime?: Date | string;
+  remark?: string;
+  [key: string]: any;
+}
+
+export const materialReplaceApi = {
+  listChangeRecord: (params: ChangeRecordQuery) =>
+    axiosInstance.get('/iscs/materials-change-record/getMaterialsChangeRecordPage', { params }),
+  getChangeRecordInfo: (id: number) =>
+    axiosInstance.get('/iscs/materials-change-record/selectMaterialsChangeRecordById', {
+      params: { id },
+    }),
+  addChangeRecord: (data: ChangeRecordVO) =>
+    axiosInstance.post('/iscs/change/insertIsMaterialsChangeRecord', data),
+  updateChangeRecord: (data: ChangeRecordVO) =>
+    axiosInstance.put('/iscs/materials-change-record/updateMaterialsChangeRecord', data),
+  deleteChangeRecord: (ids: number) =>
+    axiosInstance.post(`/iscs/materials-change-record/deleteMaterialsChangeRecordList?ids=${ids}`),
+};

+ 37 - 0
src/api/material/standard.ts

@@ -0,0 +1,37 @@
+import axiosInstance from '../../utils/axios';
+
+export interface PropertyQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  propertyName?: string;
+  propertyCode?: string;
+  status?: number;
+  [key: string]: any;
+}
+
+export interface PropertyVO {
+  propertyId?: number;
+  id?: number;
+  propertyName: string;
+  propertyCode: string;
+  propertyType?: string;
+  propertyUnit?: string;
+  status?: number;
+  remark?: string;
+  [key: string]: any;
+}
+
+export const materialStandardApi = {
+  PropertyPage: (params: PropertyQuery) =>
+    axiosInstance.get('/iscs/materials-property/getMaterialsPropertyPage', { params }),
+  selectPropertyById: (id: number) =>
+    axiosInstance.get('/iscs/materials-property/selectMaterialsPropertyById', { params: { id } }),
+  addProperty: (data: PropertyVO) =>
+    axiosInstance.post('/iscs/materials-property/insertMaterialsProperty', data),
+  updateProperty: (data: PropertyVO) =>
+    axiosInstance.put('/iscs/materials-property/updateMaterialsProperty', data),
+  deleteProperty: (ids: number | string) =>
+    axiosInstance.delete('/iscs/materials-property/deleteMaterialsPropertyList', { params: { ids } }),
+};

+ 88 - 0
src/api/material/statistics.ts

@@ -0,0 +1,88 @@
+import axiosInstance from '../../utils/axios';
+
+export interface StatisticsQuery {
+  startTime?: Date | string;
+  endTime?: Date | string;
+  type?: string;
+  [key: string]: any;
+}
+
+/** 按物资柜的开关/异常等统计(行数据) */
+export type CabinetStatRow = {
+  cabinetName?: string;
+  openCount?: number;
+  misplaceCount?: number;
+  openTimeoutCount?: number;
+  [key: string]: any;
+};
+
+/** 按物资类型的借还/均摊时长等 */
+export type MaterialsLoanStatRow = {
+  materialsTypeName?: string;
+  allCount?: number;
+  returnCount?: number;
+  timeoutCount?: number;
+  averageTime?: number;
+  [key: string]: any;
+};
+
+/** 特殊状态物资、更换统计等(按类型) */
+export type MaterialsTypeCountRow = {
+  materialsTypeName?: string;
+  willExpireCount?: number;
+  expiredCount?: number;
+  badCount?: number;
+  allCount?: number;
+  expireCount?: number;
+  [key: string]: any;
+};
+
+/** 每日借还 */
+export type DayLoanStatRow = {
+  day?: string;
+  date?: string;
+  allCount?: number;
+  returnCount?: number;
+  timeoutCount?: number;
+  [key: string]: any;
+};
+
+export const materialStatisticsApi = {
+  getMaterialInventory: (type: string) =>
+    axiosInstance.get('/iscs/statistics-api/getMaterialInventory', { params: { type } }),
+  getInventorySum: () => axiosInstance.get('/iscs/statistics-api/getInventorySum'),
+  exportMaterialInventory: () =>
+    axiosInstance.post('/iscs/statistics-api/exportMaterialInventory', undefined, {
+      responseType: 'blob',
+    }),
+  exportBaseData: (params: Record<string, any>) =>
+    axiosInstance.post('/iscs/statistics-api/exportBaseData', params, { responseType: 'blob' }),
+  exportClaimAndReturn: (params: Record<string, any>) =>
+    axiosInstance.post('/iscs/statistics-api/exportClaimAndReturn', params, { responseType: 'blob' }),
+  getCabinetStatistics: (params: StatisticsQuery) =>
+    axiosInstance.get<CabinetStatRow[] | unknown>('/iscs/statistics-api/getCabinetStatistics', { params }),
+  getMaterialsLoanStatistics: (params: StatisticsQuery) =>
+    axiosInstance.get<MaterialsLoanStatRow[] | unknown>('/iscs/statistics-api/getMaterialsLoanStatistics', {
+      params,
+    }),
+  getMaterialsStatusStatistics: (params: StatisticsQuery) =>
+    axiosInstance.get<MaterialsTypeCountRow[] | unknown>('/iscs/statistics-api/getMaterialsStatusStatistics', {
+      params,
+    }),
+  getMaterialsChangeStatistics: (params: StatisticsQuery) =>
+    axiosInstance.get<MaterialsTypeCountRow[] | unknown>('/iscs/statistics-api/getMaterialsChangeStatistics', {
+      params,
+    }),
+  getDayLoanStatistics: (params: StatisticsQuery) =>
+    axiosInstance.get<DayLoanStatRow[] | unknown>('/iscs/statistics-api/getDayLoanStatistics', { params }),
+  getHomePage: (params: StatisticsQuery) =>
+    axiosInstance.get('/iscs/statistics-api/getHomePage', { params }),
+};
+
+export function toStatArray<T>(res: T[] | { list?: T[]; records?: T[] } | null | undefined): T[] {
+  if (res == null) return [];
+  if (Array.isArray(res)) return res;
+  if (Array.isArray((res as { list?: T[] }).list)) return (res as { list: T[] }).list;
+  if (Array.isArray((res as { records?: T[] }).records)) return (res as { records: T[] }).records;
+  return [];
+}

+ 42 - 0
src/api/material/type.ts

@@ -0,0 +1,42 @@
+import axiosInstance from '../../utils/axios';
+
+export interface TypeQuery {
+  pageNo?: number;
+  pageSize?: number;
+  current?: number;
+  size?: number;
+  materialsTypeName?: string;
+  typeName?: string;
+  typeCode?: string;
+  status?: number;
+  [key: string]: any;
+}
+
+export interface TypeVO {
+  materialsTypeId?: number;
+  id?: number;
+  typeName?: string;
+  typeCode?: string;
+  typeIcon?: string;
+  typePicture?: string;
+  materialsTypeName?: string;
+  materialsTypeIcon?: string;
+  materialsTypePicture?: string;
+  parentId?: number;
+  propertyIds?: string;
+  checkStandard?: string;
+  remark?: string;
+  status?: number | string;
+  [key: string]: any;
+}
+
+export const materialTypeApi = {
+  listType: (params: TypeQuery) =>
+    axiosInstance.get('/iscs/materials-type/getMaterialsTypePage', { params }),
+  getTypeInfo: (id: number) =>
+    axiosInstance.get('/iscs/materials-type/selectMaterialsTypeById', { params: { id } }),
+  addType: (data: TypeVO) => axiosInstance.post('/iscs/materials-type/insertMaterialsType', data),
+  updateType: (data: TypeVO) => axiosInstance.put('/iscs/materials-type/updateMaterialsType', data),
+  deleteType: (ids: number) =>
+    axiosInstance.delete('/iscs/materials-type/deleteMaterialsTypeList', { params: { ids } }),
+};

+ 6 - 0
src/components/IsolationWorkListTable.css

@@ -43,3 +43,9 @@
 .system-mgmt-shadcn-table [data-slot="table-body"] > [data-slot="table-row"]:not(.system-mgmt-row-alt):hover > [data-slot="table-cell"] {
   background-color: #f9fafb !important;
 }
+
+/* 物资类型树表:展开/占位图标与「物资类型名称」文案留出间距(勿改 td 为 flex,避免破坏表格布局) */
+.material-type-tree-table .ant-table-row-expand-icon,
+.material-type-tree-table .ant-table-row-expand-icon-spaced {
+  margin-inline-end: 12px;
+}

+ 58 - 0
src/components/material/MaterialManagement.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+import { normalizeMaterialSubMenu } from './materialSubMenu';
+import MaterialLockersPage from './pages/MaterialLockersPage';
+import MaterialInventoryPage from './pages/MaterialInventoryPage';
+import MaterialTypePage from './pages/MaterialTypePage';
+import MaterialStandardPage from './pages/MaterialStandardPage';
+import MaterialStandardPropertyValuesPage from './pages/MaterialStandardPropertyValuesPage';
+import MaterialInformationPage from './pages/MaterialInformationPage';
+import MaterialLoanPage from './pages/MaterialLoanPage';
+import MaterialInspectionPlanPage from './pages/MaterialInspectionPlanPage';
+import MaterialInspectionRecordPage from './pages/MaterialInspectionRecordPage';
+import MaterialReplacementRecordPage from './pages/MaterialReplacementRecordPage';
+import MaterialBlacklistPage from './pages/MaterialBlacklistPage';
+import MaterialInstructionsPage from './pages/MaterialInstructionsPage';
+import MaterialBaseDataStatisticsPage from './pages/MaterialBaseDataStatisticsPage';
+import MaterialClaimReturnStatisticsPage from './pages/MaterialClaimReturnStatisticsPage';
+
+interface MaterialManagementProps {
+  subMenu: string;
+  /** 后端子菜单 path,用于在 key 不可靠时按路由解析到正确子页 */
+  routePath?: string | null;
+}
+
+export default function MaterialManagement({ subMenu, routePath }: MaterialManagementProps) {
+  const key = normalizeMaterialSubMenu(subMenu, routePath);
+  switch (key) {
+    case 'materialLockers':
+      return <MaterialLockersPage />;
+    case 'materialInventory':
+      return <MaterialInventoryPage />;
+    case 'materialType': 
+      return <MaterialTypePage />;
+    case 'materialStandard':
+      return <MaterialStandardPage />;
+    case 'materialStandardPropertyValues':
+      return <MaterialStandardPropertyValuesPage />;
+    case 'materialInformation':
+      return <MaterialInformationPage />;
+    case 'materialLoan':
+      return <MaterialLoanPage />;
+    case 'materialInspectionPlan':
+      return <MaterialInspectionPlanPage />;
+    case 'materialInspectionRecord':
+      return <MaterialInspectionRecordPage />;
+    case 'materialReplacementRecord':
+      return <MaterialReplacementRecordPage />;
+    case 'materialBlacklist':
+      return <MaterialBlacklistPage />;
+    case 'materialInstructions':
+      return <MaterialInstructionsPage />;
+    case 'materialBaseDataStatistics':
+      return <MaterialBaseDataStatisticsPage />;
+    case 'materialClaimReturnStatistics':
+      return <MaterialClaimReturnStatisticsPage />;
+    default:
+      return <MaterialLockersPage />;
+  }
+}

+ 266 - 0
src/components/material/MaterialPageLayout.tsx

@@ -0,0 +1,266 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Space, Select, Input } from 'antd';
+import { Search, RefreshCw, Edit2, Trash2, Eye } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Modal } from 'antd';
+import { ExclamationCircleOutlined } from '@ant-design/icons';
+import { Button as UIButton } from '../ui/button';
+import '../IsolationWorkListTable.css';
+
+/** 与点位管理一致:页面最外层 */
+export function MaterialPageRoot({ children }: { children: React.ReactNode }) {
+  return <div className="min-h-0 w-full max-w-full space-y-4 p-6">{children}</div>;
+}
+
+/** 与 SegregationPointManagement 一致:单行 justify-between,左为筛选+搜索/重置,右为新增/批量操作 */
+export function MaterialToolbarCard({
+  filterRow,
+  toolbarRight,
+  onSearch,
+  onReset,
+  hideSearchButtons,
+  topSlot,
+}: {
+  filterRow: React.ReactNode;
+  toolbarRight?: React.ReactNode;
+  /** hideSearchButtons 为 true 时可不传 */
+  onSearch?: () => void;
+  onReset?: () => void;
+  /** 盘点等特殊页可自定义按钮,不显示默认搜索/重置 */
+  hideSearchButtons?: boolean;
+  /** 嵌入物资柜详情时:与筛选区同一张白卡顶部的 Tab 等 */
+  topSlot?: React.ReactNode;
+}) {
+  const { t } = useTranslation();
+  const showSearch = !hideSearchButtons && onSearch && onReset;
+  const inner = (
+    <div className="flex items-center justify-between gap-3 flex-wrap">
+      <div className="flex min-w-0 flex-1 items-center gap-3 flex-wrap">
+        {filterRow}
+        {showSearch ? (
+          <Space className="flex-shrink-0" size="small">
+            <Button type="primary" icon={<Search />} onClick={onSearch}>
+              {t('common.search')}
+            </Button>
+            <Button icon={<RefreshCw />} onClick={onReset}>
+              {t('common.reset')}
+            </Button>
+          </Space>
+        ) : null}
+      </div>
+      {toolbarRight != null && toolbarRight !== false ? (
+        <div className="flex flex-shrink-0 justify-end">
+          <Space size="small">{toolbarRight}</Space>
+        </div>
+      ) : null}
+    </div>
+  );
+  if (topSlot) {
+    return (
+      <div className="overflow-hidden rounded-xl border border-gray-200/50 bg-white shadow-sm">
+        <div className="border-b border-gray-100/80 px-2 pb-3 pt-4 sm:px-3 sm:pb-4 sm:pt-5">{topSlot}</div>
+        <div className="px-5 pb-6 pt-6 sm:pb-8 sm:pt-7">{inner}</div>
+      </div>
+    );
+  }
+  return <div className="rounded-xl border border-gray-200/50 bg-white p-5 shadow-sm">{inner}</div>;
+}
+
+/** 与点位管理一致:表格白底容器 + 隔行样式类名;flush 时无外框,便于嵌入外层统一卡片 */
+export function MaterialTableCard({ children, flush }: { children: React.ReactNode; flush?: boolean }) {
+  return (
+    <div
+      className={
+        flush
+          ? 'bg-white isolation-work-list-table'
+          : 'bg-white rounded-lg border border-gray-200 isolation-work-list-table'
+      }
+    >
+      {children}
+    </div>
+  );
+}
+
+/** 与 SegregationPointManagement 底部分页条一致(加载中或无数据时不展示) */
+export function MaterialPaginationBar({
+  total,
+  current,
+  pageSize,
+  onPageChange,
+  loading,
+  listLength,
+  pageSizeOptions = [10, 20, 50, 100],
+  onPageSizeChange,
+  showQuickJumper,
+  flushTop,
+}: {
+  total: number;
+  current: number;
+  pageSize: number;
+  onPageChange: (page: number) => void;
+  loading: boolean;
+  listLength: number;
+  /** 传入时显示「每页条数」下拉 */
+  pageSizeOptions?: number[];
+  onPageSizeChange?: (size: number) => void;
+  /** 是否显示「前往 x 页」输入框 */
+  showQuickJumper?: boolean;
+  /** 贴紧表格底部:无上圆角与外侧边框,仅顶部分隔线(用于外层统一列表卡片) */
+  flushTop?: boolean;
+}) {
+  const { t } = useTranslation();
+  const totalPages = Math.max(1, Math.ceil(total / pageSize));
+  const [jumpVal, setJumpVal] = useState(String(current));
+  useEffect(() => {
+    setJumpVal(String(current));
+  }, [current]);
+  if (loading || listLength === 0) return null;
+  const extraControls = Boolean(onPageSizeChange || showQuickJumper);
+  return (
+    <div
+      className={
+        flushTop
+          ? 'border-t border-gray-100/80 bg-slate-50/30 px-6 py-6 sm:px-8 sm:py-8'
+          : 'rounded-lg border border-gray-200 bg-white px-6 py-6 sm:py-7'
+      }
+    >
+      <div className={`flex items-center justify-between${extraControls ? ' flex-wrap gap-3' : ''}`}>
+        <div className="text-sm text-gray-600">
+          {t('common.total')} <span className="text-blue-600 font-medium">{total}</span> {t('common.records')}
+        </div>
+        <div className="flex items-center gap-2">
+          {onPageSizeChange ? (
+            <Select
+              value={pageSize}
+              style={{ width: 110 }}
+              options={pageSizeOptions.map((n) => ({ value: n, label: `${n}条/页` }))}
+              onChange={(v) => onPageSizeChange(v)}
+            />
+          ) : null}
+          <Button onClick={() => onPageChange(current - 1)} disabled={current <= 1}>
+            {t('common.prevPage')}
+          </Button>
+          <span className="flex items-center px-4 py-2 text-sm text-gray-600">
+            {current} / {totalPages}
+          </span>
+          <Button onClick={() => onPageChange(current + 1)} disabled={current >= totalPages}>
+            {t('common.nextPage')}
+          </Button>
+          {showQuickJumper ? (
+            <span className="flex items-center gap-1 text-sm text-gray-600">
+              前往
+              <Input
+                className="w-12 text-center"
+                size="small"
+                value={jumpVal}
+                onChange={(e) => setJumpVal(e.target.value)}
+                onPressEnter={() => {
+                  const p = parseInt(jumpVal, 10);
+                  if (Number.isFinite(p) && p >= 1 && p <= totalPages) onPageChange(p);
+                }}
+              />
+              页
+            </span>
+          ) : null}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+const ghostLinkHover = {
+  onMouseEnter: (e: React.MouseEvent<HTMLButtonElement>) => {
+    e.currentTarget.style.color = '#1677ff';
+    e.currentTarget.style.textDecoration = 'underline';
+    e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #1677ff');
+  },
+  onMouseLeave: (e: React.MouseEvent<HTMLButtonElement>) => {
+    e.currentTarget.style.color = '#000000';
+    e.currentTarget.style.textDecoration = 'none';
+    e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #000000');
+  },
+};
+
+/** 与列表「查看」一致 */
+export function MaterialViewButton({ label, onClick }: { label: string; onClick: () => void }) {
+  return (
+    <UIButton
+      variant="ghost"
+      size="sm"
+      onClick={onClick}
+      className="h-8 px-2 transition-colors hover:underline"
+      style={{ color: '#000000' }}
+      {...ghostLinkHover}
+    >
+      <Eye className="w-4 h-4" style={{ color: '#000000' }} />
+      <span className="ml-1">{label}</span>
+    </UIButton>
+  );
+}
+
+/** 与点位管理列表「编辑」一致 */
+export function MaterialEditButton({ label, onClick }: { label: string; onClick: () => void }) {
+  return (
+    <UIButton
+      variant="ghost"
+      size="sm"
+      onClick={onClick}
+      className="h-8 px-2 transition-colors hover:underline"
+      style={{ color: '#000000' }}
+      {...ghostLinkHover}
+    >
+      <Edit2 className="w-4 h-4" style={{ color: '#000000' }} />
+      <span className="ml-1">{label}</span>
+    </UIButton>
+  );
+}
+
+/** 与点位管理列表「删除」一致 */
+export function MaterialDeleteButton({ label, onClick }: { label: string; onClick: () => void }) {
+  return (
+    <UIButton
+      variant="ghost"
+      size="sm"
+      onClick={onClick}
+      className="h-8 px-2 transition-colors hover:underline"
+      style={{ color: '#000000' }}
+      {...ghostLinkHover}
+    >
+      <Trash2 className="w-4 h-4" style={{ color: '#000000' }} />
+      <span className="ml-1">{label}</span>
+    </UIButton>
+  );
+}
+
+/** 与点位管理删除确认弹框一致 */
+export function materialConfirmDelete(
+  t: (key: string) => string,
+  options: { count?: number; onOk: () => void | Promise<void> }
+) {
+  const count = options.count ?? 1;
+  Modal.confirm({
+    title: t('common.confirmDelete'),
+    icon: <ExclamationCircleOutlined />,
+    content: `${t('common.confirmDeleteText')} ${count} ${t('common.records')}?`,
+    okText: t('common.confirmDelete'),
+    okType: 'danger',
+    cancelText: t('common.cancel'),
+    onOk: options.onOk,
+  });
+}
+
+/** 与点位管理表单弹窗一致的 Modal 默认属性(在组件内展开) */
+export function getMaterialFormModalProps(t: (key: string) => string, width: number = 720) {
+  return {
+    okText: t('common.confirm'),
+    cancelText: t('common.cancel'),
+    width,
+    destroyOnClose: true as const,
+  };
+}
+
+export const materialFormLayout = {
+  labelCol: { span: 6 },
+  wrapperCol: { span: 18 },
+  layout: 'horizontal' as const,
+};

+ 243 - 0
src/components/material/materialListUtils.ts

@@ -0,0 +1,243 @@
+/** 兼容 MyBatis-Page / 自定义包装 / 拦截器未完全解包等结构 */
+export function pickRecords(data: any): any[] {
+  if (data == null) return [];
+  if (Array.isArray(data)) return data;
+  const inner = data.data;
+  const candidates = [
+    data.records,
+    data.list,
+    data.rows,
+    data.items,
+    inner?.records,
+    inner?.list,
+    inner?.rows,
+    data.result?.records,
+    data.result?.list,
+    data.page?.records,
+    data.page?.list,
+  ];
+  for (const c of candidates) {
+    if (Array.isArray(c)) return c;
+  }
+  return [];
+}
+
+export function pickTotal(data: any): number {
+  if (data == null || typeof data !== 'object') return 0;
+  const inner = data.data;
+  const raw =
+    data.total ??
+    data.totalCount ??
+    data.totalRow ??
+    inner?.total ??
+    inner?.totalCount ??
+    data.page?.total ??
+    data.result?.total;
+  const n = Number(raw);
+  return Number.isFinite(n) ? n : 0;
+}
+
+/** 同时传 pageNo/pageSize 与 current/size,兼容不同后端分页参数名 */
+export function buildPageParams(pageNo: number, pageSize: number, extra?: Record<string, any>) {
+  return {
+    pageNo,
+    pageSize,
+    current: pageNo,
+    size: pageSize,
+    limit: pageSize,
+    ...(extra || {}),
+  };
+}
+
+/** getMaterialsTypePage 等接口 pageSize 上限为 100 */
+export const MATERIALS_TYPE_PAGE_SIZE_MAX = 100;
+
+/**
+ * 按每页最多 {@link MATERIALS_TYPE_PAGE_SIZE_MAX} 条拉取物资类型全量,避免单次 pageSize 超限。
+ */
+export async function fetchAllMaterialTypes(
+  listType: (params: Record<string, any>) => Promise<any>,
+  extra?: Record<string, any>,
+): Promise<any[]> {
+  const pageSize = MATERIALS_TYPE_PAGE_SIZE_MAX;
+  const all: any[] = [];
+  let pageNo = 1;
+  const maxPages = 500;
+  while (pageNo <= maxPages) {
+    const res = await listType({
+      ...buildPageParams(pageNo, pageSize),
+      ...extra,
+    });
+    const batch = pickRecords(res);
+    all.push(...batch);
+    if (batch.length < pageSize) break;
+    const total = pickTotal(res);
+    if (total > 0 && all.length >= total) break;
+    pageNo += 1;
+  }
+  return all;
+}
+
+/** Ant Design TreeSelect 的物资类型节点 */
+export type MaterialTypeTreeNode = {
+  title: string;
+  value: number;
+  key: number;
+  children?: MaterialTypeTreeNode[];
+};
+
+function materialTypeRowId(row: any): number | null {
+  /** 与 handleTree(flat, 'id', 'parentId') 一致:parentId 引用的是 id */
+  const n = Number(row?.id ?? row?.materialsTypeId);
+  return Number.isFinite(n) && n > 0 ? n : null;
+}
+
+function materialTypeParentId(row: any, idSet: Set<number>): number {
+  const raw =
+    row?.parentId ??
+    row?.parent_id ??
+    row?.materialsParentId ??
+    row?.parentMaterialsTypeId ??
+    row?.pid;
+  const n = Number(raw);
+  if (!Number.isFinite(n) || n <= 0) return 0;
+  return idSet.has(n) ? n : 0;
+}
+
+function materialTypeDisplayName(row: any): string {
+  return String(row?.materialsTypeName ?? row?.typeName ?? row?.name ?? '').trim();
+}
+
+/**
+ * 将分页拉平的物资类型列表转为 TreeSelect 用的父子树(与旧版 el-tree-select 一致)。
+ * 同 id 多行时保留首次出现的一条。
+ */
+export function buildMaterialTypeTreeData(flatRows: any[]): MaterialTypeTreeNode[] {
+  const seen = new Set<number>();
+  const rows: any[] = [];
+  for (const row of flatRows) {
+    const id = materialTypeRowId(row);
+    if (id == null || seen.has(id)) continue;
+    seen.add(id);
+    rows.push(row);
+  }
+
+  const idSet = new Set<number>();
+  for (const row of rows) {
+    const id = materialTypeRowId(row);
+    if (id != null) idSet.add(id);
+  }
+
+  const nodeById = new Map<number, MaterialTypeTreeNode>();
+  for (const row of rows) {
+    const id = materialTypeRowId(row);
+    if (id == null) continue;
+    const name = materialTypeDisplayName(row);
+    if (!name) continue;
+    if (!nodeById.has(id)) {
+      nodeById.set(id, { title: name, value: id, key: id });
+    }
+  }
+
+  const linkedAsChild = new Set<number>();
+  for (const row of rows) {
+    const id = materialTypeRowId(row);
+    if (id == null || !nodeById.has(id)) continue;
+    const pid = materialTypeParentId(row, idSet);
+    if (pid <= 0 || pid === id) continue;
+    const parent = nodeById.get(pid);
+    const child = nodeById.get(id);
+    if (!parent || !child) continue;
+    if (!parent.children) parent.children = [];
+    parent.children.push(child);
+    linkedAsChild.add(id);
+  }
+
+  const roots: MaterialTypeTreeNode[] = [];
+  for (const id of nodeById.keys()) {
+    if (!linkedAsChild.has(id)) {
+      const n = nodeById.get(id)!;
+      roots.push(n);
+    }
+  }
+
+  const pruneEmptyChildren = (nodes: MaterialTypeTreeNode[]) => {
+    for (const node of nodes) {
+      if (node.children?.length) {
+        pruneEmptyChildren(node.children);
+      } else {
+        delete node.children;
+      }
+    }
+  };
+  pruneEmptyChildren(roots);
+
+  return roots;
+}
+
+/** getMaterialsPropertyValuePage 等接口 pageSize 上限为 100 */
+export const MATERIALS_PROPERTY_VALUE_PAGE_SIZE_MAX = 100;
+
+/**
+ * 按每页最多 {@link MATERIALS_PROPERTY_VALUE_PAGE_SIZE_MAX} 条拉取某规格下的规格值全量。
+ */
+export async function fetchAllPropertyValues(
+  listPage: (params: Record<string, any>) => Promise<any>,
+  extra?: Record<string, any>,
+): Promise<any[]> {
+  const pageSize = MATERIALS_PROPERTY_VALUE_PAGE_SIZE_MAX;
+  const all: any[] = [];
+  let pageNo = 1;
+  const maxPages = 500;
+  while (pageNo <= maxPages) {
+    const res = await listPage({
+      ...buildPageParams(pageNo, pageSize),
+      ...extra,
+    });
+    const batch = pickRecords(res);
+    all.push(...batch);
+    if (batch.length < pageSize) break;
+    const total = pickTotal(res);
+    if (total > 0 && all.length >= total) break;
+    pageNo += 1;
+  }
+  return all;
+}
+
+/** getWorkstationPage(岗位/区域树)pageSize 上限为 100 */
+export const WORKSTATION_PAGE_SIZE_MAX = 100;
+
+/**
+ * 按每页最多 {@link WORKSTATION_PAGE_SIZE_MAX} 条拉取岗位/工作站全量,避免单次 pageSize 超限。
+ */
+export async function fetchAllWorkstations(
+  listPage: (params: Record<string, any>) => Promise<any>,
+  extra?: Record<string, any>,
+): Promise<any[]> {
+  const pageSize = WORKSTATION_PAGE_SIZE_MAX;
+  const all: any[] = [];
+  let pageNo = 1;
+  const maxPages = 500;
+  while (pageNo <= maxPages) {
+    const res = await listPage({
+      ...buildPageParams(pageNo, pageSize),
+      ...extra,
+    });
+    const batch = pickRecords(res);
+    all.push(...batch);
+    if (batch.length < pageSize) break;
+    const total = pickTotal(res);
+    if (total > 0 && all.length >= total) break;
+    pageNo += 1;
+  }
+  return all;
+}
+
+export function downloadBlob(blob: Blob, filename: string) {
+  const url = window.URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = filename;
+  a.click();
+  window.URL.revokeObjectURL(url);
+}

+ 29 - 0
src/components/material/materialPvNav.ts

@@ -0,0 +1,29 @@
+/** 规格设置列表页上下文(从「物资规格种类」进入时写入) */
+export const MATERIAL_PV_CONTEXT_KEY = 'MATERIAL_PV_CONTEXT';
+
+export type MaterialPvContext = { propertyId: number; propertyName: string };
+
+export function setMaterialPvContext(ctx: MaterialPvContext) {
+  sessionStorage.setItem(MATERIAL_PV_CONTEXT_KEY, JSON.stringify(ctx));
+}
+
+export function readMaterialPvContext(): MaterialPvContext | null {
+  try {
+    const raw = sessionStorage.getItem(MATERIAL_PV_CONTEXT_KEY);
+    if (!raw) return null;
+    const o = JSON.parse(raw) as MaterialPvContext;
+    const id = Number(o?.propertyId);
+    if (!Number.isFinite(id)) return null;
+    return { propertyId: id, propertyName: String(o?.propertyName ?? '') };
+  } catch {
+    return null;
+  }
+}
+
+/** 与 Dashboard 中 switchToMenu 事件约定一致 */
+export function switchMaterialSubMenu(menu: string, subMenu: string) {
+  window.dispatchEvent(new CustomEvent('switchToMenu', { detail: { menu, subMenu } }));
+}
+
+/** 检查记录「更换记录-查看」跳转更换记录列表时写入 sessionStorage 的 key */
+export const MATERIAL_REPLACEMENT_RECORD_PRESET_KEY = 'materialReplacementRecordPreset';

+ 43 - 0
src/components/material/materialStatisticsRangeUtils.ts

@@ -0,0 +1,43 @@
+export const STAT_BLUE = '#5470c6';
+export const STAT_GREEN = '#91cc75';
+export const STAT_ORANGE = '#fac858';
+
+export function getDefaultDateRange(): [Date, Date] {
+  const today = new Date();
+  const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
+  return [oneMonthAgo, today];
+}
+
+function toDate(v: Date | { toDate?: () => Date }): Date {
+  if (v && typeof (v as { toDate?: () => Date }).toDate === 'function') {
+    return (v as { toDate: () => Date }).toDate();
+  }
+  return v as Date;
+}
+
+export function formatDateTimeRangeFromRangePicker(
+  start: Date | { toDate?: () => Date },
+  end: Date | { toDate?: () => Date }
+): { startTime: string; endTime: string } {
+  const s = toDate(start);
+  const e = toDate(end);
+  const startDate = new Date(s);
+  startDate.setHours(0, 0, 0, 0);
+  const endDate = new Date(e);
+  endDate.setHours(23, 59, 59, 999);
+  return { startTime: formatDateTime(startDate), endTime: formatDateTime(endDate) };
+}
+
+export function formatDateTime(date: Date): string {
+  const y = date.getFullYear();
+  const m = String(date.getMonth() + 1).padStart(2, '0');
+  const d = String(date.getDate()).padStart(2, '0');
+  const h = String(date.getHours()).padStart(2, '0');
+  const min = String(date.getMinutes()).padStart(2, '0');
+  const sec = String(date.getSeconds()).padStart(2, '0');
+  return `${y}-${m}-${d} ${h}:${min}:${sec}`;
+}
+
+export function chartMinWidth(categories: number, perItem = 48) {
+  return Math.max(360, categories * perItem);
+}

+ 162 - 0
src/components/material/materialSubMenu.ts

@@ -0,0 +1,162 @@
+export type MaterialSubKey =
+  | 'materialLockers'
+  | 'materialInventory'
+  | 'materialType'
+  | 'materialStandard'
+  | 'materialStandardPropertyValues'
+  | 'materialInformation'
+  | 'materialLoan'
+  | 'materialInspectionPlan'
+  | 'materialInspectionRecord'
+  | 'materialReplacementRecord'
+  | 'materialBlacklist'
+  | 'materialInstructions'
+  | 'materialBaseDataStatistics'
+  | 'materialClaimReturnStatistics';
+
+const MATERIAL_SUB_KEYS: MaterialSubKey[] = [
+  'materialLockers',
+  'materialInventory',
+  'materialType',
+  'materialStandard',
+  'materialStandardPropertyValues',
+  'materialInformation',
+  'materialLoan',
+  'materialInspectionPlan',
+  'materialInspectionRecord',
+  'materialReplacementRecord',
+  'materialBlacklist',
+  'materialInstructions',
+  'materialBaseDataStatistics',
+  'materialClaimReturnStatistics',
+];
+
+/**
+ * 根据路由 path 解析物资子模块 key(与旧版 Vue 路由 lockers / inventory / type / standard /
+ * information / coll / inspectionplan / Inspectionrecords / replacementrecords / blacklist / instructions 对齐)
+ */
+export function mapMaterialRoutePathToSubKey(path: string | null | undefined): MaterialSubKey | null {
+  if (path == null) return null;
+  const raw = String(path).trim();
+  if (!raw) return null;
+  const u = raw.toLowerCase().replace(/\\/g, '/');
+  if (u === '/material' || u === 'material') return null;
+
+  if (u.includes('blacklist')) return 'materialBlacklist';
+  if (u.includes('instructions')) return 'materialInstructions';
+
+  // get-permission-info:/clientSystem/MaterialDataStatistics/statistics/basicData、…/collectionReturn
+  if (
+    u.includes('statistics/base') ||
+    u.includes('base-data') ||
+    u.includes('basicdata') ||
+    u.includes('basedata') ||
+    u.includes('lockerone') ||
+    u.includes('locker-one') ||
+    u.includes('statisticians/lockerone')
+  ) {
+    return 'materialBaseDataStatistics';
+  }
+  if (
+    u.includes('statistics/claim') ||
+    u.includes('claim-return') ||
+    u.includes('claimreturn') ||
+    u.includes('collectionreturn') ||
+    (u.includes('collection') && u.includes('return') && u.includes('statistics')) ||
+    u.includes('lockertwo') ||
+    u.includes('locker-two') ||
+    u.includes('statisticians/lockertwo')
+  ) {
+    return 'materialClaimReturnStatistics';
+  }
+
+  if (
+    u.includes('replacementrecords') ||
+    u.includes('replacement-records') ||
+    (u.includes('replacement') && (u.includes('record') || u.includes('records')))
+  ) {
+    return 'materialReplacementRecord';
+  }
+
+  if (
+    u.includes('inspectionrecords') ||
+    u.includes('inspection-records') ||
+    u.includes('inspectionrecord') ||
+    u.includes('checkrecord') ||
+    u.includes('check-record')
+  ) {
+    return 'materialInspectionRecord';
+  }
+
+  if (u.includes('inspectionplan') || u.includes('inspection-plan')) return 'materialInspectionPlan';
+
+  if (
+    u.includes('/coll') ||
+    u.includes('material/coll') ||
+    u === 'coll' ||
+    u.endsWith('/coll') ||
+    u.includes('/loan') ||
+    u.includes('materialloan') ||
+    u.includes('borrow')
+  ) {
+    return 'materialLoan';
+  }
+
+  if (u.includes('information')) return 'materialInformation';
+  /** 须在 generic `standard` 之前匹配 */
+  if (u.includes('property-values') || u.includes('propertyvalues')) return 'materialStandardPropertyValues';
+  if (u.includes('standard')) return 'materialStandard';
+  if (u.includes('/type') || /\/type(\/|$|\?)/.test(u)) return 'materialType';
+  if (u.includes('inventory')) return 'materialInventory';
+  if (u.includes('locker') || u.includes('cabinet')) return 'materialLockers';
+
+  return null;
+}
+
+/**
+ * @param subMenu Dashboard 当前子菜单 key 或后端生成的占位 key
+ * @param routePath 子菜单项上的 path(优先,用于纠正 key 与路由不一致)
+ */
+export function normalizeMaterialSubMenu(subMenu: string, routePath?: string | null): MaterialSubKey {
+  const s = String(subMenu || '').trim();
+  const lower = s.toLowerCase();
+
+  const fromPath = mapMaterialRoutePathToSubKey(routePath);
+  if (fromPath) return fromPath;
+
+  if ((MATERIAL_SUB_KEYS as readonly string[]).includes(s)) return s as MaterialSubKey;
+
+  const fromSub = mapMaterialRoutePathToSubKey(s);
+  if (fromSub) return fromSub;
+
+  if (s.includes('基础数据') && s.includes('统计')) return 'materialBaseDataStatistics';
+  if (s.includes('领取') && s.includes('归还') && s.includes('统计')) return 'materialClaimReturnStatistics';
+  if (s.includes('黑名单')) return 'materialBlacklist';
+  if (s.includes('使用说明')) return 'materialInstructions';
+  if (s.includes('物资柜')) return 'materialLockers';
+  if (s.includes('盘点')) return 'materialInventory';
+  if (s.includes('物资规格种类') || s.includes('规格种类')) return 'materialStandard';
+  if (s.includes('规格设置')) return 'materialStandardPropertyValues';
+  if (s.includes('物资类型')) return 'materialType';
+  if (s.includes('规格')) return 'materialStandard';
+  if (s.includes('类型')) return 'materialType';
+  if (s.includes('物资清单') || s.includes('清单')) return 'materialInformation';
+  if (s.includes('领取') || s.includes('归还') || s.includes('借还')) return 'materialLoan';
+  if (s.includes('检查记录')) return 'materialInspectionRecord';
+  if (s.includes('检查计划')) return 'materialInspectionPlan';
+  if (s.includes('更换记录')) return 'materialReplacementRecord';
+
+  if (lower.includes('blacklist')) return 'materialBlacklist';
+  if (lower.includes('instruction')) return 'materialInstructions';
+  if (lower.includes('locker') || lower.includes('cabinet')) return 'materialLockers';
+  if (lower.includes('inventory')) return 'materialInventory';
+  if (lower.includes('information') || lower.includes('materials')) return 'materialInformation';
+  if (lower.includes('standard')) return 'materialStandard';
+  if (lower.includes('/type') || lower.includes('materialtype')) return 'materialType';
+  if (lower.includes('loan') || lower.includes('coll')) return 'materialLoan';
+  if (lower.includes('inspectionrecord') || lower.includes('checkrecord')) return 'materialInspectionRecord';
+  if (lower.includes('inspectionplan') || lower.includes('inspection-plan')) return 'materialInspectionPlan';
+  if (lower.includes('replacement') || lower.includes('change')) return 'materialReplacementRecord';
+
+  return 'materialLockers';
+}

+ 339 - 0
src/components/material/pages/MaterialBaseDataStatisticsPage.tsx

@@ -0,0 +1,339 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { DatePicker, Button, Modal } from 'antd';
+import { Download } from 'lucide-react';
+import dayjs, { type Dayjs } from 'dayjs';
+import {
+  Bar,
+  BarChart,
+  CartesianGrid,
+  Legend,
+  ResponsiveContainer,
+  Tooltip,
+  XAxis,
+  YAxis,
+} from 'recharts';
+import { toast } from 'sonner';
+import {
+  materialStatisticsApi,
+  toStatArray,
+  type CabinetStatRow,
+  type MaterialsTypeCountRow,
+} from '../../../api/material/statistics';
+import { downloadBlob } from '../materialListUtils';
+import { MaterialPageRoot } from '../MaterialPageLayout';
+import {
+  chartMinWidth,
+  formatDateTimeRangeFromRangePicker,
+  getDefaultDateRange,
+  STAT_BLUE,
+  STAT_GREEN,
+  STAT_ORANGE,
+} from '../materialStatisticsRangeUtils';
+
+function ChartFrame({ title, children }: { title: string; children: React.ReactNode }) {
+  return (
+    <div className="overflow-hidden rounded-lg border border-gray-200 bg-white p-2 shadow-sm">
+      <h3 className="mb-1 text-center text-sm font-medium text-gray-800">{title}</h3>
+      {/* Recharts ResponsiveContainer 需要父级有确定像素高度,避免 flex h-full 链得到 0 高度导致整块空白 */}
+      {children}
+    </div>
+  );
+}
+
+function ScrollBarHost({ categories: n, children }: { categories: number; children: React.ReactNode }) {
+  return (
+    <div className="w-full overflow-x-auto">
+      <div className="h-[380px] w-full" style={{ minWidth: chartMinWidth(n) }}>
+        {children}
+      </div>
+    </div>
+  );
+}
+
+export default function MaterialBaseDataStatisticsPage() {
+  const [[startD, endD], setRange] = useState<[Dayjs, Dayjs]>(() => {
+    const [a, b] = getDefaultDateRange();
+    return [dayjs(a), dayjs(b)];
+  });
+  const [q, setQ] = useState(() => formatDateTimeRangeFromRangePicker(startD, endD));
+  const [ex, setEx] = useState(false);
+
+  const syncQ = useCallback((a: Dayjs, b: Dayjs) => {
+    setQ(formatDateTimeRangeFromRangePicker(a, b));
+  }, []);
+
+  useEffect(() => {
+    syncQ(startD, endD);
+  }, [startD, endD, syncQ]);
+
+  const onExport = () => {
+    Modal.confirm({
+      title: '确认导出',
+      content: '是否导出当前统计区间下的基础数据表?',
+      onOk: async () => {
+        setEx(true);
+        try {
+          const blob = await materialStatisticsApi.exportBaseData({ startTime: q.startTime, endTime: q.endTime });
+          downloadBlob(blob as Blob, '基础数据.xls');
+          toast.success('已开始下载');
+        } catch (e: any) {
+          toast.error(e?.message || '导出失败');
+        } finally {
+          setEx(false);
+        }
+      },
+    });
+  };
+
+  return (
+    <MaterialPageRoot>
+      <div className="mb-4 flex flex-wrap items-center gap-3">
+        <span className="text-sm text-gray-700">统计区间:</span>
+        <DatePicker.RangePicker
+          value={[startD, endD]}
+          showTime
+          onChange={(vals) => {
+            if (vals?.[0] && vals[1]) {
+              setRange([vals[0], vals[1]]);
+            }
+          }}
+        />
+        <Button type="default" icon={<Download className="h-4 w-4" />} loading={ex} onClick={onExport} className="border-amber-500 text-amber-600">
+          导出数据表
+        </Button>
+      </div>
+      <div className="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:gap-8">
+        <OpenCountPanel q={q} />
+        <AnomalyPanel q={q} />
+        <SpecialityPanel q={q} />
+        <ChangePanel q={q} />
+      </div>
+    </MaterialPageRoot>
+  );
+}
+
+function OpenCountPanel({ q }: { q: { startTime: string; endTime: string } }) {
+  const params = { startTime: q.startTime, endTime: q.endTime };
+  const [rows, setRows] = useState<CabinetStatRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    let ok = true;
+    setLoading(true);
+    materialStatisticsApi
+      .getCabinetStatistics(params)
+      .then((res) => {
+        if (ok) setRows(toStatArray(res as CabinetStatRow[]));
+      })
+      .catch((e) => {
+        console.error(e);
+        if (ok) {
+          setRows([]);
+          toast.error('加载物资柜开关次数失败');
+        }
+      })
+      .finally(() => {
+        if (ok) setLoading(false);
+      });
+    return () => {
+      ok = false;
+    };
+  }, [params.startTime, params.endTime]);
+  const data = rows.map((r) => ({
+    name: r.cabinetName || '-',
+    开关次数: r.openCount ?? 0,
+  }));
+  return (
+    <ChartFrame title="物资柜开关次数">
+      {loading ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">加载中…</div>
+      ) : data.length === 0 ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">暂无数据</div>
+      ) : (
+        <ScrollBarHost categories={data.length}>
+          <ResponsiveContainer width="100%" height={380}>
+            <BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
+              <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200" />
+              <XAxis dataKey="name" tick={{ fontSize: 11 }} />
+              <YAxis tick={{ fontSize: 11 }} name="次数" label={{ value: '次数', angle: -90, position: 'insideLeft' }} />
+              <Tooltip />
+              <Legend />
+              <Bar dataKey="开关次数" fill={STAT_BLUE} maxBarSize={36} name="开关次数" />
+            </BarChart>
+          </ResponsiveContainer>
+        </ScrollBarHost>
+      )}
+    </ChartFrame>
+  );
+}
+
+function AnomalyPanel({ q }: { q: { startTime: string; endTime: string } }) {
+  const params = { startTime: q.startTime, endTime: q.endTime };
+  const [rows, setRows] = useState<CabinetStatRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    let ok = true;
+    setLoading(true);
+    materialStatisticsApi
+      .getCabinetStatistics(params)
+      .then((res) => {
+        if (ok) setRows(toStatArray(res as CabinetStatRow[]));
+      })
+      .catch((e) => {
+        console.error(e);
+        if (ok) {
+          setRows([]);
+          toast.error('加载物资柜异常统计失败');
+        }
+      })
+      .finally(() => {
+        if (ok) setLoading(false);
+      });
+    return () => {
+      ok = false;
+    };
+  }, [params.startTime, params.endTime]);
+  const data = rows.map((r) => ({
+    name: r.cabinetName || '-',
+    物资错放: r.misplaceCount ?? 0,
+    超时未关: r.openTimeoutCount ?? 0,
+  }));
+  return (
+    <ChartFrame title="物资柜异常统计">
+      {loading ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">加载中…</div>
+      ) : data.length === 0 ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">暂无数据</div>
+      ) : (
+        <ScrollBarHost categories={data.length}>
+          <ResponsiveContainer width="100%" height={380}>
+            <BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
+              <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200" />
+              <XAxis dataKey="name" tick={{ fontSize: 11 }} />
+              <YAxis tick={{ fontSize: 11 }} name="次数" label={{ value: '次数', angle: -90, position: 'insideLeft' }} />
+              <Tooltip />
+              <Legend />
+              <Bar dataKey="物资错放" stackId="a" fill={STAT_BLUE} name="物资错放" maxBarSize={40} />
+              <Bar dataKey="超时未关" stackId="a" fill={STAT_GREEN} name="超时未关" maxBarSize={40} />
+            </BarChart>
+          </ResponsiveContainer>
+        </ScrollBarHost>
+      )}
+    </ChartFrame>
+  );
+}
+
+function SpecialityPanel({ q }: { q: { startTime: string; endTime: string } }) {
+  const params = { startTime: q.startTime, endTime: q.endTime };
+  const [rows, setRows] = useState<MaterialsTypeCountRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    let ok = true;
+    setLoading(true);
+    materialStatisticsApi
+      .getMaterialsStatusStatistics(params)
+      .then((res) => {
+        if (ok) setRows(toStatArray(res as MaterialsTypeCountRow[]));
+      })
+      .catch((e) => {
+        console.error(e);
+        if (ok) {
+          setRows([]);
+          toast.error('加载特殊状态物资统计失败');
+        }
+      })
+      .finally(() => {
+        if (ok) setLoading(false);
+      });
+    return () => {
+      ok = false;
+    };
+  }, [params.startTime, params.endTime]);
+  const data = rows.map((r) => ({
+    name: r.materialsTypeName || '-',
+    即将过期: r.willExpireCount ?? 0,
+    已过期: r.expiredCount ?? 0,
+    损坏数: r.badCount ?? 0,
+  }));
+  return (
+    <ChartFrame title="特殊状态物资统计(当前时刻)">
+      {loading ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">加载中…</div>
+      ) : data.length === 0 ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">暂无数据</div>
+      ) : (
+        <ScrollBarHost categories={data.length}>
+          <ResponsiveContainer width="100%" height={380}>
+            <BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
+              <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200" />
+              <XAxis dataKey="name" tick={{ fontSize: 11 }} />
+              <YAxis tick={{ fontSize: 11 }} label={{ value: '个', angle: -90, position: 'insideLeft' }} />
+              <Tooltip />
+              <Legend />
+              <Bar dataKey="即将过期" stackId="a" fill={STAT_BLUE} name="即将过期" maxBarSize={32} />
+              <Bar dataKey="已过期" stackId="a" fill={STAT_GREEN} name="已过期" maxBarSize={32} />
+              <Bar dataKey="损坏数" stackId="a" fill={STAT_ORANGE} name="损坏数" maxBarSize={32} />
+            </BarChart>
+          </ResponsiveContainer>
+        </ScrollBarHost>
+      )}
+    </ChartFrame>
+  );
+}
+
+function ChangePanel({ q }: { q: { startTime: string; endTime: string } }) {
+  const params = { startTime: q.startTime, endTime: q.endTime };
+  const [rows, setRows] = useState<MaterialsTypeCountRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    let ok = true;
+    setLoading(true);
+    materialStatisticsApi
+      .getMaterialsChangeStatistics(params)
+      .then((res) => {
+        if (ok) setRows(toStatArray(res as MaterialsTypeCountRow[]));
+      })
+      .catch((e) => {
+        console.error(e);
+        if (ok) {
+          setRows([]);
+          toast.error('加载物资更换统计失败');
+        }
+      })
+      .finally(() => {
+        if (ok) setLoading(false);
+      });
+    return () => {
+      ok = false;
+    };
+  }, [params.startTime, params.endTime]);
+  const data = rows.map((r) => ({
+    name: r.materialsTypeName || '-',
+    正常更换次数: r.allCount ?? 0,
+    过期更换次数: r.expireCount ?? 0,
+    损坏更换次数: r.badCount ?? 0,
+  }));
+  return (
+    <ChartFrame title="物资更换统计">
+      {loading ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">加载中…</div>
+      ) : data.length === 0 ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">暂无数据</div>
+      ) : (
+        <ScrollBarHost categories={data.length}>
+          <ResponsiveContainer width="100%" height={380}>
+            <BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
+              <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200" />
+              <XAxis dataKey="name" tick={{ fontSize: 11 }} />
+              <YAxis tick={{ fontSize: 11 }} label={{ value: '次数', angle: -90, position: 'insideLeft' }} />
+              <Tooltip />
+              <Legend />
+              <Bar dataKey="正常更换次数" stackId="a" fill={STAT_BLUE} name="正常更换次数" maxBarSize={32} />
+              <Bar dataKey="过期更换次数" stackId="a" fill={STAT_GREEN} name="过期更换次数" maxBarSize={32} />
+              <Bar dataKey="损坏更换次数" stackId="a" fill={STAT_ORANGE} name="损坏更换次数" maxBarSize={32} />
+            </BarChart>
+          </ResponsiveContainer>
+        </ScrollBarHost>
+      )}
+    </ChartFrame>
+  );
+}

+ 356 - 0
src/components/material/pages/MaterialBlacklistPage.tsx

@@ -0,0 +1,356 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import dayjs from 'dayjs';
+import { Button, Table, Modal, Input, Transfer, Spin } from 'antd';
+
+import type { TransferProps } from 'antd/es/transfer';
+
+import type { ColumnsType } from 'antd/es/table';
+
+import { Plus, Trash2 } from 'lucide-react';
+
+import { toast } from 'sonner';
+
+import { useTranslation } from 'react-i18next';
+
+import { materialBlacklistApi } from '../../../api/material/blacklist';
+
+import { pickRecords, pickTotal, buildPageParams } from '../materialListUtils';
+
+import {
+  MaterialPageRoot,
+  MaterialToolbarCard,
+  MaterialTableCard,
+  MaterialPaginationBar,
+  MaterialDeleteButton,
+  materialConfirmDelete,
+  getMaterialFormModalProps,
+} from '../MaterialPageLayout';
+
+type TransferRecord = NonNullable<TransferProps['dataSource']>[number];
+
+function firstDefinedRow(row: Record<string, any>, keys: string[]) {
+  for (const k of keys) {
+    const v = row[k];
+    if (v !== undefined && v !== null && v !== '') return v;
+  }
+  return undefined;
+}
+
+function formatDateTimeCell(v: unknown): string {
+  if (v === undefined || v === null || v === '') return '—';
+  const n = typeof v === 'number' ? v : /^\d+$/.test(String(v).trim()) ? Number(String(v).trim()) : NaN;
+  const d = Number.isFinite(n) && n > 1e12 ? dayjs(n) : dayjs(v as string | number | Date);
+  return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : '—';
+}
+
+/** 与旧版 BlacklistForm.vue 一致:module 固定为物资柜等业务维度 */
+const BLACKLIST_MODULE = '2';
+
+export default function MaterialBlacklistPage() {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize] = useState(10);
+  const [draftUserName, setDraftUserName] = useState('');
+  const [draftNickName, setDraftNickName] = useState('');
+  const [appliedUserName, setAppliedUserName] = useState('');
+  const [appliedNickName, setAppliedNickName] = useState('');
+  const [modalOpen, setModalOpen] = useState(false);
+  const [submitLoading, setSubmitLoading] = useState(false);
+  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
+
+  const [transferLoading, setTransferLoading] = useState(false);
+  const [transferDataSource, setTransferDataSource] = useState<TransferRecord[]>([]);
+  const [targetKeys, setTargetKeys] = useState<string[]>([]);
+  /** key 为穿梭框 key(与旧版一致为 userId 字符串),值为提交用 userId */
+  const [userIdByKey, setUserIdByKey] = useState<Map<string, string | number>>(new Map());
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await materialBlacklistApi.listBlacklist({
+        ...buildPageParams(pageNo, pageSize),
+        userName: appliedUserName || undefined,
+        nickName: appliedNickName || undefined,
+      });
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  }, [pageNo, pageSize, appliedUserName, appliedNickName, t]);
+
+  useEffect(() => {
+    fetchList();
+  }, [fetchList]);
+
+  /**
+   * 与旧版 BlacklistForm getLeftList 一致:白名单分页接口拉可选用户,
+   * 不再用「全量用户 − 黑名单」自行过滤,避免 userId 形态与后端不一致。
+   */
+  const loadTransferUsers = useCallback(async () => {
+    setTransferLoading(true);
+    try {
+      const res = await materialBlacklistApi.listWhitelist({
+        ...buildPageParams(1, -1),
+        nickName: undefined,
+      });
+      const rows = pickRecords(res);
+      const map = new Map<string, string | number>();
+      const items: TransferRecord[] = [];
+      for (const item of rows as any[]) {
+        const userId = firstDefinedRow(item, ['userId', 'user_id', 'id']);
+        if (userId === undefined || userId === null || userId === '') continue;
+        const sid = String(userId);
+        map.set(sid, userId as string | number);
+        items.push({
+          key: sid,
+          title: `${item.nickName ?? item.nickname ?? '—'} (${item.userName ?? item.username ?? sid})`,
+        });
+      }
+      setUserIdByKey(map);
+      setTransferDataSource(items);
+    } catch (e: any) {
+      toast.error(e?.message || '加载用户列表失败');
+      setTransferDataSource([]);
+      setUserIdByKey(new Map());
+    } finally {
+      setTransferLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    if (!modalOpen) return;
+    setTargetKeys([]);
+    void loadTransferUsers();
+  }, [modalOpen, loadTransferUsers]);
+
+  const handleQuery = () => {
+    setAppliedUserName(draftUserName);
+    setAppliedNickName(draftNickName);
+    setPageNo(1);
+    setSelectedRowKeys([]);
+  };
+
+  const resetQuery = () => {
+    setDraftUserName('');
+    setDraftNickName('');
+    setAppliedUserName('');
+    setAppliedNickName('');
+    setPageNo(1);
+    setSelectedRowKeys([]);
+  };
+
+  /** 与旧版 submitForm 一致:一次 POST 数组 [{ userId, module: '2' }, ...] */
+  const submitAdd = async () => {
+    if (targetKeys.length === 0) {
+      toast.error('请至少选择一名用户加入黑名单');
+      return;
+    }
+    setSubmitLoading(true);
+    try {
+      const payload = targetKeys.map((key) => ({
+        userId: userIdByKey.get(key) ?? key,
+        module: BLACKLIST_MODULE,
+      }));
+      await materialBlacklistApi.addBlacklist(payload);
+      toast.success(t('common.addSuccess'));
+      setModalOpen(false);
+      setTargetKeys([]);
+      fetchList();
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setSubmitLoading(false);
+    }
+  };
+
+  const onDelete = (row: any) => {
+    const id = row.recordId ?? row.id;
+    materialConfirmDelete(t, {
+      count: 1,
+      onOk: async () => {
+        await materialBlacklistApi.delBlacklist(id);
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys((prev) => prev.filter((k) => String(k) !== String(id)));
+        fetchList();
+      },
+    });
+  };
+
+  const batchDelete = () => {
+    if (selectedRowKeys.length === 0) {
+      toast.error(t('common.pleaseSelectDataToDelete'));
+      return;
+    }
+    const ids = selectedRowKeys
+      .map((k) => Number(k))
+      .filter((n) => Number.isFinite(n) && !Number.isNaN(n));
+    if (ids.length === 0) {
+      toast.error(t('common.pleaseSelectDataToDelete'));
+      return;
+    }
+    materialConfirmDelete(t, {
+      count: ids.length,
+      onOk: async () => {
+        await materialBlacklistApi.delBlacklist(ids.join(','));
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys([]);
+        fetchList();
+      },
+    });
+  };
+
+  /** Ant Design Transfer:operations[0]=左→右(添加),operations[1]=右→左(移除) */
+  const transferFilter: TransferProps['filterOption'] = (inputValue, item) => {
+    const title = (item.title ?? '').toLowerCase();
+    const q = inputValue.trim().toLowerCase();
+    return !q || title.includes(q);
+  };
+
+  const columns: ColumnsType<any> = [
+    { title: '用户编号', dataIndex: 'userId', align: 'center' },
+    { title: '工号', dataIndex: 'userName', align: 'center' },
+    { title: '姓名', dataIndex: 'nickName', align: 'center' },
+    {
+      title: '创建时间',
+      key: 'createTime',
+      width: 170,
+      align: 'center',
+      render: (_, row) =>
+        formatDateTimeCell(
+          firstDefinedRow(row, ['createTime', 'create_time', 'gmtCreate', 'gmt_create', 'createDate']),
+        ),
+    },
+    {
+      title: t('table.operation'),
+      width: 120,
+      align: 'center',
+      fixed: 'right',
+      render: (_, row) => (
+        <div className="flex justify-center">
+          <MaterialDeleteButton label={t('common.delete')} onClick={() => onDelete(row)} />
+        </div>
+      ),
+    },
+  ];
+
+  return (
+    <MaterialPageRoot>
+      <MaterialToolbarCard
+        filterRow={
+          <>
+            <div className="flex items-center gap-2">
+              <label className="text-sm font-medium text-gray-700 whitespace-nowrap">工号:</label>
+              <Input
+                allowClear
+                placeholder="请输入"
+                className="w-[140px]"
+                value={draftUserName}
+                onChange={(e) => setDraftUserName(e.target.value)}
+              />
+            </div>
+            <div className="flex items-center gap-2">
+              <label className="text-sm font-medium text-gray-700 whitespace-nowrap">姓名:</label>
+              <Input
+                allowClear
+                placeholder="请输入"
+                className="w-[140px]"
+                value={draftNickName}
+                onChange={(e) => setDraftNickName(e.target.value)}
+              />
+            </div>
+          </>
+        }
+        onSearch={handleQuery}
+        onReset={resetQuery}
+        toolbarRight={
+          <>
+            <Button
+              type="primary"
+              icon={<Plus />}
+              onClick={() => {
+                setTargetKeys([]);
+                setModalOpen(true);
+              }}
+            >
+              {t('common.addNew')}
+            </Button>
+            <Button
+              danger
+              icon={<Trash2 className="h-4 w-4" />}
+              disabled={selectedRowKeys.length === 0}
+              onClick={batchDelete}
+            >
+              {t('common.batchDelete')}
+            </Button>
+          </>
+        }
+      />
+      <MaterialTableCard>
+        <Table
+          rowKey={(r) => r.recordId ?? r.id}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          scroll={{ x: 'max-content' }}
+          rowSelection={{
+            selectedRowKeys,
+            onChange: setSelectedRowKeys,
+            preserveSelectedRowKeys: true,
+            getCheckboxProps: (record) => ({ name: record.userName }),
+          }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        loading={loading}
+        listLength={list.length}
+      />
+
+      <Modal
+        title="新增"
+        open={modalOpen}
+        onOk={submitAdd}
+        onCancel={() => {
+          setModalOpen(false);
+          setTargetKeys([]);
+        }}
+        confirmLoading={submitLoading}
+        {...getMaterialFormModalProps(t, 880)}
+      >
+        {transferLoading ? (
+          <div className="flex justify-center py-16">
+            <Spin />
+          </div>
+        ) : (
+          <Transfer
+            dataSource={transferDataSource}
+            titles={['可选用户', '已选用户']}
+            targetKeys={targetKeys}
+            onChange={(next) => setTargetKeys(next as string[])}
+            render={(item) => item.title ?? item.key}
+            showSearch
+            filterOption={transferFilter}
+            listStyle={{ width: 360, height: 400 }}
+            operations={['添加', '移除']}
+            locale={{
+              itemUnit: '项',
+              itemsUnit: '项',
+              searchPlaceholder: '请输入姓名或登录名',
+            }}
+          />
+        )}
+      </Modal>
+    </MaterialPageRoot>
+  );
+}

+ 346 - 0
src/components/material/pages/MaterialClaimReturnStatisticsPage.tsx

@@ -0,0 +1,346 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { DatePicker, Button, Modal } from 'antd';
+import { Download } from 'lucide-react';
+import dayjs, { type Dayjs } from 'dayjs';
+import {
+  Bar,
+  BarChart,
+  CartesianGrid,
+  Legend,
+  ResponsiveContainer,
+  Tooltip,
+  XAxis,
+  YAxis,
+} from 'recharts';
+import { toast } from 'sonner';
+import {
+  materialStatisticsApi,
+  toStatArray,
+  type DayLoanStatRow,
+  type MaterialsLoanStatRow,
+} from '../../../api/material/statistics';
+import { downloadBlob } from '../materialListUtils';
+import { MaterialPageRoot } from '../MaterialPageLayout';
+import {
+  chartMinWidth,
+  formatDateTimeRangeFromRangePicker,
+  getDefaultDateRange,
+  STAT_BLUE,
+  STAT_GREEN,
+  STAT_ORANGE,
+} from '../materialStatisticsRangeUtils';
+
+function dayLabel(d: DayLoanStatRow) {
+  return d.day || d.date || '-';
+}
+
+function ChartFrame({ title, children }: { title: string; children: React.ReactNode }) {
+  return (
+    <div className="overflow-hidden rounded-lg border border-gray-200 bg-white p-2 shadow-sm">
+      <h3 className="mb-1 text-center text-sm font-medium text-gray-800">{title}</h3>
+      {children}
+    </div>
+  );
+}
+
+function ScrollBarHost({ categories: n, children }: { categories: number; children: React.ReactNode }) {
+  return (
+    <div className="w-full overflow-x-auto">
+      <div className="h-[380px] w-full" style={{ minWidth: chartMinWidth(n) }}>
+        {children}
+      </div>
+    </div>
+  );
+}
+
+export default function MaterialClaimReturnStatisticsPage() {
+  const [[startD, endD], setRange] = useState<[Dayjs, Dayjs]>(() => {
+    const [a, b] = getDefaultDateRange();
+    return [dayjs(a), dayjs(b)];
+  });
+  const [q, setQ] = useState(() => formatDateTimeRangeFromRangePicker(startD, endD));
+  const [ex, setEx] = useState(false);
+
+  const syncQ = useCallback((a: Dayjs, b: Dayjs) => {
+    setQ(formatDateTimeRangeFromRangePicker(a, b));
+  }, []);
+
+  useEffect(() => {
+    syncQ(startD, endD);
+  }, [startD, endD, syncQ]);
+
+  const onExport = () => {
+    Modal.confirm({
+      title: '确认导出',
+      content: '是否导出当前统计区间下的领取归还数据表?',
+      onOk: async () => {
+        setEx(true);
+        try {
+          const blob = await materialStatisticsApi.exportClaimAndReturn({
+            startTime: q.startTime,
+            endTime: q.endTime,
+          });
+          downloadBlob(blob as Blob, '领取归还统计.xls');
+          toast.success('已开始下载');
+        } catch (e: any) {
+          toast.error(e?.message || '导出失败');
+        } finally {
+          setEx(false);
+        }
+      },
+    });
+  };
+
+  return (
+    <MaterialPageRoot>
+      <div className="mb-4 flex flex-wrap items-center gap-3">
+        <span className="text-sm text-gray-700">统计区间:</span>
+        <DatePicker.RangePicker
+          value={[startD, endD]}
+          showTime
+          onChange={(vals) => {
+            if (vals?.[0] && vals[1]) {
+              setRange([vals[0], vals[1]]);
+            }
+          }}
+        />
+        <Button type="default" icon={<Download className="h-4 w-4" />} loading={ex} onClick={onExport} className="border-amber-500 text-amber-600">
+          导出数据表
+        </Button>
+      </div>
+      <div className="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:gap-8">
+        <DailyPanel q={q} />
+        <LendingPanel q={q} />
+        <CollectionPanel q={q} />
+        <ReturnPanel q={q} />
+      </div>
+    </MaterialPageRoot>
+  );
+}
+
+function DailyPanel({ q }: { q: { startTime: string; endTime: string } }) {
+  const params = { startTime: q.startTime, endTime: q.endTime };
+  const [rows, setRows] = useState<DayLoanStatRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    let ok = true;
+    setLoading(true);
+    materialStatisticsApi
+      .getDayLoanStatistics(params)
+      .then((res) => {
+        if (!ok) return;
+        const list = toStatArray(res as DayLoanStatRow[]);
+        const sorted = [...list].sort((a, b) => {
+          const da = new Date((a.day || a.date || '') as string).getTime();
+          const db = new Date((b.day || b.date || '') as string).getTime();
+          return da - db;
+        });
+        setRows(sorted);
+      })
+      .catch((e) => {
+        console.error(e);
+        if (ok) {
+          setRows([]);
+          toast.error('加载每日领取归还统计失败');
+        }
+      })
+      .finally(() => {
+        if (ok) setLoading(false);
+      });
+    return () => {
+      ok = false;
+    };
+  }, [params.startTime, params.endTime]);
+  const data = rows.map((r) => ({
+    name: dayLabel(r),
+    累计领取次数: r.allCount ?? 0,
+    累计正常归还次数: r.returnCount ?? 0,
+    累计超时归还次数: r.timeoutCount ?? 0,
+  }));
+  return (
+    <ChartFrame title="每日领取归还统计">
+      {loading ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">加载中…</div>
+      ) : data.length === 0 ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">暂无数据</div>
+      ) : (
+        <ScrollBarHost categories={data.length}>
+          <ResponsiveContainer width="100%" height={380}>
+            <BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
+              <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200" />
+              <XAxis dataKey="name" tick={{ fontSize: 11 }} />
+              <YAxis tick={{ fontSize: 11 }} name="次数" label={{ value: '次数', angle: -90, position: 'insideLeft' }} />
+              <Tooltip />
+              <Legend />
+              <Bar dataKey="累计领取次数" fill={STAT_BLUE} name="累计领取次数" maxBarSize={28} />
+              <Bar dataKey="累计正常归还次数" fill={STAT_GREEN} name="累计正常归还次数" maxBarSize={28} />
+              <Bar dataKey="累计超时归还次数" fill={STAT_ORANGE} name="累计超时归还次数" maxBarSize={28} />
+            </BarChart>
+          </ResponsiveContainer>
+        </ScrollBarHost>
+      )}
+    </ChartFrame>
+  );
+}
+
+function LendingPanel({ q }: { q: { startTime: string; endTime: string } }) {
+  const params = { startTime: q.startTime, endTime: q.endTime };
+  const [rows, setRows] = useState<MaterialsLoanStatRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    let ok = true;
+    setLoading(true);
+    materialStatisticsApi
+      .getMaterialsLoanStatistics(params)
+      .then((res) => {
+        if (ok) setRows(toStatArray(res as MaterialsLoanStatRow[]));
+      })
+      .catch((e) => {
+        console.error(e);
+        if (ok) {
+          setRows([]);
+          toast.error('加载借出平均时长失败');
+        }
+      })
+      .finally(() => {
+        if (ok) setLoading(false);
+      });
+    return () => {
+      ok = false;
+    };
+  }, [params.startTime, params.endTime]);
+  const data = rows.map((r) => {
+    const sec = r.averageTime ?? 0;
+    const hours = parseFloat((sec / 3600).toFixed(2));
+    return { name: r.materialsTypeName || '-', 平均借出时长: hours };
+  });
+  return (
+    <ChartFrame title="物资借出平均时长">
+      {loading ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">加载中…</div>
+      ) : data.length === 0 ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">暂无数据</div>
+      ) : (
+        <ScrollBarHost categories={data.length}>
+          <ResponsiveContainer width="100%" height={380}>
+            <BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
+              <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200" />
+              <XAxis dataKey="name" tick={{ fontSize: 11 }} />
+              <YAxis tick={{ fontSize: 11 }} label={{ value: '小时', angle: -90, position: 'insideLeft' }} />
+              <Tooltip />
+              <Legend />
+              <Bar dataKey="平均借出时长" fill={STAT_BLUE} name="平均借出时长(小时)" maxBarSize={32} />
+            </BarChart>
+          </ResponsiveContainer>
+        </ScrollBarHost>
+      )}
+    </ChartFrame>
+  );
+}
+
+function CollectionPanel({ q }: { q: { startTime: string; endTime: string } }) {
+  const params = { startTime: q.startTime, endTime: q.endTime };
+  const [rows, setRows] = useState<MaterialsLoanStatRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    let ok = true;
+    setLoading(true);
+    materialStatisticsApi
+      .getMaterialsLoanStatistics(params)
+      .then((res) => {
+        if (ok) setRows(toStatArray(res as MaterialsLoanStatRow[]));
+      })
+      .catch((e) => {
+        console.error(e);
+        if (ok) {
+          setRows([]);
+          toast.error('加载物资领取统计失败');
+        }
+      })
+      .finally(() => {
+        if (ok) setLoading(false);
+      });
+    return () => {
+      ok = false;
+    };
+  }, [params.startTime, params.endTime]);
+  const data = rows.map((r) => ({ name: r.materialsTypeName || '-', 领取次数: r.allCount ?? 0 }));
+  return (
+    <ChartFrame title="物资领取统计">
+      {loading ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">加载中…</div>
+      ) : data.length === 0 ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">暂无数据</div>
+      ) : (
+        <ScrollBarHost categories={data.length}>
+          <ResponsiveContainer width="100%" height={380}>
+            <BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
+              <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200" />
+              <XAxis dataKey="name" tick={{ fontSize: 11 }} />
+              <YAxis tick={{ fontSize: 11 }} name="次数" label={{ value: '次数', angle: -90, position: 'insideLeft' }} />
+              <Tooltip />
+              <Legend />
+              <Bar dataKey="领取次数" fill={STAT_BLUE} name="领取次数" maxBarSize={36} />
+            </BarChart>
+          </ResponsiveContainer>
+        </ScrollBarHost>
+      )}
+    </ChartFrame>
+  );
+}
+
+function ReturnPanel({ q }: { q: { startTime: string; endTime: string } }) {
+  const params = { startTime: q.startTime, endTime: q.endTime };
+  const [rows, setRows] = useState<MaterialsLoanStatRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    let ok = true;
+    setLoading(true);
+    materialStatisticsApi
+      .getMaterialsLoanStatistics(params)
+      .then((res) => {
+        if (ok) setRows(toStatArray(res as MaterialsLoanStatRow[]));
+      })
+      .catch((e) => {
+        console.error(e);
+        if (ok) {
+          setRows([]);
+          toast.error('加载物资归还统计失败');
+        }
+      })
+      .finally(() => {
+        if (ok) setLoading(false);
+      });
+    return () => {
+      ok = false;
+    };
+  }, [params.startTime, params.endTime]);
+  const data = rows.map((r) => ({
+    name: r.materialsTypeName || '-',
+    正常归还次数: r.returnCount ?? 0,
+    超时归还次数: r.timeoutCount ?? 0,
+  }));
+  return (
+    <ChartFrame title="物资归还统计">
+      {loading ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">加载中…</div>
+      ) : data.length === 0 ? (
+        <div className="flex min-h-[380px] items-center justify-center text-sm text-gray-500">暂无数据</div>
+      ) : (
+        <ScrollBarHost categories={data.length}>
+          <ResponsiveContainer width="100%" height={380}>
+            <BarChart data={data} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
+              <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200" />
+              <XAxis dataKey="name" tick={{ fontSize: 11 }} />
+              <YAxis tick={{ fontSize: 11 }} label={{ value: '次数', angle: -90, position: 'insideLeft' }} />
+              <Tooltip />
+              <Legend />
+              <Bar dataKey="正常归还次数" stackId="a" fill={STAT_BLUE} name="正常归还次数" maxBarSize={36} />
+              <Bar dataKey="超时归还次数" stackId="a" fill={STAT_GREEN} name="超时归还次数" maxBarSize={36} />
+            </BarChart>
+          </ResponsiveContainer>
+        </ScrollBarHost>
+      )}
+    </ChartFrame>
+  );
+}

+ 972 - 0
src/components/material/pages/MaterialInformationPage.tsx

@@ -0,0 +1,972 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import dayjs, { type Dayjs } from 'dayjs';
+import { Button, Table, Modal, Form, Input, Select, TreeSelect, Row, Col, Image, Tag, Space, DatePicker } from 'antd';
+import type { FormProps } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Plus, Trash2, Search, RefreshCw } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialInformationApi, MaterialsVO } from '../../../api/material/information';
+import { materialLockerApi } from '../../../api/material/lockers';
+import { materialTypeApi } from '../../../api/material/type';
+import { materialStandardApi } from '../../../api/material/standard';
+import { materialPropertyValueApi } from '../../../api/material/propertyValue';
+import { pickRecords, pickTotal, buildPageParams, fetchAllMaterialTypes, buildMaterialTypeTreeData } from '../materialListUtils';
+import UploadImg from '../../lockCabinet/UploadImg';
+import {
+  MaterialPageRoot,
+  MaterialTableCard,
+  MaterialPaginationBar,
+  MaterialEditButton,
+  MaterialDeleteButton,
+  materialConfirmDelete,
+  getMaterialFormModalProps,
+} from '../MaterialPageLayout';
+
+function firstDefined(row: any, keys: string[]) {
+  for (const k of keys) {
+    const v = row[k];
+    if (v !== undefined && v !== null && v !== '') return v;
+  }
+  return undefined;
+}
+
+function inCabinetTag(row: any) {
+  const ic = firstDefined(row, ['inCabinet', 'isInCabinet', 'inCabinetFlag']);
+  if (ic === 1 || ic === true || ic === '1') return <Tag color="success">是</Tag>;
+  if (ic === 0 || ic === false || ic === '0') return <Tag color="default">否</Tag>;
+  // 兼容旧接口:仅返回 loanState 时按借还状态推断是否在柜(0 在柜、3 已归还视为在柜)
+  const ls = row.loanState;
+  if (ls !== undefined && ls !== null && ls !== '') {
+    const n = Number(ls);
+    if (n === 0 || n === 3) return <Tag color="success">是</Tag>;
+    if (n === 1 || n === 2) return <Tag color="default">否</Tag>;
+  }
+  return <span className="text-gray-400">—</span>;
+}
+
+function statusTag(row: any) {
+  const s = firstDefined(row, ['status', 'materialsStatus', 'state']);
+  if (s === undefined || s === null || s === '') return <span className="text-gray-400">—</span>;
+  if (typeof s === 'string' && (s === '正常' || s === '停用' || s === '启用')) {
+    return <Tag color={s === '停用' ? 'default' : 'blue'}>{s}</Tag>;
+  }
+  const n = Number(s);
+  if (n === 0) return <Tag color="blue">正常</Tag>;
+  if (n === 1) return <Tag>停用</Tag>;
+  return <Tag color="blue">{String(s)}</Tag>;
+}
+
+/** 物资清单新增/编辑弹窗:与老系统 MaterialForm.vue(label-width 110px、控件约 348px)一致 */
+const materialInfoModalFormLayout: FormProps = {
+  layout: 'horizontal',
+  colon: false,
+  labelAlign: 'right',
+  labelCol: { flex: '0 0 110px' },
+  wrapperCol: { flex: '1 1 auto', style: { maxWidth: 348 } },
+};
+
+/** 与老系统 index.vue / MaterialForm.vue 一致:全量物资柜(pageSize: -1)+ 末尾「空」选项 */
+const MATERIALS_CABINET_LIST_PARAMS = buildPageParams(1, -1);
+
+function mapCabinetListToOptions(rows: any[]): { value: number; label: string }[] {
+  const mapped = rows
+    .map((c: any) => {
+      const rawId = c.id ?? c.cabinetId;
+      const value = Number(rawId);
+      const label = String(c.cabinetName ?? c.name ?? '').trim();
+      return { value, label: label || (Number.isFinite(value) ? `#${value}` : '') };
+    })
+    .filter((o) => Number.isFinite(o.value) && o.label);
+  const hasZero = mapped.some((o) => o.value === 0);
+  return hasZero ? mapped : [...mapped, { value: 0, label: '空' }];
+}
+
+type DraftQuery = {
+  materialsCabinetId?: number;
+  materialsName: string;
+  materialsTypeId?: number;
+  materialsPropertyId?: number;
+  materialsPropertyValueId?: number;
+  rfid: string;
+  supplier: string;
+  validityStart: string;
+  validityEnd: string;
+  inCabinet?: number;
+  status?: number;
+};
+
+const emptyDraft = (fixedCabinetId?: number): DraftQuery => ({
+  materialsName: '',
+  rfid: '',
+  supplier: '',
+  validityStart: '',
+  validityEnd: '',
+  ...(fixedCabinetId != null && Number.isFinite(fixedCabinetId) ? { materialsCabinetId: fixedCabinetId } : {}),
+});
+
+export interface MaterialInformationPageProps {
+  /** 嵌入物资柜详情时固定筛选该柜,不可改选 */
+  fixedCabinetId?: number;
+  fixedCabinetName?: string;
+  /** 为 true 时不使用 MaterialPageRoot 外层(由详情页布局) */
+  embedded?: boolean;
+  /** 嵌入详情时与筛选区同卡顶部的 Tab 条 */
+  embeddedTabBar?: React.ReactNode;
+}
+
+export default function MaterialInformationPage({
+  fixedCabinetId,
+  fixedCabinetName,
+  embedded,
+  embeddedTabBar,
+}: MaterialInformationPageProps = {}) {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize] = useState(10);
+  const [draft, setDraft] = useState<DraftQuery>(() => emptyDraft(fixedCabinetId));
+  const [applied, setApplied] = useState<DraftQuery>(() => emptyDraft(fixedCabinetId));
+  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
+  const [modalOpen, setModalOpen] = useState(false);
+  const [form] = Form.useForm();
+  const [editing, setEditing] = useState<any | null>(null);
+
+  const [cabinetOpts, setCabinetOpts] = useState<{ value: number; label: string }[]>([]);
+  const [materialTypeRows, setMaterialTypeRows] = useState<any[]>([]);
+  const [propertyOpts, setPropertyOpts] = useState<{ value: number; label: string }[]>([]);
+  const [valueOpts, setValueOpts] = useState<{ value: number; label: string }[]>([]);
+
+  const typeTreeData = useMemo(() => buildMaterialTypeTreeData(materialTypeRows), [materialTypeRows]);
+
+  const typeNameMap = useMemo(() => {
+    const m = new Map<number, string>();
+    for (const x of materialTypeRows) {
+      const id = Number(x.id ?? x.materialsTypeId);
+      if (!Number.isFinite(id)) continue;
+      const name = String(x.materialsTypeName ?? x.typeName ?? x.name ?? '').trim();
+      if (name) m.set(id, name);
+    }
+    return m;
+  }, [materialTypeRows]);
+
+  const cabinetNameMap = useMemo(() => {
+    const m = new Map<number, string>();
+    cabinetOpts.forEach((o) => m.set(o.value, o.label));
+    return m;
+  }, [cabinetOpts]);
+
+  /** 嵌入物资柜详情时弹窗内只能展示当前柜,选项需与 disabled 状态一致,否则易不显示或提交异常 */
+  const modalCabinetSelectOptions = useMemo(() => {
+    if (fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))) {
+      const id = Number(fixedCabinetId);
+      return [
+        {
+          value: id,
+          label:
+            fixedCabinetName?.trim() ||
+            cabinetNameMap.get(id) ||
+            (Number.isFinite(id) ? `物资柜 #${id}` : ''),
+        },
+      ];
+    }
+    return cabinetOpts;
+  }, [fixedCabinetId, fixedCabinetName, cabinetNameMap, cabinetOpts]);
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const cabRes = await materialLockerApi.listMaterialsCabinet(MATERIALS_CABINET_LIST_PARAMS);
+        setCabinetOpts(mapCabinetListToOptions(pickRecords(cabRes)));
+      } catch {
+        try {
+          const cabRes2 = await materialLockerApi.listMaterialsCabinet(buildPageParams(1, 500));
+          setCabinetOpts(mapCabinetListToOptions(pickRecords(cabRes2)));
+        } catch {
+          setCabinetOpts([{ value: 0, label: '空' }]);
+        }
+      }
+      try {
+        const typeRows = await fetchAllMaterialTypes(materialTypeApi.listType);
+        const seen = new Set<number>();
+        const unique: any[] = [];
+        for (const x of typeRows) {
+          const id = Number(x.id ?? x.materialsTypeId);
+          if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
+          seen.add(id);
+          unique.push(x);
+        }
+        setMaterialTypeRows(unique);
+      } catch {
+        setMaterialTypeRows([]);
+      }
+      try {
+        const propRes = await materialStandardApi.PropertyPage(buildPageParams(1, 500));
+        setPropertyOpts(
+          pickRecords(propRes)
+            .map((x: any) => ({
+              value: Number(x.propertyId ?? x.id),
+              label: String(x.propertyName ?? x.propertyId ?? ''),
+            }))
+            .filter((o) => Number.isFinite(o.value) && o.label),
+        );
+      } catch {
+        setPropertyOpts([]);
+      }
+    })();
+  }, []);
+
+  useEffect(() => {
+    if (fixedCabinetId != null && Number.isFinite(fixedCabinetId)) {
+      setDraft((d) => ({ ...d, materialsCabinetId: fixedCabinetId }));
+      setApplied((a) => ({ ...a, materialsCabinetId: fixedCabinetId }));
+    }
+  }, [fixedCabinetId]);
+
+  useEffect(() => {
+    const pid = draft.materialsPropertyId;
+    if (pid == null) {
+      setValueOpts([]);
+      return;
+    }
+    materialPropertyValueApi
+      .PropertyValuePage(buildPageParams(1, 500, { propertyId: pid }))
+      .then((res) => {
+        setValueOpts(
+          pickRecords(res)
+            .map((x: any) => ({
+              value: Number(x.recordId ?? x.id ?? x.propertyValueId),
+              label: String(x.propertyValue ?? ''),
+            }))
+            .filter((o) => Number.isFinite(o.value)),
+        );
+      })
+      .catch(() => setValueOpts([]));
+  }, [draft.materialsPropertyId]);
+
+  const buildListParams = useCallback(() => {
+    const base = buildPageParams(pageNo, pageSize);
+    const q: Record<string, any> = { ...base };
+    if (applied.materialsCabinetId != null) {
+      const mc = Number(applied.materialsCabinetId);
+      if (Number.isFinite(mc)) {
+        q.materialsCabinetId = mc;
+        q.cabinetId = mc;
+      }
+    }
+    if (applied.materialsName?.trim()) q.materialsName = applied.materialsName.trim();
+    if (applied.materialsTypeId != null) q.materialsTypeId = applied.materialsTypeId;
+    if (applied.materialsPropertyId != null) {
+      q.materialsPropertyId = applied.materialsPropertyId;
+      q.propertyId = applied.materialsPropertyId;
+    }
+    if (applied.materialsPropertyValueId != null) {
+      q.materialsPropertyValueId = applied.materialsPropertyValueId;
+      q.propertyValueId = applied.materialsPropertyValueId;
+    }
+    if (applied.rfid?.trim()) {
+      const r = applied.rfid.trim();
+      q.rfid = r;
+      q.materialsRfid = r;
+    }
+    if (applied.supplier?.trim()) q.supplier = applied.supplier.trim();
+    if (applied.validityStart) {
+      q.validityEndStart = applied.validityStart;
+      q.expirationDateStart = applied.validityStart;
+    }
+    if (applied.validityEnd) {
+      q.validityEndEnd = applied.validityEnd;
+      q.expirationDateEnd = applied.validityEnd;
+    }
+    if (applied.inCabinet != null) q.inCabinet = applied.inCabinet;
+    if (applied.status != null) q.status = applied.status;
+    return q;
+  }, [pageNo, pageSize, applied]);
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const q = buildListParams();
+      let res: any;
+      try {
+        res = await materialInformationApi.getExMaterials(q);
+      } catch {
+        res = await materialInformationApi.listMaterials({
+          ...buildPageParams(pageNo, pageSize),
+          materialsName: applied.materialsName?.trim() || undefined,
+          materialsCabinetId: applied.materialsCabinetId,
+          cabinetId: applied.materialsCabinetId,
+          materialsTypeId: applied.materialsTypeId,
+        });
+      }
+      const records = pickRecords(res);
+      const tot = pickTotal(res);
+      setList(records);
+      setTotal(tot > 0 ? tot : records.length);
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+      setList([]);
+      setTotal(0);
+    } finally {
+      setLoading(false);
+    }
+  }, [buildListParams, t]);
+
+  useEffect(() => {
+    fetchList();
+  }, [fetchList]);
+
+  useEffect(() => {
+    setSelectedRowKeys([]);
+  }, [pageNo]);
+
+  const handleQuery = () => {
+    setApplied({ ...draft });
+    setPageNo(1);
+    setSelectedRowKeys([]);
+  };
+
+  const resetQuery = () => {
+    const e = emptyDraft(fixedCabinetId);
+    setDraft(e);
+    setApplied(e);
+    setPageNo(1);
+    setSelectedRowKeys([]);
+  };
+
+  const rowKey = (r: any, i?: number) =>
+    String(r.materialsId ?? r.id ?? r.materialsDetailId ?? r.recordId ?? `row-${i ?? 0}`);
+
+  const openForm = (row?: any) => {
+    setEditing(row || null);
+    form.resetFields();
+    if (row) {
+      const expRaw = firstDefined(row, ['expirationDate', 'validityEnd', 'expireDate', 'expiryDate', 'endTime']);
+      const cabRaw = firstDefined(row, ['materialsCabinetId', 'cabinetId', 'bindCabinetId']);
+      const rawProps = firstDefined(row, ['properties', 'materialsSpec', 'materialsSpecDesc']);
+      let propertiesText = '';
+      if (rawProps != null && String(rawProps).trim() !== '') {
+        const s = String(rawProps).trim();
+        try {
+          const parsed = JSON.parse(s);
+          if (typeof parsed === 'string') propertiesText = parsed;
+          else if (parsed != null && typeof parsed === 'object') propertiesText = '';
+        } catch {
+          propertiesText = s;
+        }
+      }
+      if (!propertiesText) {
+        const namePart = firstDefined(row, ['propertiesProperty', 'propertyName', 'materialsPropertyName']);
+        const valPart = firstDefined(row, ['propertiesValue', 'propertyValue', 'materialsPropertyValue']);
+        const parts = [namePart, valPart].filter((x) => x != null && String(x).trim() !== '');
+        if (parts.length) propertiesText = parts.map((x) => String(x).trim()).join(' / ');
+      }
+      form.setFieldsValue({
+        materialsCabinetId: cabRaw != null && cabRaw !== '' ? Number(cabRaw) : undefined,
+        materialsName: row.materialsName,
+        materialsTypeId: row.materialsTypeId != null ? Number(row.materialsTypeId) : undefined,
+        materialsRfid: firstDefined(row, ['materialsRfid', 'rfid', 'rfidCode']),
+        supplier: firstDefined(row, ['supplier', 'supplierName']),
+        expirationDate: expRaw ? dayjs(String(expRaw).slice(0, 10)) : undefined,
+        materialsPicture: firstDefined(row, ['materialsPicture', 'materialsTypePicture', 'picture', 'imageUrl']),
+        properties: propertiesText || undefined,
+      });
+      if (fixedCabinetId != null && Number.isFinite(fixedCabinetId)) {
+        form.setFieldsValue({ materialsCabinetId: Number(fixedCabinetId) });
+      }
+    } else if (fixedCabinetId != null && Number.isFinite(fixedCabinetId)) {
+      form.setFieldsValue({ materialsCabinetId: fixedCabinetId });
+    }
+    setModalOpen(true);
+  };
+
+  const submit = async () => {
+    const v = await form.validateFields();
+    const expRaw = v.expirationDate;
+    const expStr =
+      expRaw == null
+        ? undefined
+        : dayjs.isDayjs(expRaw)
+          ? expRaw.format('YYYY-MM-DD')
+          : dayjs(expRaw as string | number | Date).format('YYYY-MM-DD');
+    const rid = typeof v.materialsRfid === 'string' ? v.materialsRfid.trim() : '';
+    const sup = typeof v.supplier === 'string' ? v.supplier.trim() : '';
+    const pic = v.materialsPicture;
+    const mc = v.materialsCabinetId;
+    const propsText = typeof v.properties === 'string' ? v.properties.trim() : '';
+    const basePayload: Record<string, unknown> = {
+      materialsName: v.materialsName,
+      materialsTypeId: v.materialsTypeId,
+      materialsPicture: pic,
+      materialsIcon: pic ?? (editing ? firstDefined(editing, ['materialsIcon', 'materialsTypeIcon']) : undefined),
+      loanState: editing != null && editing.loanState != null ? Number(editing.loanState) : 0,
+      remark: editing?.remark,
+      materialsCabinetId: mc,
+      cabinetId: mc,
+      materialsRfid: rid || undefined,
+      rfid: rid || undefined,
+      supplier: sup || undefined,
+      expirationDate: expStr,
+      validityEnd: expStr,
+      properties: propsText,
+    };
+    setLoading(true);
+    try {
+      if (editing) {
+        await materialInformationApi.updateMaterials({
+          ...editing,
+          ...basePayload,
+          materialsId: editing.materialsId ?? editing.id,
+        } as MaterialsVO);
+        toast.success(t('common.updateSuccess'));
+      } else {
+        await materialInformationApi.addMaterials(basePayload as MaterialsVO);
+        toast.success(t('common.addSuccess'));
+      }
+      setModalOpen(false);
+      fetchList();
+    } catch (e: any) {
+      if (!e?.errorFields) toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const onDelete = (row: any) => {
+    const id = row.materialsId ?? row.id;
+    materialConfirmDelete(t, {
+      count: 1,
+      onOk: async () => {
+        await materialInformationApi.deleteMaterials(id);
+        toast.success(t('common.deleteSuccess'));
+        fetchList();
+      },
+    });
+  };
+
+  const batchDelete = () => {
+    if (selectedRowKeys.length === 0) return;
+    const ids = list
+      .filter((r, idx) => selectedRowKeys.includes(rowKey(r, idx)))
+      .map((r) => r.materialsId ?? r.id)
+      .filter((x) => x != null);
+    if (ids.length === 0) {
+      toast.error('无法解析选中行的物资 ID');
+      return;
+    }
+    materialConfirmDelete(t, {
+      count: ids.length,
+      onOk: async () => {
+        await materialInformationApi.deleteMaterials(ids.join(','));
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys([]);
+        fetchList();
+      },
+    });
+  };
+
+  const setDraftField = <K extends keyof DraftQuery>(key: K, value: DraftQuery[K]) => {
+    setDraft((d) => ({ ...d, [key]: value }));
+  };
+
+  const columns: ColumnsType<any> = [
+    {
+      title: fixedCabinetId != null ? '物资柜' : '绑定物资柜',
+      width: 124,
+      align: 'center',
+      render: (_, row) => {
+        const name = firstDefined(row, ['cabinetName', 'materialsCabinetName']);
+        const rawId = firstDefined(row, ['materialsCabinetId', 'cabinetId', 'bindCabinetId']);
+        const id = rawId !== undefined && rawId !== null && rawId !== '' ? Number(rawId) : NaN;
+        if (name) return String(name);
+        if (id === 0) return '空';
+        if (Number.isFinite(id) && id > 0) return cabinetNameMap.get(id) ?? String(id);
+        return '—';
+      },
+    },
+    {
+      title: '物资编号',
+      width: 92,
+      align: 'center',
+      render: (_, row) =>
+        String(firstDefined(row, ['materialsCode', 'materialsNo', 'materialsId', 'id']) ?? '—'),
+    },
+    {
+      title: '物资名称',
+      dataIndex: 'materialsName',
+      key: 'materialsName',
+      width: 160,
+      align: 'center',
+      ellipsis: { showTitle: true },
+    },
+    {
+      title: '物资类型',
+      width: 132,
+      align: 'center',
+      render: (_, row) => {
+        const name = firstDefined(row, ['materialsTypeName', 'typeName']);
+        if (name) return String(name);
+        const tid = row.materialsTypeId;
+        if (tid != null) return typeNameMap.get(Number(tid)) ?? String(tid);
+        return '—';
+      },
+    },
+    {
+      title: '物资规格种类',
+      width: 128,
+      align: 'center',
+      render: (_, row) =>
+        String(
+          firstDefined(row, [
+            'propertiesProperty',
+            'propertyName',
+            'materialsPropertyName',
+            'specTypeName',
+          ]) ?? '—',
+        ),
+    },
+    {
+      title: '物资规格',
+      width: 104,
+      align: 'center',
+      render: (_, row) =>
+        String(
+          firstDefined(row, ['propertiesValue', 'propertyValue', 'materialsPropertyValue', 'specValue']) ?? '—',
+        ),
+    },
+    {
+      title: '物资图片',
+      width: 96,
+      align: 'center',
+      render: (_, row) => {
+        const src = firstDefined(row, [
+          'materialsTypePicture',
+          'materialsPicture',
+          'materialsTypeIcon',
+          'materialsIcon',
+          'picture',
+          'imageUrl',
+        ]);
+        if (!src) return <span className="text-gray-400">—</span>;
+        return (
+          <div className="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 shadow-sm">
+            <Image
+              src={String(src)}
+              width={44}
+              height={44}
+              className="!rounded object-cover"
+              preview={{ mask: '预览' }}
+            />
+          </div>
+        );
+      },
+    },
+    {
+      title: 'RFID',
+      width: 148,
+      ellipsis: true,
+      align: 'center',
+      render: (_, row) => String(firstDefined(row, ['materialsRfid', 'rfid', 'rfidCode', 'rfId']) ?? '—'),
+    },
+    {
+      title: '供应商',
+      width: 108,
+      ellipsis: true,
+      align: 'center',
+      render: (_, row) => String(firstDefined(row, ['supplier', 'supplierName']) ?? '—'),
+    },
+    {
+      title: '有效期截止',
+      width: 118,
+      align: 'center',
+      render: (_, row) => {
+        const d = firstDefined(row, [
+          'expirationDate',
+          'validityEnd',
+          'expireDate',
+          'expiryDate',
+          'endTime',
+        ]);
+        return d ? String(d).slice(0, 10) : '—';
+      },
+    },
+    { title: '是否在柜中', width: 112, align: 'center', render: (_, row) => inCabinetTag(row) },
+    { title: '状态', width: 88, align: 'center', render: (_, row) => statusTag(row) },
+    {
+      title: t('table.operation'),
+      width: 160,
+      align: 'center',
+      fixed: 'right',
+      render: (_, row) => (
+        <div className="flex items-center gap-2 justify-center flex-wrap">
+          <MaterialEditButton label={t('common.edit')} onClick={() => openForm(row)} />
+          <MaterialDeleteButton label={t('common.delete')} onClick={() => onDelete(row)} />
+        </div>
+      ),
+    },
+  ];
+
+  const filterLabelCls =
+    'shrink-0 text-right text-sm font-medium text-gray-600 whitespace-nowrap w-[100px] sm:w-[108px]';
+
+  const validityRangeValue: [Dayjs | null, Dayjs | null] | null =
+    draft.validityStart || draft.validityEnd
+      ? [draft.validityStart ? dayjs(draft.validityStart) : null, draft.validityEnd ? dayjs(draft.validityEnd) : null]
+      : null;
+
+  const toolbarCard = (
+    <div className="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">
+      {embeddedTabBar ? (
+        <div className="border-b border-gray-100/80 px-2 pb-3 pt-4 sm:px-3 sm:pb-4 sm:pt-5">
+          {embeddedTabBar}
+        </div>
+      ) : null}
+      <div
+        className="box-border w-full min-w-0 px-5"
+        style={{ paddingTop: 28, paddingBottom: 20 }}
+      >
+        <div className="w-full min-w-0 space-y-4">
+        <Row gutter={[16, 0]}>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>{fixedCabinetId != null ? '物资柜' : '绑定物资柜'}</span>
+              <Select
+                allowClear={fixedCabinetId == null}
+                disabled={fixedCabinetId != null}
+                placeholder="请选择物资柜"
+                className="min-w-0 flex-1"
+                options={
+                  fixedCabinetId != null
+                    ? [
+                        {
+                          value: fixedCabinetId,
+                          label:
+                            fixedCabinetName?.trim() ||
+                            cabinetNameMap.get(fixedCabinetId) ||
+                            `物资柜 #${fixedCabinetId}`,
+                        },
+                      ]
+                    : cabinetOpts
+                }
+                value={draft.materialsCabinetId}
+                onChange={(v) => setDraftField('materialsCabinetId', v ?? undefined)}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>物资名称</span>
+              <Input
+                allowClear
+                placeholder="请输入物资名称"
+                className="min-w-0 flex-1"
+                value={draft.materialsName}
+                onChange={(e) => setDraftField('materialsName', e.target.value)}
+                onPressEnter={handleQuery}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>物资类型</span>
+              <TreeSelect
+                allowClear
+                showSearch
+                treeDefaultExpandAll
+                treeLine={{ showLeafIcon: false }}
+                placeholder="请选择物资类型"
+                className="min-w-0 flex-1"
+                treeData={typeTreeData}
+                treeNodeFilterProp="title"
+                value={draft.materialsTypeId}
+                onChange={(v) =>
+                  setDraftField('materialsTypeId', v != null && v !== '' ? Number(v) : undefined)
+                }
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>物资规格种类</span>
+              <Select
+                allowClear
+                placeholder="请选择物资规格种类"
+                className="min-w-0 flex-1"
+                options={propertyOpts}
+                value={draft.materialsPropertyId}
+                onChange={(v) => {
+                  setDraftField('materialsPropertyId', v ?? undefined);
+                  setDraftField('materialsPropertyValueId', undefined);
+                }}
+              />
+            </div>
+          </Col>
+        </Row>
+        <Row gutter={[16, 0]}>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>物资规格</span>
+              <Select
+                allowClear
+                placeholder="请选择物资规格"
+                className="min-w-0 flex-1"
+                disabled={draft.materialsPropertyId == null}
+                options={valueOpts}
+                value={draft.materialsPropertyValueId}
+                onChange={(v) => setDraftField('materialsPropertyValueId', v ?? undefined)}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>RFID</span>
+              <Input
+                allowClear
+                placeholder="请输入RFID"
+                className="min-w-0 flex-1"
+                value={draft.rfid}
+                onChange={(e) => setDraftField('rfid', e.target.value)}
+                onPressEnter={handleQuery}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>供应商</span>
+              <Input
+                allowClear
+                placeholder="请输入供应商"
+                className="min-w-0 flex-1"
+                value={draft.supplier}
+                onChange={(e) => setDraftField('supplier', e.target.value)}
+                onPressEnter={handleQuery}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>有效期截止</span>
+              <DatePicker.RangePicker
+                className="min-w-0 flex-1 [&_.ant-picker-input]:min-w-0"
+                value={validityRangeValue}
+                onChange={(vals) => {
+                  const [s, e] = vals || [];
+                  setDraft((d) => ({
+                    ...d,
+                    validityStart: s ? s.format('YYYY-MM-DD') : '',
+                    validityEnd: e ? e.format('YYYY-MM-DD') : '',
+                  }));
+                }}
+                placeholder={['开始日期', '结束日期']}
+              />
+            </div>
+          </Col>
+        </Row>
+        <Row gutter={[16, 0]} align="middle">
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>是否在柜中</span>
+              <Select
+                allowClear
+                placeholder="是否在柜中"
+                className="min-w-0 flex-1"
+                value={draft.inCabinet}
+                onChange={(v) => setDraftField('inCabinet', v ?? undefined)}
+                options={[
+                  { value: 1, label: '是' },
+                  { value: 0, label: '否' },
+                ]}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={6}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>状态</span>
+              <Select
+                allowClear
+                placeholder="物资状态"
+                className="min-w-0 flex-1"
+                value={draft.status}
+                onChange={(v) => setDraftField('status', v ?? undefined)}
+                options={[
+                  { value: 0, label: '正常' },
+                  { value: 1, label: '停用' },
+                ]}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={24} lg={12}>
+            <div className="flex min-w-0 w-full flex-wrap items-center justify-start lg:min-h-[32px]">
+              <Space size="small" wrap className="min-w-0 shrink-0">
+                <Button type="primary" icon={<Search className="h-4 w-4" />} onClick={handleQuery}>
+                  {t('common.search')}
+                </Button>
+                <Button icon={<RefreshCw className="h-4 w-4" />} onClick={resetQuery}>
+                  {t('common.reset')}
+                </Button>
+                <Button type="primary" icon={<Plus />} onClick={() => openForm()}>
+                  {t('common.addNew')}
+                </Button>
+                <Button
+                  danger
+                  icon={<Trash2 className="h-4 w-4" />}
+                  disabled={selectedRowKeys.length === 0}
+                  onClick={batchDelete}
+                >
+                  {t('common.batchDelete')}
+                </Button>
+              </Space>
+            </div>
+          </Col>
+        </Row>
+        </div>
+      </div>
+    </div>
+  );
+
+  const tableInner = (
+    <>
+      <MaterialTableCard flush={Boolean(embedded)}>
+        <Table
+          size="middle"
+          rowKey={(r, i) => rowKey(r, i)}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          scroll={{ x: 1360 }}
+          rowSelection={{
+            columnWidth: 48,
+            selectedRowKeys,
+            onChange: setSelectedRowKeys,
+          }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        flushTop={Boolean(embedded)}
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        loading={loading}
+        listLength={list.length}
+      />
+    </>
+  );
+
+  const listSection = embedded ? (
+    <div className="rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">
+      {tableInner}
+    </div>
+  ) : (
+    tableInner
+  );
+
+  const pageBody = (
+    <>
+      {toolbarCard}
+      {listSection}
+
+      <Modal
+        title={editing ? '编辑' : '新增'}
+        open={modalOpen}
+        onCancel={() => setModalOpen(false)}
+        footer={
+          <div className="flex justify-end gap-2">
+            <Button
+              type="primary"
+              loading={loading}
+              onClick={() => {
+                void submit().catch(() => {});
+              }}
+            >
+              {t('common.confirm')}
+            </Button>
+            <Button onClick={() => setModalOpen(false)}>{t('common.cancel')}</Button>
+          </div>
+        }
+        {...getMaterialFormModalProps(t, 960)}
+        styles={{ body: { padding: '12px 24px 16px' } }}
+      >
+        <Form form={form} {...materialInfoModalFormLayout}>
+          <Row gutter={16}>
+            <Col span={12}>
+              <Form.Item name="materialsCabinetId" label="绑定物资柜">
+                <Select
+                  allowClear={fixedCabinetId == null}
+                  disabled={fixedCabinetId != null}
+                  showSearch
+                  optionFilterProp="label"
+                  options={modalCabinetSelectOptions}
+                  placeholder="请选择绑定物资柜"
+                  onClear={() => form.setFieldValue('materialsCabinetId', 0)}
+                />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item name="materialsName" label="物资名称" rules={[{ required: true, message: '请输入物资名称' }]}>
+                <Input allowClear placeholder="请输入物资名称" />
+              </Form.Item>
+            </Col>
+          </Row>
+          <Row gutter={16}>
+            <Col span={12}>
+              <Form.Item name="materialsTypeId" label="物资类型" rules={[{ required: true, message: '请选择物资类型' }]}>
+                <TreeSelect
+                  showSearch
+                  treeDefaultExpandAll
+                  treeLine={{ showLeafIcon: false }}
+                  treeData={typeTreeData}
+                  treeNodeFilterProp="title"
+                  placeholder="选择物资类型"
+                  className="w-full"
+                />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item name="materialsRfid" label="RFID">
+                <Input allowClear placeholder="请输入物资RFID" />
+              </Form.Item>
+            </Col>
+          </Row>
+          <Row gutter={16}>
+            <Col span={12}>
+              <Form.Item name="supplier" label="供应商">
+                <Input allowClear placeholder="请输入供应商" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item name="expirationDate" label="有效期截止">
+                <DatePicker className="w-full" allowClear placeholder="选择日期" />
+              </Form.Item>
+            </Col>
+          </Row>
+          <Row gutter={16} align="top">
+            <Col span={12}>
+              <Form.Item name="properties" label="物资规格">
+                <Input.TextArea
+                  allowClear
+                  placeholder="请输入物资规格"
+                  rows={6}
+                  maxLength={2000}
+                  showCount
+                  className="w-full"
+                />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item name="materialsPicture" label="物资图片">
+                <UploadImg width="90px" height="90px" />
+              </Form.Item>
+            </Col>
+          </Row>
+        </Form>
+      </Modal>
+    </>
+  );
+
+  return embedded ? <div className="space-y-4">{pageBody}</div> : <MaterialPageRoot>{pageBody}</MaterialPageRoot>;
+}

+ 1178 - 0
src/components/material/pages/MaterialInspectionPlanPage.tsx

@@ -0,0 +1,1178 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import dayjs, { type Dayjs } from 'dayjs';
+import {
+  Button,
+  Table,
+  Modal,
+  Form,
+  Input,
+  Select,
+  DatePicker,
+  Tag,
+  Row,
+  Col,
+  Checkbox,
+  Space,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Plus, Search, RefreshCw } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialPlanApi, PlanVO } from '../../../api/material/plan';
+import { materialLockerApi } from '../../../api/material/lockers';
+import { workstationApi } from '../../../api/workstation';
+import { userApi } from '../../../api/user';
+import { handleTree } from '../../../utils/tree';
+import {
+  pickRecords,
+  pickTotal,
+  buildPageParams,
+  fetchAllWorkstations,
+  MATERIALS_TYPE_PAGE_SIZE_MAX,
+} from '../materialListUtils';
+import {
+  MaterialPageRoot,
+  MaterialTableCard,
+  MaterialPaginationBar,
+  MaterialEditButton,
+  MaterialDeleteButton,
+  MaterialViewButton,
+  materialConfirmDelete,
+  getMaterialFormModalProps,
+} from '../MaterialPageLayout';
+
+const MATERIALS_CABINET_LIST_PARAMS = buildPageParams(1, -1);
+/** 与旧版 inspectionplan/index.vue getAutoConfigInfo 一致 */
+const PLAN_AUTO_FETCH_PARAM = 'test_01';
+const PLAN_AUTO_TEMPLATE_CODE = 'CHECK_PLAN';
+
+function firstDefined(row: Record<string, any>, keys: string[]) {
+  for (const k of keys) {
+    const v = row[k];
+    if (v !== undefined && v !== null && v !== '') return v;
+  }
+  return undefined;
+}
+
+function formatPlanDate(v: unknown): string {
+  if (v === undefined || v === null || v === '') return '—';
+  const d = dayjs(v as string | number | Date);
+  return d.isValid() ? d.format('YYYY-MM-DD') : String(v);
+}
+
+function mapCabinetListToFilterOptions(rows: any[]): { value: number; label: string }[] {
+  const mapped = rows
+    .map((c: any) => {
+      const rawId = c.id ?? c.cabinetId;
+      const value = Number(rawId);
+      const label = String(c.cabinetName ?? c.name ?? '').trim();
+      return { value, label: label || (Number.isFinite(value) ? `#${value}` : '') };
+    })
+    .filter((o) => Number.isFinite(o.value) && o.label);
+  const hasZero = mapped.some((o) => o.value === 0);
+  return hasZero ? mapped : [...mapped, { value: 0, label: '空' }];
+}
+
+/** 物资柜名称:字符串、数组或嵌套对象 */
+function renderCabinetNames(_: unknown, row: any) {
+  const v = firstDefined(row, [
+    'cabinetNames',
+    'cabinet_names',
+    'cabinetName',
+    'cabinetNameList',
+    'materialsCabinetNames',
+    'cabinetList',
+    'cabinets',
+    'cabinetNamesStr',
+  ]);
+  if (v == null || v === '') return <span className="text-gray-400">—</span>;
+  if (Array.isArray(v)) {
+    const text = v
+      .map((item) => {
+        if (item == null) return '';
+        if (typeof item === 'string' || typeof item === 'number') return String(item);
+        return String(
+          firstDefined(item as Record<string, any>, ['cabinetName', 'cabinet_name', 'name', 'materialsCabinetName']) ??
+            '',
+        );
+      })
+      .filter(Boolean)
+      .join(',');
+    return text ? (
+      <span className="text-left inline-block max-w-full">{text}</span>
+    ) : (
+      <span className="text-gray-400">—</span>
+    );
+  }
+  return <span className="text-left inline-block max-w-full">{String(v)}</span>;
+}
+
+/** 与旧版 dict CHECKING_STATUS + 数值兼容 */
+function renderPlanStatus(_: unknown, row: any) {
+  const name = firstDefined(row, ['planStatusName', 'plan_status_name', 'statusName', 'status_name', 'planStatusStr']);
+  if (name != null && String(name).trim() !== '') {
+    return <Tag color="blue">{String(name).trim()}</Tag>;
+  }
+  const raw = firstDefined(row, ['planStatus', 'plan_status', 'status', 'planState', 'plan_state']);
+  if (raw === undefined || raw === null || raw === '') {
+    return <span className="text-gray-400">—</span>;
+  }
+  if (typeof raw === 'string' && Number.isNaN(Number(raw))) {
+    const byLabel: Record<string, string> = {
+      未开始: 'blue',
+      进行中: 'processing',
+      已完成: 'success',
+      已结束: 'default',
+      已取消: 'default',
+    };
+    const t = raw.trim();
+    return <Tag color={byLabel[t] ?? 'default'}>{t}</Tag>;
+  }
+  const n = Number(raw);
+  const byNum: Record<number, { label: string; color: string }> = {
+    0: { label: '未开始', color: 'blue' },
+    1: { label: '进行中', color: 'processing' },
+    2: { label: '已完成', color: 'success' },
+    3: { label: '已结束', color: 'default' },
+    4: { label: '已取消', color: 'default' },
+  };
+  if (Number.isFinite(n) && byNum[n]) {
+    const x = byNum[n];
+    return <Tag color={x.color}>{x.label}</Tag>;
+  }
+  return <Tag>{String(raw)}</Tag>;
+}
+
+function goInspectionRecordsForPlan(row: any) {
+  const planId = firstDefined(row, ['planId', 'plan_id', 'id', 'materialsCheckPlanId']);
+  try {
+    const n = planId != null && planId !== '' ? Number(planId) : NaN;
+    const planName = String(
+      firstDefined(row, ['planName', 'plan_name', 'materialsCheckPlanName', 'checkPlanName', 'name']) ?? '',
+    ).trim();
+    sessionStorage.setItem(
+      'materialInspectionRecordPreset',
+      JSON.stringify({
+        planId: Number.isFinite(n) ? n : undefined,
+        materialsCheckPlanId: Number.isFinite(n) ? n : undefined,
+        ...(planName ? { planName } : {}),
+      }),
+    );
+  } catch {
+    /* ignore */
+  }
+  window.dispatchEvent(
+    new CustomEvent('switchToMenu', {
+      detail: { menu: 'materialManagement', subMenu: 'materialInspectionRecord' },
+    }),
+  );
+}
+
+function flattenWorkstations(nodes: any[]): { label: string; value: number }[] {
+  const out: { label: string; value: number }[] = [];
+  const walk = (arr: any[]) => {
+    for (const n of arr) {
+      const id = Number(n.id ?? n.workstationId);
+      if (Number.isFinite(id) && id > 0) {
+        const code = n.workstationCode ? String(n.workstationCode) : '';
+        const name = n.workstationName ? String(n.workstationName) : '';
+        const label = [code, name].filter(Boolean).join(' / ') || String(id);
+        out.push({ value: id, label });
+      }
+      if (n.children?.length) walk(n.children);
+    }
+  };
+  walk(nodes);
+  return out;
+}
+
+function parseCabinetIdsFromRow(row: any): number[] {
+  const scalarKeys = [
+    'cabinetIds',
+    'cabinet_id_list',
+    'cabinetIdList',
+    'materialsCabinetIds',
+    'materials_cabinet_ids',
+    'cabinetList',
+    'planCabinetIds',
+    'cabinetId',
+    'cabinet_id',
+  ];
+  for (const key of scalarKeys) {
+    const raw = row?.[key];
+    if (raw === undefined || raw === null || raw === '') continue;
+    if (Array.isArray(raw)) {
+      const nums = raw
+        .map((x) =>
+          Number(
+            typeof x === 'object'
+              ? (x as any).cabinetId ?? (x as any).id ?? (x as any).materialsCabinetId
+              : x,
+          ),
+        )
+        .filter((n) => Number.isFinite(n) && n > 0);
+      if (nums.length) return nums;
+    }
+    if (typeof raw === 'string' && raw.trim()) {
+      const parts = raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
+      const nums = parts.map((s) => Number(s)).filter((n) => Number.isFinite(n) && n > 0);
+      if (nums.length) return nums;
+    }
+    if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return [raw];
+  }
+  const relList =
+    row?.planCabinetList ??
+    row?.planCabinetVOList ??
+    row?.materialsPlanCabinetList ??
+    row?.checkPlanCabinetList ??
+    row?.materialsCheckPlanCabinetList;
+  if (Array.isArray(relList) && relList.length) {
+    return relList
+      .map((x: any) => Number(x?.cabinetId ?? x?.id ?? x?.materialsCabinetId ?? x?.materialsCabinetId))
+      .filter((n) => Number.isFinite(n) && n > 0);
+  }
+  return [];
+}
+
+/** 合并下拉选项:保证已选物资柜 id 在 options 中有展示标签(与旧版详情回显一致) */
+function mergeCabinetEchoOptions(
+  base: { value: number; label: string }[],
+  ids: number[],
+  row: any,
+): { value: number; label: string }[] {
+  const byValue = new Map<number, string>();
+  for (const o of base) {
+    if (Number.isFinite(o.value) && o.value > 0) byValue.set(o.value, String(o.label || '').trim());
+  }
+  for (const id of ids) {
+    if (!Number.isFinite(id) || id <= 0) continue;
+    if (!byValue.has(id)) byValue.set(id, '');
+  }
+  const namesStr = firstDefined(row, ['cabinetName', 'cabinetNames', 'cabinet_names']);
+  const names =
+    typeof namesStr === 'string' && namesStr.trim()
+      ? namesStr.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
+      : [];
+  if (names.length === ids.length && ids.length > 0) {
+    ids.forEach((id, i) => {
+      if (Number.isFinite(id) && id > 0 && names[i]) byValue.set(id, names[i]);
+    });
+  }
+  const relList =
+    row?.planCabinetList ??
+    row?.planCabinetVOList ??
+    row?.materialsPlanCabinetList ??
+    row?.checkPlanCabinetList ??
+    row?.materialsCheckPlanCabinetList;
+  if (Array.isArray(relList)) {
+    for (const it of relList) {
+      const id = Number((it as any)?.cabinetId ?? (it as any)?.id ?? (it as any)?.materialsCabinetId);
+      const name = String((it as any)?.cabinetName ?? (it as any)?.materialsCabinetName ?? '').trim();
+      if (Number.isFinite(id) && id > 0 && name) byValue.set(id, name);
+    }
+  }
+  for (const id of ids) {
+    if (!Number.isFinite(id) || id <= 0) continue;
+    const lb = byValue.get(id);
+    if (!lb) byValue.set(id, `物资柜 #${id}`);
+  }
+  return Array.from(byValue.entries()).map(([value, label]) => ({ value, label: label || `物资柜 #${value}` }));
+}
+
+function unwrapPlanDetail(res: any): Record<string, any> | null {
+  if (res == null) return null;
+  const d = res.data !== undefined ? res.data : res;
+  if (d == null) return null;
+  if (d.data != null && typeof d.data === 'object' && !Array.isArray(d.data)) return d.data as Record<string, any>;
+  return d as Record<string, any>;
+}
+
+function buildPlanDateOptions(frequency: string | null | undefined): { value: string | number; label: string }[] {
+  if (frequency === '月') {
+    return Array.from({ length: 31 }, (_, i) => ({
+      value: String(i + 1).padStart(2, '0'),
+      label: String(i + 1).padStart(2, '0'),
+    }));
+  }
+  if (frequency === '周') {
+    return [
+      { value: 1, label: '周一' },
+      { value: 2, label: '周二' },
+      { value: 3, label: '周三' },
+      { value: 4, label: '周四' },
+      { value: 5, label: '周五' },
+      { value: 6, label: '周六' },
+      { value: 7, label: '周日' },
+    ];
+  }
+  return [];
+}
+
+/** 旧版 CHECKING_STATUS 常见取值(无字典接口时兜底) */
+const PLAN_STATUS_FILTER_OPTIONS = [
+  { value: '0', label: '未开始' },
+  { value: '1', label: '进行中' },
+  { value: '2', label: '已完成' },
+  { value: '3', label: '已结束' },
+  { value: '4', label: '已取消' },
+];
+
+const planFormLayout = {
+  layout: 'horizontal' as const,
+  labelCol: { span: 5 },
+  wrapperCol: { span: 19 },
+};
+
+type PlanDraft = {
+  planName: string;
+  cabinetId?: number;
+  planDateRange: [Dayjs, Dayjs] | null;
+  checkUserName?: string;
+  status?: string;
+};
+
+type AutoConfigState = {
+  startStatus: boolean;
+  planFrequency: string | null;
+  planDate: string | number | null;
+  checkUserId: number | null;
+  templateCode: string;
+};
+
+function emptyPlanDraft(fixedCabinetId?: number): PlanDraft {
+  return {
+    planName: '',
+    planDateRange: null,
+    checkUserName: undefined,
+    status: undefined,
+    ...(fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))
+      ? { cabinetId: Number(fixedCabinetId) }
+      : {}),
+  };
+}
+
+export interface MaterialInspectionPlanPageProps {
+  fixedCabinetId?: number;
+  embedded?: boolean;
+  embeddedTabBar?: React.ReactNode;
+}
+
+export default function MaterialInspectionPlanPage({
+  fixedCabinetId,
+  embedded,
+  embeddedTabBar,
+}: MaterialInspectionPlanPageProps = {}) {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize] = useState(10);
+  const [draft, setDraft] = useState<PlanDraft>(() => emptyPlanDraft(fixedCabinetId));
+  const [applied, setApplied] = useState<PlanDraft>(() => emptyPlanDraft(fixedCabinetId));
+
+  const [modalOpen, setModalOpen] = useState(false);
+  const [form] = Form.useForm();
+  const [editing, setEditing] = useState<any | null>(null);
+  const [workstationOptions, setWorkstationOptions] = useState<{ label: string; value: number }[]>([]);
+  const [cabinetOptions, setCabinetOptions] = useState<{ label: string; value: number }[]>([]);
+  const [cabinetFilterOptions, setCabinetFilterOptions] = useState<{ label: string; value: number }[]>([]);
+  const [userOptions, setUserOptions] = useState<{ label: string; value: number }[]>([]);
+  const [inspectorFilterOptions, setInspectorFilterOptions] = useState<{ label: string; value: string }[]>([]);
+  const [cabinetsLoading, setCabinetsLoading] = useState(false);
+
+  const [autoConfig, setAutoConfig] = useState<AutoConfigState>({
+    startStatus: false,
+    planFrequency: null,
+    planDate: null,
+    checkUserId: null,
+    templateCode: PLAN_AUTO_TEMPLATE_CODE,
+  });
+  const [planDateOptions, setPlanDateOptions] = useState<{ value: string | number; label: string }[]>([]);
+
+  const hidePlanNameFilter = fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId));
+  const cabinetSelectDisabled = hidePlanNameFilter;
+
+  const filterLabelCls =
+    'shrink-0 text-right text-sm font-medium text-gray-600 whitespace-nowrap w-[100px] sm:w-[108px]';
+
+  const persistAutomaticConfig = async (next: AutoConfigState) => {
+    await materialPlanApi.updateAutomaticConfig({
+      planFrequency: next.planFrequency ?? undefined,
+      planDate: next.planDate ?? undefined,
+      checkUserId: next.checkUserId ?? undefined,
+      startStatus: next.startStatus ? 1 : 0,
+      templateCode: next.templateCode,
+    });
+  };
+
+  /** 分页拉物资柜(弹窗内按区域筛选;可在弹窗打开前调用以完成回显) */
+  const fetchCabinetSelectOptions = useCallback(async (workstationId?: number | null) => {
+    setCabinetsLoading(true);
+    try {
+      const wid =
+        workstationId != null && Number.isFinite(Number(workstationId)) && Number(workstationId) > 0
+          ? Number(workstationId)
+          : undefined;
+      const all: any[] = [];
+      let p = 1;
+      const ps = MATERIALS_TYPE_PAGE_SIZE_MAX;
+      while (p <= 50) {
+        const res = await materialLockerApi.listMaterialsCabinet({
+          ...buildPageParams(p, ps),
+          ...(wid != null ? { workstationId: wid } : {}),
+        });
+        const batch = pickRecords(res);
+        all.push(...batch);
+        if (batch.length < ps) break;
+        const tot = pickTotal(res);
+        if (tot > 0 && all.length >= tot) break;
+        p += 1;
+      }
+      const opts = all.map((r: any) => ({
+        value: Number(r.cabinetId ?? r.id),
+        label: String(r.cabinetName ?? r.cabinetCode ?? r.cabinetId ?? r.id ?? ''),
+      }));
+      setCabinetOptions(opts);
+      return opts;
+    } catch (e: any) {
+      toast.error(e?.message || '加载物资柜失败');
+      setCabinetOptions([]);
+      return [];
+    } finally {
+      setCabinetsLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const res = await materialLockerApi.listMaterialsCabinet(MATERIALS_CABINET_LIST_PARAMS);
+        setCabinetFilterOptions(mapCabinetListToFilterOptions(pickRecords(res)));
+      } catch {
+        try {
+          const res2 = await materialLockerApi.listMaterialsCabinet(buildPageParams(1, 500));
+          setCabinetFilterOptions(mapCabinetListToFilterOptions(pickRecords(res2)));
+        } catch {
+          setCabinetFilterOptions([]);
+        }
+      }
+    })();
+  }, []);
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const users = await userApi.getRoleUser('check');
+        const arr = Array.isArray(users) ? users : [];
+        setUserOptions(
+          arr.map((u: any) => ({
+            value: Number(u.id),
+            label: String(u.nickname || u.username || u.id || ''),
+          })),
+        );
+        setInspectorFilterOptions(
+          arr.map((u: any) => {
+            const nick = String(u.nickname || u.username || '').trim();
+            return { value: nick, label: nick || String(u.id) };
+          }).filter((o) => o.value),
+        );
+      } catch {
+        setUserOptions([]);
+        setInspectorFilterOptions([]);
+      }
+    })();
+  }, []);
+
+  const loadAutoConfigFromServer = useCallback(async () => {
+    try {
+      const data: any = await materialPlanApi.selectIsMailNotifyConfigByCode({
+        templateCode: PLAN_AUTO_FETCH_PARAM,
+        configCode: PLAN_AUTO_FETCH_PARAM,
+      });
+      const frequency = data?.planFrequency ?? null;
+      const opts = buildPlanDateOptions(frequency);
+      setPlanDateOptions(opts);
+      setAutoConfig({
+        startStatus: data?.startStatus === 1 || data?.startStatus === '1',
+        planFrequency: frequency,
+        planDate: data?.planDate ?? null,
+        checkUserId:
+          data?.checkUserId != null && data?.checkUserId !== ''
+            ? Number(data.checkUserId)
+            : data?.checkUserId === 0
+              ? 0
+              : null,
+        templateCode: PLAN_AUTO_TEMPLATE_CODE,
+      });
+    } catch {
+      setPlanDateOptions([]);
+    }
+  }, []);
+
+  useEffect(() => {
+    void loadAutoConfigFromServer();
+  }, [loadAutoConfigFromServer]);
+
+  const buildListParams = useCallback(() => {
+    const q: Record<string, any> = {
+      ...buildPageParams(pageNo, pageSize),
+      planName: applied.planName?.trim() || undefined,
+      cabinetId:
+        fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))
+          ? Number(fixedCabinetId)
+          : applied.cabinetId != null && Number.isFinite(Number(applied.cabinetId)) && Number(applied.cabinetId) > 0
+            ? Number(applied.cabinetId)
+            : undefined,
+      checkUserName: applied.checkUserName?.trim() || undefined,
+      status: applied.status != null && applied.status !== '' ? applied.status : undefined,
+    };
+    if (applied.planDateRange?.[0] && applied.planDateRange[1]) {
+      q.startTime = applied.planDateRange[0].format('YYYY-MM-DD HH:mm:ss');
+      q.endTime = applied.planDateRange[1].format('YYYY-MM-DD HH:mm:ss');
+    }
+    return q;
+  }, [pageNo, pageSize, applied, fixedCabinetId]);
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await materialPlanApi.listPlan(buildListParams());
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  }, [buildListParams, t]);
+
+  useEffect(() => {
+    void fetchList();
+  }, [fetchList]);
+
+  useEffect(() => {
+    if (fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))) {
+      const id = Number(fixedCabinetId);
+      setDraft((d) => ({ ...d, cabinetId: id }));
+      setApplied((a) => ({ ...a, cabinetId: id }));
+    }
+  }, [fixedCabinetId]);
+
+  useEffect(() => {
+    if (!modalOpen) return;
+    let cancelled = false;
+    (async () => {
+      try {
+        const flat = await fetchAllWorkstations(workstationApi.listMarsDept);
+        const normalized = flat.map((w: any) => {
+          const id = Number(w.workstationId ?? w.id);
+          return {
+            ...w,
+            id: Number.isFinite(id) ? id : 0,
+            parentId: w.parentId != null && w.parentId !== '' ? Number(w.parentId) : 0,
+          };
+        });
+        const tree = handleTree(normalized, 'id', 'parentId', 'children');
+        if (!cancelled) setWorkstationOptions(flattenWorkstations(tree));
+      } catch {
+        if (!cancelled) setWorkstationOptions([]);
+      }
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, [modalOpen]);
+
+  useEffect(() => {
+    if (!modalOpen) return;
+    const timer = window.setTimeout(() => {
+      const wid = form.getFieldValue('workstationId');
+      const n = wid != null && wid !== '' ? Number(wid) : NaN;
+      void fetchCabinetSelectOptions(Number.isFinite(n) && n > 0 ? n : null);
+    }, 0);
+    return () => clearTimeout(timer);
+  }, [modalOpen, fetchCabinetSelectOptions, form]);
+
+  const setDraftField = <K extends keyof PlanDraft>(key: K, value: PlanDraft[K]) => {
+    setDraft((d) => ({ ...d, [key]: value }));
+  };
+
+  const handleQuery = () => {
+    setApplied({ ...draft });
+    setPageNo(1);
+  };
+
+  const resetQuery = () => {
+    const e = emptyPlanDraft(fixedCabinetId);
+    setDraft(e);
+    setApplied(e);
+    setPageNo(1);
+  };
+
+  const onAutoStartChange = (checked: boolean) => {
+    setAutoConfig((prev) => {
+      const previous = prev.startStatus;
+      const next = { ...prev, startStatus: checked };
+      void (async () => {
+        try {
+          await persistAutomaticConfig(next);
+          toast.success('更新成功');
+        } catch (e: any) {
+          setAutoConfig((c) => ({ ...c, startStatus: previous }));
+          toast.error(e?.message || t('common.operationFailed'));
+        }
+      })();
+      return next;
+    });
+  };
+
+  const onAutoFieldChange = (patch: Partial<AutoConfigState>) => {
+    setAutoConfig((prev) => {
+      const next = { ...prev, ...patch };
+      void persistAutomaticConfig(next).catch((e: any) => {
+        toast.error(e?.message || t('common.operationFailed'));
+      });
+      return next;
+    });
+  };
+
+  const onFrequencySelectChange = (v: string | null) => {
+    const freq = v ?? null;
+    setPlanDateOptions(buildPlanDateOptions(freq));
+    setAutoConfig((prev) => {
+      const next = { ...prev, planFrequency: freq, planDate: null };
+      void persistAutomaticConfig(next).catch((e: any) => {
+        toast.error(e?.message || t('common.operationFailed'));
+      });
+      return next;
+    });
+  };
+
+  const openForm = async (row?: any) => {
+    form.resetFields();
+    setEditing(null);
+
+    if (!row) {
+      setModalOpen(true);
+      return;
+    }
+
+    const planIdRaw = firstDefined(row, ['planId', 'plan_id', 'id', 'materialsCheckPlanId']);
+    let dataRow: any = { ...row };
+
+    if (planIdRaw != null && planIdRaw !== '') {
+      try {
+        const res = await materialPlanApi.getPlanInfo(Number(planIdRaw));
+        const detail = unwrapPlanDetail(res);
+        if (detail && typeof detail === 'object') {
+          dataRow = { ...row, ...detail };
+        }
+      } catch {
+        /* 列表行继续用 row */
+      }
+    }
+
+    let ids = parseCabinetIdsFromRow(dataRow);
+    if (ids.length === 0 && planIdRaw != null && planIdRaw !== '') {
+      try {
+        const cr = await materialPlanApi.getCheckPlanCabinetList({
+          ...buildPageParams(1, -1),
+          planId: Number(planIdRaw),
+          materialsCheckPlanId: Number(planIdRaw),
+        });
+        const rows = pickRecords(cr);
+        if (rows.length) {
+          dataRow = { ...dataRow, planCabinetList: rows };
+          ids = parseCabinetIdsFromRow(dataRow);
+        }
+      } catch {
+        /* ignore */
+      }
+    }
+
+    const workstationRaw = firstDefined(dataRow, ['workstationId', 'workstation_id', 'deptId', 'areaId', 'dept_id']);
+    const widNum =
+      workstationRaw != null && workstationRaw !== '' ? Number(workstationRaw) : NaN;
+    const widOpt = Number.isFinite(widNum) && widNum > 0 ? widNum : null;
+
+    const baseOpts = await fetchCabinetSelectOptions(widOpt);
+    setCabinetOptions(mergeCabinetEchoOptions(baseOpts, ids, dataRow));
+
+    const planDateRaw = firstDefined(dataRow, [
+      'planDate',
+      'plan_date',
+      'checkPlanDate',
+      'scheduleDate',
+      'startTime',
+      'start_time',
+    ]);
+    const planDateVal = planDateRaw ? dayjs(planDateRaw as string | number) : undefined;
+    const inspectorRaw = firstDefined(dataRow, [
+      'checkUserId',
+      'check_user_id',
+      'inspectorUserId',
+      'inspector_user_id',
+      'checkerId',
+      'checker_id',
+      'userId',
+      'user_id',
+      'planCheckerId',
+    ]);
+
+    setEditing(dataRow);
+    form.setFieldsValue({
+      planName: firstDefined(dataRow, ['planName', 'plan_name', 'materialsCheckPlanName', 'checkPlanName', 'name']),
+      workstationId: widOpt ?? undefined,
+      cabinetIds: ids,
+      planDate: planDateVal?.isValid() ? planDateVal : undefined,
+      inspectorUserId: (() => {
+        if (inspectorRaw == null || inspectorRaw === '') return undefined;
+        const n = Number(inspectorRaw);
+        return Number.isFinite(n) ? n : undefined;
+      })(),
+    });
+    setModalOpen(true);
+  };
+
+  const submit = async () => {
+    const v = await form.validateFields();
+    setLoading(true);
+    try {
+      const d = v.planDate as Dayjs | undefined;
+      const dateStr =
+        d && dayjs.isDayjs(d) && d.isValid() ? d.format('YYYY-MM-DD') : dayjs(v.planDate).format('YYYY-MM-DD');
+      const cabinetIdsArr: number[] = Array.isArray(v.cabinetIds)
+        ? v.cabinetIds.map((x: unknown) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
+        : [];
+      const cabinetIdsStr = cabinetIdsArr.join(',');
+
+      const commonPayload: Record<string, any> = {
+        planName: v.planName,
+        workstationId: v.workstationId,
+        workstation_id: v.workstationId,
+        cabinetIds: cabinetIdsStr,
+        cabinetIdList: cabinetIdsArr,
+        cabinet_ids: cabinetIdsStr,
+        planDate: dateStr,
+        startTime: `${dateStr}T00:00:00`,
+        endTime: `${dateStr}T23:59:59`,
+        inspectorUserId: v.inspectorUserId,
+        checkUserId: v.inspectorUserId,
+        checkerId: v.inspectorUserId,
+        planCheckerId: v.inspectorUserId,
+        planStatus: editing?.planStatus ?? 0,
+        checkCycle: editing?.checkCycle ?? 1,
+        checkCycleUnit: editing?.checkCycleUnit || 'day',
+        remark: editing?.remark,
+      };
+
+      if (editing) {
+        await materialPlanApi.updatePlan({
+          ...editing,
+          ...commonPayload,
+          planId: firstDefined(editing, ['planId', 'plan_id', 'id', 'materialsCheckPlanId']),
+        } as PlanVO);
+        toast.success(t('common.updateSuccess'));
+      } else {
+        await materialPlanApi.addPlan(commonPayload as PlanVO);
+        toast.success(t('common.addSuccess'));
+      }
+      setModalOpen(false);
+      void fetchList();
+    } catch (e: any) {
+      if (!e?.errorFields) toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const onDelete = (row: any) => {
+    const id = firstDefined(row, ['planId', 'plan_id', 'id', 'materialsCheckPlanId']);
+    materialConfirmDelete(t, {
+      count: 1,
+      onOk: async () => {
+        await materialPlanApi.deletePlan(Number(id));
+        toast.success(t('common.deleteSuccess'));
+        void fetchList();
+      },
+    });
+  };
+
+  const isEditDisabled = useCallback((row: any) => {
+    const st = firstDefined(row, ['status', 'planStatus', 'plan_status']);
+    return st === '1' || st === 1;
+  }, []);
+
+  const columns: ColumnsType<any> = useMemo(
+    () => [
+      {
+        title: '计划编号',
+        key: 'planId',
+        width: 88,
+        align: 'center',
+        render: (_, row) => String(firstDefined(row, ['planId', 'plan_id', 'id', 'materialsCheckPlanId']) ?? '—'),
+      },
+      {
+        title: '计划名称',
+        key: 'planName',
+        width: 200,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) =>
+          String(
+            firstDefined(row, ['planName', 'plan_name', 'materialsCheckPlanName', 'checkPlanName', 'name']) ?? '—',
+          ),
+      },
+      {
+        title: '物资柜',
+        key: 'cabinets',
+        width: 220,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) => renderCabinetNames(_, row),
+      },
+      {
+        title: '计划日期',
+        key: 'planDate',
+        width: 118,
+        align: 'center',
+        render: (_, row) =>
+          formatPlanDate(
+            firstDefined(row, [
+              'planDate',
+              'plan_date',
+              'checkPlanDate',
+              'scheduleDate',
+              'planDay',
+              'startTime',
+              'start_time',
+            ]),
+          ),
+      },
+      {
+        title: '检察员',
+        key: 'inspector',
+        width: 112,
+        align: 'center',
+        render: (_, row) =>
+          String(
+            firstDefined(row, [
+              'checkUserName',
+              'check_user_name',
+              'inspectorName',
+              'inspector_name',
+              'checkerName',
+              'checker_name',
+              'planChecker',
+              'plan_checker',
+              'nickName',
+              'nick_name',
+              'userName',
+              'username',
+            ]) ?? '—',
+          ),
+      },
+      {
+        title: '状态',
+        key: 'planStatus',
+        width: 104,
+        align: 'center',
+        render: (_, row) => renderPlanStatus(_, row),
+      },
+      {
+        title: '检查记录',
+        key: 'records',
+        width: 100,
+        align: 'center',
+        className: '!align-middle',
+        render: (_, row) => <MaterialViewButton label="查看" onClick={() => goInspectionRecordsForPlan(row)} />,
+      },
+      {
+        title: t('table.operation'),
+        width: 140,
+        align: 'center',
+        fixed: 'right',
+        render: (_, row) => (
+          <div className="flex items-center gap-2 justify-center">
+            {isEditDisabled(row) ? (
+              <Button type="link" disabled className="!text-gray-400">
+                修改
+              </Button>
+            ) : (
+              <MaterialEditButton label="修改" onClick={() => void openForm(row)} />
+            )}
+            <MaterialDeleteButton label={t('common.delete')} onClick={() => onDelete(row)} />
+          </div>
+        ),
+      },
+    ],
+    [t, isEditDisabled],
+  );
+
+  const filterCard = (
+    <div className="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">
+      {embeddedTabBar ? (
+        <div className="border-b border-gray-100/80 px-2 pb-3 pt-4 sm:px-3 sm:pb-4 sm:pt-5">{embeddedTabBar}</div>
+      ) : null}
+      <div className="box-border w-full min-w-0 px-5" style={{ paddingTop: 28, paddingBottom: 20 }}>
+        <div className="w-full min-w-0 space-y-4">
+          <Row gutter={[16, 16]}>
+            {!hidePlanNameFilter ? (
+              <Col xs={24} sm={12} lg={8}>
+                <div className="flex min-w-0 items-center gap-2.5">
+                  <span className={filterLabelCls}>计划名称</span>
+                  <Input
+                    allowClear
+                    placeholder="请输入计划名称"
+                    className="min-w-0 flex-1"
+                    value={draft.planName}
+                    onChange={(e) => setDraftField('planName', e.target.value)}
+                    onPressEnter={handleQuery}
+                  />
+                </div>
+              </Col>
+            ) : null}
+            <Col xs={24} sm={12} lg={hidePlanNameFilter ? 8 : 8}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资柜</span>
+                <Select
+                  allowClear={!cabinetSelectDisabled}
+                  disabled={cabinetSelectDisabled}
+                  placeholder="请选择检查的物资柜"
+                  className="min-w-0 flex-1"
+                  options={cabinetFilterOptions}
+                  value={draft.cabinetId}
+                  onChange={(v) => setDraftField('cabinetId', v ?? undefined)}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={8}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>计划日期</span>
+                <DatePicker.RangePicker
+                  showTime
+                  className="min-w-0 flex-1 [&_.ant-picker-input]:min-w-0"
+                  value={draft.planDateRange}
+                  onChange={(vals) => {
+                    const a = vals?.[0];
+                    const b = vals?.[1];
+                    setDraftField('planDateRange', a && b ? [a, b] : null);
+                  }}
+                  placeholder={['开始日期', '结束日期']}
+                />
+              </div>
+            </Col>
+          </Row>
+          <Row gutter={[16, 16]} align="middle">
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>检察员</span>
+                <Select
+                  allowClear
+                  showSearch
+                  optionFilterProp="label"
+                  placeholder="请选择检察员"
+                  className="min-w-0 flex-1"
+                  options={inspectorFilterOptions}
+                  value={draft.checkUserName}
+                  onChange={(v) => setDraftField('checkUserName', v ?? undefined)}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>状态</span>
+                <Select
+                  allowClear
+                  placeholder="请选择状态"
+                  className="min-w-0 flex-1"
+                  options={PLAN_STATUS_FILTER_OPTIONS}
+                  value={draft.status}
+                  onChange={(v) => setDraftField('status', v ?? undefined)}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={24} lg={12}>
+              <Space size="small" wrap>
+                <Button type="primary" icon={<Search className="h-4 w-4" />} onClick={handleQuery}>
+                  {t('common.search')}
+                </Button>
+                <Button icon={<RefreshCw className="h-4 w-4" />} onClick={resetQuery}>
+                  {t('common.reset')}
+                </Button>
+                <Button type="primary" ghost icon={<Plus className="h-4 w-4" />} onClick={() => void openForm()}>
+                  {t('common.addNew')}
+                </Button>
+              </Space>
+            </Col>
+          </Row>
+        </div>
+      </div>
+    </div>
+  );
+
+  const autoConfigCard = (
+    <div className="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">
+      <div
+        className="box-border flex min-h-[100px] w-full min-w-0 items-center px-5 py-8 sm:px-6 sm:py-10"
+      >
+        <Row gutter={[16, 16]} align="middle" className="w-full">
+          <Col xs={24} lg={4}>
+            <div className="flex flex-wrap items-center gap-3">
+              <Checkbox
+                checked={autoConfig.startStatus}
+                onChange={(e) => onAutoStartChange(e.target.checked)}
+                className="scale-125"
+              />
+              <span className="text-sm text-gray-800">启动自动创建</span>
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={5}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>计划频率</span>
+              <Select
+                allowClear
+                disabled={!autoConfig.startStatus}
+                placeholder="请选择计划频率"
+                className="min-w-0 flex-1"
+                value={autoConfig.planFrequency ?? undefined}
+                onChange={(v) => onFrequencySelectChange(v ?? null)}
+                options={[
+                  { value: '月', label: '月' },
+                  { value: '周', label: '周' },
+                ]}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={5}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>计划日期</span>
+              <Select
+                allowClear
+                disabled={!autoConfig.startStatus}
+                placeholder="请选择计划日期"
+                className="min-w-0 flex-1"
+                value={autoConfig.planDate ?? undefined}
+                options={planDateOptions}
+                onChange={(v) => void onAutoFieldChange({ planDate: v ?? null })}
+              />
+            </div>
+          </Col>
+          <Col xs={24} sm={12} lg={5}>
+            <div className="flex min-w-0 items-center gap-2.5">
+              <span className={filterLabelCls}>检察员</span>
+              <Select
+                allowClear
+                showSearch
+                optionFilterProp="label"
+                disabled={!autoConfig.startStatus}
+                placeholder="请选择检察员"
+                className="min-w-0 flex-1"
+                options={userOptions}
+                value={autoConfig.checkUserId ?? undefined}
+                onChange={(v) => void onAutoFieldChange({ checkUserId: v != null ? Number(v) : null })}
+              />
+            </div>
+          </Col>
+          <Col xs={24} lg={5}>
+            <span className="text-sm text-gray-500">(*自动创建的检查计划,覆盖所有物资柜)</span>
+          </Col>
+        </Row>
+      </div>
+    </div>
+  );
+
+  const tableInner = (
+    <>
+      <MaterialTableCard flush={Boolean(embedded)}>
+        <Table
+          rowKey={(r, i) => String(firstDefined(r, ['planId', 'plan_id', 'id', 'materialsCheckPlanId']) ?? i)}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          tableLayout="fixed"
+          scroll={{ x: 1100 }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        flushTop={Boolean(embedded)}
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        loading={loading}
+        listLength={list.length}
+      />
+    </>
+  );
+
+  const listSection = embedded ? (
+    <div className="rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">{tableInner}</div>
+  ) : (
+    tableInner
+  );
+
+  const pageBody = (
+    <>
+      {filterCard}
+      {autoConfigCard}
+      {listSection}
+
+      <Modal
+        title={editing ? '编辑检查计划' : '新增'}
+        open={modalOpen}
+        onOk={submit}
+        onCancel={() => setModalOpen(false)}
+        confirmLoading={loading}
+        {...getMaterialFormModalProps(t, 620)}
+        destroyOnClose={false}
+      >
+        <Form form={form} {...planFormLayout} preserve={false}>
+          <Form.Item name="planName" label="计划名称" rules={[{ required: true, message: '请输入计划名称' }]}>
+            <Input allowClear placeholder="请输入计划名称" />
+          </Form.Item>
+          <Form.Item name="workstationId" label="所属区域">
+            <Select
+              allowClear
+              showSearch
+              optionFilterProp="label"
+              placeholder="请选择所属区域"
+              options={workstationOptions}
+              onChange={(v) => {
+                form.setFieldsValue({ cabinetIds: undefined });
+                const n = v != null && v !== '' ? Number(v) : NaN;
+                void fetchCabinetSelectOptions(Number.isFinite(n) && n > 0 ? n : null);
+              }}
+            />
+          </Form.Item>
+          <Form.Item
+            name="cabinetIds"
+            label="物资柜"
+            rules={[{ required: true, message: '请选择需要检查的物资柜' }]}
+          >
+            <Select
+              mode="multiple"
+              allowClear
+              showSearch
+              optionFilterProp="label"
+              placeholder="请选择需要检查的物资柜"
+              options={cabinetOptions}
+              loading={cabinetsLoading}
+            />
+          </Form.Item>
+          <Form.Item name="planDate" label="计划日期" rules={[{ required: true, message: '请选择日期' }]}>
+            <DatePicker placeholder="请选择日期" style={{ width: 280 }} />
+          </Form.Item>
+          <Form.Item name="inspectorUserId" label="检察员" rules={[{ required: true, message: '请选择检察员' }]}>
+            <Select showSearch optionFilterProp="label" placeholder="请选择检察员" options={userOptions} />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </>
+  );
+
+  return embedded ? <div className="space-y-4">{pageBody}</div> : <MaterialPageRoot>{pageBody}</MaterialPageRoot>;
+}

+ 641 - 0
src/components/material/pages/MaterialInspectionRecordPage.tsx

@@ -0,0 +1,641 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import dayjs, { type Dayjs } from 'dayjs';
+import {
+  Table,
+  Button,
+  Row,
+  Col,
+  Select,
+  Input,
+  TreeSelect,
+  DatePicker,
+  Space,
+  Image,
+  Tag,
+  Modal,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Search, RefreshCw, Download } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialCheckRecordApi } from '../../../api/material/checkRecord';
+import { materialLockerApi } from '../../../api/material/lockers';
+import { materialTypeApi } from '../../../api/material/type';
+import {
+  pickRecords,
+  pickTotal,
+  buildPageParams,
+  fetchAllMaterialTypes,
+  buildMaterialTypeTreeData,
+  downloadBlob,
+} from '../materialListUtils';
+import {
+  MaterialPageRoot,
+  MaterialTableCard,
+  MaterialPaginationBar,
+  MaterialViewButton,
+} from '../MaterialPageLayout';
+import { switchMaterialSubMenu, MATERIAL_REPLACEMENT_RECORD_PRESET_KEY } from '../materialPvNav';
+
+const MATERIALS_CABINET_LIST_PARAMS = buildPageParams(1, -1);
+
+function firstDefined(row: Record<string, any>, keys: string[]) {
+  for (const k of keys) {
+    const v = row[k];
+    if (v !== undefined && v !== null && v !== '') return v;
+  }
+  return undefined;
+}
+
+function mapCabinetListToFilterOptions(rows: any[]): { value: number; label: string }[] {
+  const mapped = rows
+    .map((c: any) => {
+      const rawId = c.id ?? c.cabinetId;
+      const value = Number(rawId);
+      const label = String(c.cabinetName ?? c.name ?? '').trim();
+      return { value, label: label || (Number.isFinite(value) ? `#${value}` : '') };
+    })
+    .filter((o) => Number.isFinite(o.value) && o.label);
+  const hasZero = mapped.some((o) => o.value === 0);
+  return hasZero ? mapped : [...mapped, { value: 0, label: '空' }];
+}
+
+/** 与旧版 CHECKS_STATUS 常见取值一致(无字典接口时兜底) */
+const CHECK_RESULT_FILTER_OPTIONS = [
+  { value: 0, label: '正常' },
+  { value: 1, label: '异常' },
+];
+
+/** 与旧版 EXCEPTIONS_STATUS 常见取值一致 */
+const ABNORMAL_REASON_FILTER_OPTIONS = [
+  { value: 0, label: '无' },
+  { value: 1, label: '损坏' },
+  { value: 2, label: '丢失' },
+  { value: 3, label: '过期' },
+];
+
+function renderCheckResultCell(_: unknown, row: any) {
+  const name = firstDefined(row, ['statusName', 'status_name', 'checkStatusName', 'checkResultName']);
+  if (name != null && String(name).trim() !== '') {
+    const s = String(name).trim();
+    const color = s.includes('异') ? 'error' : s.includes('正') ? 'success' : 'processing';
+    return <Tag color={color}>{s}</Tag>;
+  }
+  const raw = firstDefined(row, ['status', 'checkStatus', 'check_status', 'checkResult']);
+  if (raw === undefined || raw === null || raw === '') return <span className="text-gray-400">—</span>;
+  const n = Number(raw);
+  const map: Record<number, { label: string; color: string }> = {
+    0: { label: '正常', color: 'success' },
+    1: { label: '异常', color: 'error' },
+  };
+  if (Number.isFinite(n) && map[n]) {
+    const x = map[n];
+    return <Tag color={x.color}>{x.label}</Tag>;
+  }
+  return <Tag>{String(raw)}</Tag>;
+}
+
+function renderAbnormalReasonCell(_: unknown, row: any) {
+  const raw = firstDefined(row, ['reason', 'exceptionReason', 'exception_reason', 'abnormalReason']);
+  if (raw === undefined || raw === null || raw === '' || raw === 0 || raw === '0') {
+    return <span className="text-gray-400">—</span>;
+  }
+  const name = firstDefined(row, ['reasonName', 'reason_name', 'exceptionName']);
+  if (name != null && String(name).trim() !== '') {
+    return <Tag color="magenta">{String(name).trim()}</Tag>;
+  }
+  const n = Number(raw);
+  const map: Record<number, string> = {
+    1: '损坏',
+    2: '丢失',
+    3: '过期',
+  };
+  if (Number.isFinite(n) && map[n]) return <Tag color="magenta">{map[n]}</Tag>;
+  return <Tag color="magenta">{String(raw)}</Tag>;
+}
+
+type RecordDraft = {
+  planName: string;
+  cabinetId?: number;
+  materialsName: string;
+  materialsTypeId?: number;
+  materialsRfid: string;
+  checkTimeRange: [Dayjs, Dayjs] | null;
+  checkStatus?: number;
+  reason?: number;
+  planId?: number;
+};
+
+function emptyDraft(fixedCabinetId?: number): RecordDraft {
+  return {
+    planName: '',
+    materialsName: '',
+    materialsRfid: '',
+    checkTimeRange: null,
+    checkStatus: undefined,
+    reason: undefined,
+    planId: undefined,
+    ...(fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))
+      ? { cabinetId: Number(fixedCabinetId) }
+      : {}),
+  };
+}
+
+export interface MaterialInspectionRecordPageProps {
+  fixedCabinetId?: number;
+  embedded?: boolean;
+  embeddedTabBar?: React.ReactNode;
+}
+
+export default function MaterialInspectionRecordPage({
+  fixedCabinetId,
+  embedded,
+  embeddedTabBar,
+}: MaterialInspectionRecordPageProps = {}) {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [exportLoading, setExportLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize] = useState(10);
+  const [draft, setDraft] = useState<RecordDraft>(() => emptyDraft(fixedCabinetId));
+  const [applied, setApplied] = useState<RecordDraft>(() => emptyDraft(fixedCabinetId));
+  const [cabinetOpts, setCabinetOpts] = useState<{ value: number; label: string }[]>([]);
+  const [materialTypeRows, setMaterialTypeRows] = useState<any[]>([]);
+
+  const typeTreeData = useMemo(() => buildMaterialTypeTreeData(materialTypeRows), [materialTypeRows]);
+
+  const filterLabelCls =
+    'shrink-0 text-right text-sm font-medium text-gray-600 whitespace-nowrap w-[100px] sm:w-[108px]';
+
+  const cabinetLocked = fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId));
+  const showPlanFilters = !embedded;
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const res = await materialLockerApi.listMaterialsCabinet(MATERIALS_CABINET_LIST_PARAMS);
+        setCabinetOpts(mapCabinetListToFilterOptions(pickRecords(res)));
+      } catch {
+        try {
+          const res2 = await materialLockerApi.listMaterialsCabinet(buildPageParams(1, 500));
+          setCabinetOpts(mapCabinetListToFilterOptions(pickRecords(res2)));
+        } catch {
+          setCabinetOpts([]);
+        }
+      }
+    })();
+  }, []);
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const typeRows = await fetchAllMaterialTypes(materialTypeApi.listType);
+        const seen = new Set<number>();
+        const unique: any[] = [];
+        for (const x of typeRows) {
+          const id = Number(x.id ?? x.materialsTypeId);
+          if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
+          seen.add(id);
+          unique.push(x);
+        }
+        setMaterialTypeRows(unique);
+      } catch {
+        setMaterialTypeRows([]);
+      }
+    })();
+  }, []);
+
+  /** 从检查计划「查看」跳转:带入计划筛选(与旧版路由 query 一致) */
+  useEffect(() => {
+    if (embedded) return;
+    try {
+      const raw = sessionStorage.getItem('materialInspectionRecordPreset');
+      if (!raw) return;
+      const p = JSON.parse(raw);
+      sessionStorage.removeItem('materialInspectionRecordPreset');
+      const pid = p?.planId ?? p?.materialsCheckPlanId;
+      const next = emptyDraft(fixedCabinetId);
+      if (pid != null && Number.isFinite(Number(pid))) {
+        next.planId = Number(pid);
+      }
+      const pn = p?.planName;
+      if (typeof pn === 'string' && pn.trim()) next.planName = pn.trim();
+      setDraft(next);
+      setApplied(next);
+      setPageNo(1);
+    } catch {
+      sessionStorage.removeItem('materialInspectionRecordPreset');
+    }
+  }, [embedded, fixedCabinetId]);
+
+  useEffect(() => {
+    if (cabinetLocked) {
+      const id = Number(fixedCabinetId);
+      setDraft((d) => ({ ...d, cabinetId: id }));
+      setApplied((a) => ({ ...a, cabinetId: id }));
+    }
+  }, [fixedCabinetId, cabinetLocked]);
+
+  const buildListParams = useCallback(() => {
+    const q: Record<string, any> = {
+      ...buildPageParams(pageNo, pageSize),
+    };
+    if (applied.planName?.trim()) q.planName = applied.planName.trim();
+    if (applied.planId != null && Number.isFinite(applied.planId)) {
+      q.planId = applied.planId;
+      q.materialsCheckPlanId = applied.planId;
+    }
+    const cab =
+      cabinetLocked && fixedCabinetId != null
+        ? Number(fixedCabinetId)
+        : applied.cabinetId != null && Number.isFinite(applied.cabinetId) && Number(applied.cabinetId) > 0
+          ? Number(applied.cabinetId)
+          : undefined;
+    if (cab != null) q.cabinetId = cab;
+    if (applied.materialsName?.trim()) q.materialsName = applied.materialsName.trim();
+    if (applied.materialsTypeId != null) q.materialsTypeId = applied.materialsTypeId;
+    if (applied.materialsRfid?.trim()) q.materialsRfid = applied.materialsRfid.trim();
+    if (applied.checkStatus != null) q.status = applied.checkStatus;
+    if (applied.reason != null) q.reason = applied.reason;
+    if (applied.checkTimeRange?.[0] && applied.checkTimeRange[1]) {
+      q.startTime = applied.checkTimeRange[0].format('YYYY-MM-DD HH:mm:ss');
+      q.endTime = applied.checkTimeRange[1].format('YYYY-MM-DD HH:mm:ss');
+    }
+    return q;
+  }, [pageNo, pageSize, applied, cabinetLocked, fixedCabinetId]);
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await materialCheckRecordApi.listCheckRecord(buildListParams());
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  }, [buildListParams, t]);
+
+  useEffect(() => {
+    void fetchList();
+  }, [fetchList]);
+
+  const setDraftField = <K extends keyof RecordDraft>(key: K, value: RecordDraft[K]) => {
+    setDraft((d) => ({ ...d, [key]: value }));
+  };
+
+  const handleQuery = () => {
+    setApplied({ ...draft });
+    setPageNo(1);
+  };
+
+  const resetQuery = () => {
+    const e = emptyDraft(fixedCabinetId);
+    setDraft(e);
+    setApplied(e);
+    setPageNo(1);
+  };
+
+  const handleExport = () => {
+    Modal.confirm({
+      title: '导出确认',
+      content: '是否确认导出当前筛选条件下的物资检查记录?',
+      okText: t('common.confirm'),
+      cancelText: t('common.cancel'),
+      onOk: async () => {
+        setExportLoading(true);
+        try {
+          const exportParams = { ...buildListParams(), ...buildPageParams(1, 5000) };
+          const blob = (await materialCheckRecordApi.exportCheckRecord(exportParams)) as unknown as Blob;
+          if (blob instanceof Blob) {
+            downloadBlob(blob, '物资检查记录.xls');
+            toast.success('导出成功');
+          }
+        } catch (e: any) {
+          toast.error(e?.message || t('common.operationFailed'));
+        } finally {
+          setExportLoading(false);
+        }
+      },
+    });
+  };
+
+  const goReplacementRecord = (row: any) => {
+    const recordId = firstDefined(row, ['recordId', 'id', 'materialsCheckRecordId']);
+    try {
+      const n = recordId != null && recordId !== '' ? Number(recordId) : NaN;
+      sessionStorage.setItem(
+        MATERIAL_REPLACEMENT_RECORD_PRESET_KEY,
+        JSON.stringify({
+          recordId: Number.isFinite(n) ? n : undefined,
+          checkRecordId: Number.isFinite(n) ? n : undefined,
+        }),
+      );
+    } catch {
+      /* ignore */
+    }
+    switchMaterialSubMenu('materialManagement', 'materialReplacementRecord');
+  };
+
+  const columns: ColumnsType<any> = useMemo(
+    () => [
+      {
+        title: '计划名称',
+        width: 200,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) =>
+          String(firstDefined(row, ['planName', 'plan_name', 'materialsCheckPlanName']) ?? '—'),
+      },
+      {
+        title: '物资柜',
+        width: 120,
+        align: 'center',
+        render: (_, row) =>
+          String(firstDefined(row, ['cabinetName', 'cabinet_name', 'materialsCabinetName']) ?? '—'),
+      },
+      {
+        title: '物资编号',
+        width: 96,
+        align: 'center',
+        render: (_, row) => String(firstDefined(row, ['materialsId', 'materials_id']) ?? '—'),
+      },
+      {
+        title: '物资名称',
+        width: 120,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) => String(firstDefined(row, ['materialsName', 'materials_name']) ?? '—'),
+      },
+      {
+        title: '物资类型',
+        width: 120,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) => String(firstDefined(row, ['materialsTypeName', 'typeName']) ?? '—'),
+      },
+      {
+        title: '物资图片',
+        width: 88,
+        align: 'center',
+        render: (_, row) => {
+          const src = firstDefined(row, [
+            'materialsTypePicture',
+            'materialsPicture',
+            'picture',
+            'imageUrl',
+          ]);
+          if (!src) return <span className="text-gray-400">—</span>;
+          return (
+            <div className="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 shadow-sm">
+              <Image
+                src={String(src)}
+                width={44}
+                height={44}
+                className="!rounded object-cover"
+                preview={{ mask: '预览' }}
+              />
+            </div>
+          );
+        },
+      },
+      {
+        title: 'RFID',
+        width: 140,
+        ellipsis: true,
+        align: 'center',
+        render: (_, row) => String(firstDefined(row, ['materialsRfid', 'rfid', 'rfidCode']) ?? '—'),
+      },
+      {
+        title: '检查时间',
+        width: 172,
+        align: 'center',
+        render: (_, row) =>
+          String(firstDefined(row, ['checkDate', 'checkTime', 'check_time', 'createTime']) ?? '—'),
+      },
+      {
+        title: '检查结果',
+        width: 100,
+        align: 'center',
+        render: (_, row) => renderCheckResultCell(_, row),
+      },
+      {
+        title: '异常原因',
+        width: 100,
+        align: 'center',
+        render: (_, row) => renderAbnormalReasonCell(_, row),
+      },
+      {
+        title: '措施',
+        width: 120,
+        ellipsis: true,
+        align: 'center',
+        render: (_, row) => String(firstDefined(row, ['measure', 'measures', 'handleMeasure']) ?? '—'),
+      },
+      {
+        title: '更换记录',
+        width: 100,
+        align: 'center',
+        render: (_, row) => <MaterialViewButton label="查看" onClick={() => goReplacementRecord(row)} />,
+      },
+    ],
+    [t],
+  );
+
+  const filterCard = (
+    <div className="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">
+      {embeddedTabBar ? (
+        <div className="border-b border-gray-100/80 px-2 pb-3 pt-4 sm:px-3 sm:pb-4 sm:pt-5">{embeddedTabBar}</div>
+      ) : null}
+      <div className="box-border w-full min-w-0 px-5" style={{ paddingTop: 28, paddingBottom: 20 }}>
+        <div className="w-full min-w-0 space-y-4">
+          <Row gutter={[16, 16]}>
+            {showPlanFilters ? (
+              <Col xs={24} sm={12} lg={6}>
+                <div className="flex min-w-0 items-center gap-2.5">
+                  <span className={filterLabelCls}>计划名称</span>
+                  <Input
+                    allowClear
+                    placeholder="请输入计划名称"
+                    className="min-w-0 flex-1"
+                    value={draft.planName}
+                    onChange={(e) => setDraftField('planName', e.target.value)}
+                    onPressEnter={handleQuery}
+                  />
+                </div>
+              </Col>
+            ) : null}
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资柜</span>
+                <Select
+                  allowClear={!cabinetLocked}
+                  disabled={cabinetLocked}
+                  placeholder="请选择物资柜"
+                  className="min-w-0 flex-1"
+                  options={cabinetOpts}
+                  value={draft.cabinetId}
+                  onChange={(v) => setDraftField('cabinetId', v ?? undefined)}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资名称</span>
+                <Input
+                  allowClear
+                  placeholder="请输入物资名称"
+                  className="min-w-0 flex-1"
+                  value={draft.materialsName}
+                  onChange={(e) => setDraftField('materialsName', e.target.value)}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资类型</span>
+                <TreeSelect
+                  allowClear
+                  showSearch
+                  treeDefaultExpandAll
+                  treeLine={{ showLeafIcon: false }}
+                  placeholder="请选择物资类型"
+                  className="min-w-0 flex-1"
+                  treeData={typeTreeData}
+                  treeNodeFilterProp="title"
+                  value={draft.materialsTypeId}
+                  onChange={(v) =>
+                    setDraftField('materialsTypeId', v != null && v !== '' ? Number(v) : undefined)
+                  }
+                />
+              </div>
+            </Col>
+          </Row>
+          <Row gutter={[16, 16]}>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>RFID</span>
+                <Input
+                  allowClear
+                  placeholder="请输入RFID"
+                  className="min-w-0 flex-1"
+                  value={draft.materialsRfid}
+                  onChange={(e) => setDraftField('materialsRfid', e.target.value)}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={9}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>检查时间</span>
+                <DatePicker.RangePicker
+                  showTime
+                  className="min-w-0 flex-1 [&_.ant-picker-input]:min-w-0"
+                  value={draft.checkTimeRange}
+                  onChange={(vals) => {
+                    const a = vals?.[0];
+                    const b = vals?.[1];
+                    setDraftField('checkTimeRange', a && b ? [a, b] : null);
+                  }}
+                  placeholder={['开始日期', '结束日期']}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>检查结果</span>
+                <Select
+                  allowClear
+                  placeholder="请选择检查结果"
+                  className="min-w-0 flex-1"
+                  options={CHECK_RESULT_FILTER_OPTIONS}
+                  value={draft.checkStatus}
+                  onChange={(v) => setDraftField('checkStatus', v ?? undefined)}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>异常原因</span>
+                <Select
+                  allowClear
+                  placeholder="请选择异常原因"
+                  className="min-w-0 flex-1"
+                  options={ABNORMAL_REASON_FILTER_OPTIONS}
+                  value={draft.reason}
+                  onChange={(v) => setDraftField('reason', v ?? undefined)}
+                />
+              </div>
+            </Col>
+            <Col flex="auto" className="min-w-0">
+              <div className="ml-[20px] flex min-w-0 flex-wrap items-center">
+                <Space size="small" wrap>
+                  <Button type="primary" icon={<Search className="h-4 w-4" />} onClick={handleQuery}>
+                    {t('common.search')}
+                  </Button>
+                  <Button icon={<RefreshCw className="h-4 w-4" />} onClick={resetQuery}>
+                    {t('common.reset')}
+                  </Button>
+                  <Button
+                    type="primary"
+                    ghost
+                    className="!border-green-600 !text-green-600 hover:!border-green-700 hover:!text-green-700"
+                    icon={<Download className="h-4 w-4" />}
+                    loading={exportLoading}
+                    onClick={handleExport}
+                  >
+                    导出
+                  </Button>
+                </Space>
+              </div>
+            </Col>
+          </Row>
+        </div>
+      </div>
+    </div>
+  );
+
+  const tableInner = (
+    <>
+      <MaterialTableCard flush={Boolean(embedded)}>
+        <Table
+          rowKey={(r) => r.recordId ?? r.id}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          scroll={{ x: 'max-content' }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        flushTop={Boolean(embedded)}
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        loading={loading}
+        listLength={list.length}
+      />
+    </>
+  );
+
+  const listSection = embedded ? (
+    <div className="rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">{tableInner}</div>
+  ) : (
+    tableInner
+  );
+
+  const pageBody = (
+    <>
+      {filterCard}
+      {listSection}
+    </>
+  );
+
+  return embedded ? <div className="space-y-4">{pageBody}</div> : <MaterialPageRoot>{pageBody}</MaterialPageRoot>;
+}

+ 641 - 0
src/components/material/pages/MaterialInstructionsPage.tsx

@@ -0,0 +1,641 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import dayjs from 'dayjs';
+import { Button, Table, Modal, Form, Input, InputNumber, Select, Tag, TreeSelect, Upload, Spin } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Plus, Trash2, Upload as UploadIcon } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialInstructionsApi, InstructionsVO } from '../../../api/material/instructions';
+import { materialTypeApi } from '../../../api/material/type';
+import { dictDataApi } from '../../../api/DictData';
+import { fileApi } from '../../../api/file';
+import {
+  pickRecords,
+  pickTotal,
+  buildPageParams,
+  fetchAllMaterialTypes,
+  buildMaterialTypeTreeData,
+  type MaterialTypeTreeNode,
+} from '../materialListUtils';
+import { setDictOptions, getDictLabel, type DictDataType } from '../../../utils/dict';
+import {
+  MaterialPageRoot,
+  MaterialToolbarCard,
+  MaterialTableCard,
+  MaterialPaginationBar,
+  MaterialEditButton,
+  MaterialDeleteButton,
+  MaterialViewButton,
+  materialConfirmDelete,
+  getMaterialFormModalProps,
+  materialFormLayout,
+} from '../MaterialPageLayout';
+
+/** 与字典缓存 key 一致,便于 getDictLabel */
+const INSTRUCTIONS_FILE_TYPE_DICT = 'materials_instructions_file_type';
+
+function firstDefined(row: Record<string, any>, keys: string[]) {
+  for (const k of keys) {
+    const v = row[k];
+    if (v !== undefined && v !== null && v !== '') return v;
+  }
+  return undefined;
+}
+
+function formatAddTime(row: any): string {
+  const v = firstDefined(row, ['createTime', 'create_time', 'addTime', 'add_time', 'gmtCreate', 'gmt_create']);
+  if (v === undefined || v === null || v === '') return '—';
+  const n = typeof v === 'number' ? v : /^\d+$/.test(String(v).trim()) ? Number(String(v).trim()) : NaN;
+  const d = Number.isFinite(n) && n > 1e12 ? dayjs(n) : dayjs(v as string | number | Date);
+  return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : String(v);
+}
+
+function labelForFileType(raw: unknown): string {
+  if (raw === undefined || raw === null || raw === '') return '—';
+  const s = String(raw).trim();
+  const lower = s.toLowerCase();
+  const fromDict = getDictLabel(INSTRUCTIONS_FILE_TYPE_DICT, lower) || getDictLabel(INSTRUCTIONS_FILE_TYPE_DICT, s);
+  if (fromDict) return fromDict;
+  return s.toUpperCase();
+}
+
+async function loadInstructionsFileTypeDict(): Promise<{ label: string; value: string }[]> {
+  const dictTypes = [
+    'materials_instructions_file_type',
+    'instructions_file_type',
+    'materials_file_type',
+    'iscs_materials_file_type',
+    'file_type',
+  ];
+  for (const dictType of dictTypes) {
+    try {
+      const res = await dictDataApi.getDictDataPage({ pageNo: 1, pageSize: -1, dictType });
+      const rows = pickRecords(res);
+      if (!rows?.length) continue;
+      const mapped: DictDataType[] = rows.map((r: any) => ({
+        dictType: INSTRUCTIONS_FILE_TYPE_DICT,
+        label: String(r.label ?? r.dictLabel ?? r.value ?? ''),
+        value: String(r.value ?? r.dictValue ?? '').toLowerCase(),
+        colorType: 'primary',
+        cssClass: '',
+      }));
+      setDictOptions(INSTRUCTIONS_FILE_TYPE_DICT, mapped);
+      return mapped.map((m) => ({ label: m.label, value: String(m.value) }));
+    } catch {
+      /* try next dictType */
+    }
+  }
+  const fallback: DictDataType[] = [
+    { dictType: INSTRUCTIONS_FILE_TYPE_DICT, label: 'PDF', value: 'pdf', colorType: 'primary', cssClass: '' },
+    { dictType: INSTRUCTIONS_FILE_TYPE_DICT, label: 'MP4', value: 'mp4', colorType: 'primary', cssClass: '' },
+  ];
+  setDictOptions(INSTRUCTIONS_FILE_TYPE_DICT, fallback);
+  return fallback.map((m) => ({ label: m.label, value: String(m.value) }));
+}
+
+type FilePreviewKind = 'video' | 'pdf' | 'image' | 'other';
+
+function resolvePreviewKind(fileType: string | undefined, url: string): FilePreviewKind {
+  const u = url.trim().toLowerCase();
+  const t = (fileType || '').toLowerCase();
+  if (t === 'mp4' || t === 'webm' || t === 'ogg' || /\.(mp4|webm|ogg)(\?|#|$)/i.test(u)) return 'video';
+  if (t === 'pdf' || u.includes('.pdf')) return 'pdf';
+  if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(u)) return 'image';
+  return 'other';
+}
+
+const INSTRUCTIONS_FILE_MAX_MB = 100;
+const INSTRUCTIONS_FILE_MAX_BYTES = INSTRUCTIONS_FILE_MAX_MB * 1024 * 1024;
+
+function instructionsFileAccept(file: File): boolean {
+  const name = file.name.toLowerCase();
+  return name.endsWith('.pdf') || name.endsWith('.mp4');
+}
+
+/** 与旧版 UploadFile 一致:选取文件上传,mp4/pdf、100MB;已有地址则预览 + 删除 */
+function InstructionsFileUrlControl({
+  value,
+  onChange,
+  fileType,
+}: {
+  value?: string;
+  onChange?: (v: string) => void;
+  fileType?: string;
+}) {
+  const url = (value ?? '').trim();
+  if (!url) {
+    return (
+      <div className="space-y-1">
+        <Upload
+          accept=".pdf,.mp4,application/pdf,video/mp4"
+          maxCount={1}
+          showUploadList={false}
+          beforeUpload={(file) => {
+            if (!instructionsFileAccept(file)) {
+              toast.error('仅支持上传 mp4 或 pdf 文件');
+              return Upload.LIST_IGNORE;
+            }
+            if (file.size > INSTRUCTIONS_FILE_MAX_BYTES) {
+              toast.error(`文件大小不能超过 ${INSTRUCTIONS_FILE_MAX_MB}MB`);
+              return Upload.LIST_IGNORE;
+            }
+            return true;
+          }}
+          customRequest={async ({ file, onError, onSuccess }) => {
+            try {
+              const fileUrl = await fileApi.upload(file as File);
+              onChange?.(fileUrl);
+              onSuccess?.(fileUrl as any);
+            } catch (err: any) {
+              toast.error(err?.message || '上传失败');
+              onError?.(err);
+            }
+          }}
+        >
+          <Button type="primary" icon={<UploadIcon className="h-4 w-4" />}>
+            选取文件
+          </Button>
+        </Upload>
+        <div className="text-sm text-red-500">大小不超过 {INSTRUCTIONS_FILE_MAX_MB}MB</div>
+        <div className="text-sm text-red-500">格式为 mp4/pdf 的文件</div>
+      </div>
+    );
+  }
+  const kind = resolvePreviewKind(fileType, url);
+  return (
+    <div className="space-y-3">
+      <div className="rounded-lg border border-gray-200 bg-gray-50/80 p-2 overflow-hidden">
+        {kind === 'video' ? (
+          <video
+            key={url}
+            src={url}
+            controls
+            className="mx-auto block max-h-80 w-full max-w-full rounded bg-black"
+          />
+        ) : null}
+        {kind === 'pdf' ? (
+          <iframe title="pdf-preview" src={url} className="h-80 w-full rounded border-0 bg-white" />
+        ) : null}
+        {kind === 'image' ? (
+          <img
+            alt=""
+            src={url}
+            className="mx-auto block max-h-80 max-w-full rounded object-contain"
+          />
+        ) : null}
+        {kind === 'other' ? (
+          <div className="flex flex-col items-center justify-center gap-2 py-8 text-center text-sm text-gray-600">
+            <span>当前文件类型不支持内嵌预览</span>
+            <Button type="link" className="p-0 h-auto" onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}>
+              在新窗口打开
+            </Button>
+          </div>
+        ) : null}
+      </div>
+      <div>
+        <Button danger onClick={() => onChange?.('')}>
+          删除
+        </Button>
+      </div>
+    </div>
+  );
+}
+
+export default function MaterialInstructionsPage() {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize, setPageSize] = useState(10);
+  const [draftTitle, setDraftTitle] = useState('');
+  const [appliedTitle, setAppliedTitle] = useState('');
+  const [draftMaterialsTypeId, setDraftMaterialsTypeId] = useState<number | undefined>();
+  const [appliedMaterialsTypeId, setAppliedMaterialsTypeId] = useState<number | undefined>();
+  const [draftFileType, setDraftFileType] = useState<string | undefined>();
+  const [appliedFileType, setAppliedFileType] = useState<string | undefined>();
+  const [modalOpen, setModalOpen] = useState(false);
+  const [form] = Form.useForm();
+  const modalFileType = Form.useWatch('fileType', form);
+  const [editing, setEditing] = useState<any | null>(null);
+  const [detailLoading, setDetailLoading] = useState(false);
+  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
+  const [typeTreeData, setTypeTreeData] = useState<MaterialTypeTreeNode[]>([]);
+  const [fileTypeSelectOptions, setFileTypeSelectOptions] = useState<{ label: string; value: string }[]>([]);
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await materialInstructionsApi.listInstructions({
+        ...buildPageParams(pageNo, pageSize),
+        instructionsTitle: appliedTitle || undefined,
+        materialsTypeId: appliedMaterialsTypeId,
+        fileType: appliedFileType || undefined,
+      });
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  }, [pageNo, pageSize, appliedTitle, appliedMaterialsTypeId, appliedFileType, t]);
+
+  useEffect(() => {
+    fetchList();
+  }, [fetchList]);
+
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      try {
+        const flat = await fetchAllMaterialTypes(materialTypeApi.listType, {});
+        if (cancelled) return;
+        setTypeTreeData(buildMaterialTypeTreeData(Array.isArray(flat) ? flat : []));
+      } catch {
+        if (!cancelled) setTypeTreeData([]);
+      }
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  useEffect(() => {
+    void (async () => {
+      const opts = await loadInstructionsFileTypeDict();
+      const allow = new Set(['pdf', 'mp4']);
+      const filtered = opts.filter((o) => allow.has(String(o.value).toLowerCase()));
+      setFileTypeSelectOptions(
+        filtered.length > 0
+          ? filtered
+          : [
+              { label: 'PDF', value: 'pdf' },
+              { label: 'MP4', value: 'mp4' },
+            ],
+      );
+    })();
+  }, []);
+
+  const openForm = async (row?: any) => {
+    setEditing(null);
+    form.resetFields();
+    setModalOpen(true);
+    if (!row) {
+      form.setFieldsValue({ orderNum: 0 });
+      return;
+    }
+    const id = Number(row.instructionsId ?? row.id);
+    if (!Number.isFinite(id) || id <= 0) {
+      toast.error('无效的说明编号');
+      setModalOpen(false);
+      return;
+    }
+    setDetailLoading(true);
+    try {
+      const detail = await materialInstructionsApi.getInstructionsInfo(id);
+      const d = detail && typeof detail === 'object' ? detail : row;
+      setEditing(d);
+      const ft = firstDefined(d, ['fileType', 'file_type']);
+      const sortRaw = firstDefined(d, ['orderNum', 'sort', 'sortOrder', 'orders']);
+      const sortNum = sortRaw !== undefined && sortRaw !== null && sortRaw !== '' ? Number(sortRaw) : NaN;
+      form.setFieldsValue({
+        instructionsTitle: firstDefined(d, ['instructionsTitle', 'instructions_title', 'title']),
+        materialsTypeId: firstDefined(d, ['materialsTypeId', 'materials_type_id', 'typeId']),
+        fileType: ft != null ? String(ft).toLowerCase() : undefined,
+        fileUrl: firstDefined(d, ['fileUrl', 'file_url', 'url']) ?? '',
+        orderNum: Number.isFinite(sortNum) ? sortNum : 0,
+      });
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+      setModalOpen(false);
+    } finally {
+      setDetailLoading(false);
+    }
+  };
+
+  const submit = async () => {
+    const v = await form.validateFields();
+    setLoading(true);
+    try {
+      const fileTypeNorm = String(v.fileType || '').toLowerCase();
+      const parsedOrder =
+        v.orderNum === undefined || v.orderNum === null || v.orderNum === ''
+          ? NaN
+          : Number(v.orderNum);
+      const orderNumOut = Number.isFinite(parsedOrder) ? parsedOrder : 0;
+      const fileUrlStr = v.fileUrl != null && String(v.fileUrl).trim() !== '' ? String(v.fileUrl).trim() : '';
+      const payload: InstructionsVO = {
+        instructionsTitle: v.instructionsTitle,
+        materialsTypeId: v.materialsTypeId,
+        fileType: fileTypeNorm,
+        fileUrl: fileUrlStr,
+        orderNum: orderNumOut,
+      };
+      if (editing) {
+        await materialInstructionsApi.updateInstructions({
+          ...editing,
+          ...payload,
+          instructionsId: editing.instructionsId ?? editing.id,
+        } as InstructionsVO);
+        toast.success(t('common.updateSuccess'));
+      } else {
+        await materialInstructionsApi.addInstructions(payload);
+        toast.success(t('common.addSuccess'));
+      }
+      setModalOpen(false);
+      fetchList();
+    } catch (e: any) {
+      if (!e?.errorFields) toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const onDelete = (row: any) => {
+    const id = row.instructionsId ?? row.id;
+    materialConfirmDelete(t, {
+      count: 1,
+      onOk: async () => {
+        await materialInstructionsApi.delInstructions(id);
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys((prev) => prev.filter((k) => String(k) !== String(id)));
+        fetchList();
+      },
+    });
+  };
+
+  const batchDelete = () => {
+    if (selectedRowKeys.length === 0) {
+      toast.error(t('common.pleaseSelectDataToDelete'));
+      return;
+    }
+    const ids = selectedRowKeys
+      .map((k) => Number(k))
+      .filter((n) => Number.isFinite(n) && !Number.isNaN(n));
+    if (ids.length === 0) {
+      toast.error(t('common.pleaseSelectDataToDelete'));
+      return;
+    }
+    materialConfirmDelete(t, {
+      count: ids.length,
+      onOk: async () => {
+        await materialInstructionsApi.delInstructions(ids.join(','));
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys([]);
+        fetchList();
+      },
+    });
+  };
+
+  const columns: ColumnsType<any> = [
+    {
+      title: '说明编号',
+      key: 'instructionsId',
+      width: 96,
+      align: 'center',
+      render: (_, row) => String(firstDefined(row, ['instructionsId', 'instructions_id', 'id']) ?? '—'),
+    },
+    {
+      title: '排序',
+      key: 'sort',
+      width: 72,
+      align: 'center',
+      render: (_, row) => String(firstDefined(row, ['sort', 'sortOrder', 'orderNum', 'orders']) ?? '0'),
+    },
+    {
+      title: '标题',
+      key: 'instructionsTitle',
+      width: 200,
+      ellipsis: true,
+      align: 'center',
+      render: (_, row) =>
+        String(firstDefined(row, ['instructionsTitle', 'instructions_title', 'title']) ?? '—'),
+    },
+    {
+      title: '物资类型',
+      key: 'materialsTypeName',
+      width: 160,
+      ellipsis: true,
+      align: 'center',
+      render: (_, row) =>
+        String(
+          firstDefined(row, [
+            'materialsTypeName',
+            'materials_type_name',
+            'typeName',
+            'type_name',
+            'materialsType',
+          ]) ?? '—',
+        ),
+    },
+    {
+      title: '类型',
+      key: 'fileType',
+      width: 112,
+      align: 'center',
+      render: (_, row) => {
+        const raw = firstDefined(row, ['fileType', 'file_type', 'instructionsFileType']);
+        const label = labelForFileType(raw);
+        return <Tag color="blue">{label}</Tag>;
+      },
+    },
+    {
+      title: '文件',
+      key: 'fileUrl',
+      width: 88,
+      align: 'center',
+      render: (_, row) => {
+        const url = firstDefined(row, ['fileUrl', 'file_url', 'url']);
+        if (!url) return <span className="text-gray-400">—</span>;
+        return (
+          <MaterialViewButton
+            label="查看"
+            onClick={() => window.open(String(url), '_blank', 'noopener,noreferrer')}
+          />
+        );
+      },
+    },
+    {
+      title: '添加时间',
+      key: 'createTime',
+      width: 176,
+      align: 'center',
+      render: (_, row) => formatAddTime(row),
+    },
+    {
+      title: t('table.operation'),
+      width: 148,
+      align: 'center',
+      fixed: 'right',
+      render: (_, row) => (
+        <div className="flex items-center gap-2 justify-center">
+          <MaterialEditButton label="修改" onClick={() => openForm(row)} />
+          <MaterialDeleteButton label={t('common.delete')} onClick={() => onDelete(row)} />
+        </div>
+      ),
+    },
+  ];
+
+  const handleSearch = () => {
+    setAppliedTitle(draftTitle);
+    setAppliedMaterialsTypeId(draftMaterialsTypeId);
+    setAppliedFileType(draftFileType);
+    setPageNo(1);
+    setSelectedRowKeys([]);
+  };
+
+  const handleReset = () => {
+    setDraftTitle('');
+    setAppliedTitle('');
+    setDraftMaterialsTypeId(undefined);
+    setAppliedMaterialsTypeId(undefined);
+    setDraftFileType(undefined);
+    setAppliedFileType(undefined);
+    setPageNo(1);
+    setSelectedRowKeys([]);
+  };
+
+  return (
+    <MaterialPageRoot>
+      <MaterialToolbarCard
+        filterRow={
+          <div className="flex flex-wrap items-center gap-3">
+            <div className="flex items-center gap-2">
+              <span className="text-sm font-medium text-gray-700 whitespace-nowrap">标题:</span>
+              <Input
+                allowClear
+                placeholder="请输入标题"
+                className="w-[180px]"
+                value={draftTitle}
+                onChange={(e) => setDraftTitle(e.target.value)}
+                onPressEnter={handleSearch}
+              />
+            </div>
+            <div className="flex items-center gap-2">
+              <span className="text-sm font-medium text-gray-700 whitespace-nowrap">物资类型:</span>
+              <TreeSelect
+                allowClear
+                showSearch
+                treeDefaultExpandAll
+                treeLine={{ showLeafIcon: false }}
+                treeData={typeTreeData}
+                treeNodeFilterProp="title"
+                placeholder="请选择物资类型"
+                className="min-w-[200px] w-[200px]"
+                value={draftMaterialsTypeId}
+                onChange={(v) => setDraftMaterialsTypeId(v ?? undefined)}
+              />
+            </div>
+            <div className="flex items-center gap-2">
+              <span className="text-sm font-medium text-gray-700 whitespace-nowrap">类型:</span>
+              <Select
+                allowClear
+                showSearch
+                optionFilterProp="label"
+                placeholder="请选择类型"
+                className="w-[160px]"
+                options={fileTypeSelectOptions}
+                value={draftFileType}
+                onChange={(v) => setDraftFileType(v)}
+              />
+            </div>
+          </div>
+        }
+        onSearch={handleSearch}
+        onReset={handleReset}
+        toolbarRight={
+          <>
+            <Button type="primary" icon={<Plus />} onClick={() => openForm()}>
+              {t('common.addNew')}
+            </Button>
+            <Button
+              danger
+              icon={<Trash2 className="h-4 w-4" />}
+              disabled={selectedRowKeys.length === 0}
+              onClick={batchDelete}
+            >
+              {t('common.batchDelete')}
+            </Button>
+          </>
+        }
+      />
+      <MaterialTableCard>
+        <Table
+          rowKey={(r) => r.instructionsId ?? r.id}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          scroll={{ x: 1200 }}
+          rowSelection={{
+            columnWidth: 48,
+            selectedRowKeys,
+            onChange: setSelectedRowKeys,
+            preserveSelectedRowKeys: true,
+            getCheckboxProps: (record) => ({ name: record.instructionsTitle }),
+          }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        loading={loading}
+        listLength={list.length}
+        onPageSizeChange={(size) => {
+          setPageSize(size);
+          setPageNo(1);
+        }}
+        showQuickJumper
+      />
+
+      <Modal
+        title={editing ? '修改' : '新增'}
+        open={modalOpen}
+        onOk={submit}
+        onCancel={() => {
+          setModalOpen(false);
+          setDetailLoading(false);
+        }}
+        confirmLoading={loading}
+        {...getMaterialFormModalProps(t, 720)}
+      >
+        <Spin spinning={detailLoading}>
+          <Form form={form} {...materialFormLayout}>
+            <Form.Item name="orderNum" label="显示排序">
+              <InputNumber min={0} step={1} placeholder="0" className="w-full max-w-xs" />
+            </Form.Item>
+            <Form.Item name="instructionsTitle" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
+              <Input allowClear placeholder="请输入标题" />
+            </Form.Item>
+            <Form.Item name="materialsTypeId" label="物资类型" rules={[{ required: true, message: '请选择物资类型' }]}>
+              <TreeSelect
+                showSearch
+                treeDefaultExpandAll
+                treeLine={{ showLeafIcon: false }}
+                treeData={typeTreeData}
+                treeNodeFilterProp="title"
+                placeholder="请选择物资类型"
+                className="w-full max-w-md"
+              />
+            </Form.Item>
+            <Form.Item name="fileType" label="类型" rules={[{ required: true, message: '请选择文件类型' }]}>
+              <Select
+                showSearch
+                optionFilterProp="label"
+                placeholder="请选择文件类型"
+                options={fileTypeSelectOptions}
+              />
+            </Form.Item>
+            <Form.Item name="fileUrl" label="文件">
+              <InstructionsFileUrlControl fileType={modalFileType} />
+            </Form.Item>
+          </Form>
+        </Spin>
+      </Modal>
+    </MaterialPageRoot>
+  );
+}

+ 140 - 0
src/components/material/pages/MaterialInventoryPage.tsx

@@ -0,0 +1,140 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Radio } from 'antd';
+import { Download } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialStatisticsApi } from '../../../api/material/statistics';
+import { downloadBlob } from '../materialListUtils';
+import { MaterialPageRoot, MaterialToolbarCard, MaterialTableCard } from '../MaterialPageLayout';
+
+type TabDef = { dictValue?: string; dictLabel?: string; sum?: number; label?: string; value?: string };
+
+export default function MaterialInventoryPage() {
+  const { t } = useTranslation();
+  const [tab, setTab] = useState('0');
+  const [tabs, setTabs] = useState<TabDef[]>([]);
+  const [matrix, setMatrix] = useState<any[]>([]);
+  const [loading, setLoading] = useState(false);
+
+  const loadTabsAndData = async (tkey: string) => {
+    setLoading(true);
+    try {
+      const sumData: any = await materialStatisticsApi.getInventorySum();
+      const tabList = Array.isArray(sumData) ? sumData : sumData?.list || sumData?.tabs || [];
+      setTabs(tabList.length ? tabList : [{ dictValue: '0', dictLabel: '正常', sum: 0 }]);
+      const data = await materialStatisticsApi.getMaterialInventory(tkey);
+      setMatrix(Array.isArray(data) ? data : []);
+    } catch (e: any) {
+      toast.error(e?.message || '加载盘点数据失败');
+      setMatrix([]);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    loadTabsAndData(tab);
+  }, [tab]);
+
+  const exportExcel = async () => {
+    try {
+      const blob = await materialStatisticsApi.exportMaterialInventory();
+      downloadBlob(blob as Blob, '物资盘点.xls');
+      toast.success('已开始下载');
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    }
+  };
+
+  const tabKey = (x: TabDef, i: number) => String(x.dictValue ?? x.value ?? i);
+  const tabLabel = (x: TabDef) => `${x.dictLabel ?? x.label ?? ''}${x.sum != null ? ` (${x.sum})` : ''}`;
+
+  const cabinets = matrix;
+  const materialTypes =
+    cabinets.length > 0 ? cabinets[0]?.materialsTypeVOList || cabinets[0]?.materialsTypeList || [] : [];
+
+  const getCell = (typeId: string | number, cabinetId: string | number) => {
+    const cab = cabinets.find((c: any) => String(c.cabinetId) === String(cabinetId));
+    if (!cab) return '';
+    const list = cab.materialsTypeVOList || cab.materialsTypeList || [];
+    const mt = list.find((m: any) => String(m.materialsTypeId) === String(typeId));
+    if (!mt) return '';
+    const n = mt.number;
+    return n == null || n === 0 ? '' : String(n);
+  };
+
+  return (
+    <MaterialPageRoot>
+      <MaterialToolbarCard
+        hideSearchButtons
+        filterRow={
+          <div className="flex flex-wrap items-center justify-between gap-3 w-full min-w-0">
+            <Radio.Group value={tab} onChange={(e) => setTab(e.target.value)} optionType="button" buttonStyle="solid">
+              {(tabs.length ? tabs : [{ dictValue: '0', dictLabel: '正常' }]).map((x, i) => (
+                <Radio.Button key={tabKey(x, i)} value={tabKey(x, i)}>
+                  {tabLabel(x) || `分类 ${i}`}
+                </Radio.Button>
+              ))}
+            </Radio.Group>
+            <Button type="primary" icon={<Download className="w-4 h-4" />} onClick={exportExcel}>
+              导出
+            </Button>
+          </div>
+        }
+      />
+
+      <MaterialTableCard>
+        <div className="overflow-x-auto p-1">
+          <table className="min-w-full border-collapse text-sm">
+            <thead>
+              <tr className="bg-gray-50">
+                <th className="border border-gray-200 p-2 text-left w-40 text-gray-700 font-medium">物资类型</th>
+                {cabinets.map((c: any) => (
+                  <th key={c.cabinetId} className="border border-gray-200 p-2 text-center whitespace-nowrap text-gray-700 font-medium">
+                    {c.cabinetName}
+                  </th>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {loading ? (
+                <tr>
+                  <td colSpan={Math.max(cabinets.length + 1, 2)} className="border border-gray-200 p-6 text-center text-gray-500">
+                    加载中…
+                  </td>
+                </tr>
+              ) : materialTypes.length === 0 ? (
+                <tr>
+                  <td colSpan={Math.max(cabinets.length + 1, 2)} className="border border-gray-200 p-6 text-center text-gray-500">
+                    暂无数据
+                  </td>
+                </tr>
+              ) : (
+                materialTypes.map((mt: any, idx: number) => (
+                  <tr
+                    key={mt.materialsTypeId}
+                    className={idx % 2 === 1 ? 'bg-[#f0f0f0] hover:bg-[#e8e8e8]' : 'bg-white hover:bg-gray-50'}
+                  >
+                    <td className="border border-gray-200 p-2">
+                      <div className="flex items-center gap-2">
+                        {mt.materialsTypeIcon ? (
+                          <img src={mt.materialsTypeIcon} alt="" className="h-10 w-10 rounded object-cover" />
+                        ) : null}
+                        <span>{mt.materialsTypeName}</span>
+                      </div>
+                    </td>
+                    {cabinets.map((c: any) => (
+                      <td key={c.cabinetId} className="border border-gray-200 p-2 text-center text-lg">
+                        {getCell(mt.materialsTypeId, c.cabinetId)}
+                      </td>
+                    ))}
+                  </tr>
+                ))
+              )}
+            </tbody>
+          </table>
+        </div>
+      </MaterialTableCard>
+    </MaterialPageRoot>
+  );
+}

+ 563 - 0
src/components/material/pages/MaterialLoanPage.tsx

@@ -0,0 +1,563 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import type { Dayjs } from 'dayjs';
+import { Table, Tag, Row, Col, Select, Input, TreeSelect, DatePicker, Button, Space, Image } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Search, RefreshCw, Clock } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialLoanApi } from '../../../api/material/loan';
+import { materialLockerApi } from '../../../api/material/lockers';
+import { materialTypeApi } from '../../../api/material/type';
+import {
+  pickRecords,
+  pickTotal,
+  buildPageParams,
+  fetchAllMaterialTypes,
+  buildMaterialTypeTreeData,
+} from '../materialListUtils';
+import { MaterialPageRoot, MaterialTableCard, MaterialPaginationBar } from '../MaterialPageLayout';
+
+export interface MaterialLoanPageProps {
+  fixedCabinetId?: number;
+  embedded?: boolean;
+  embeddedTabBar?: React.ReactNode;
+}
+
+const MATERIALS_CABINET_LIST_PARAMS = buildPageParams(1, -1);
+
+function firstDefined(row: any, keys: string[]) {
+  for (const k of keys) {
+    const v = row[k];
+    if (v !== undefined && v !== null && v !== '') return v;
+  }
+  return undefined;
+}
+
+function mapCabinetListToOptions(rows: any[]): { value: number; label: string }[] {
+  const mapped = rows
+    .map((c: any) => {
+      const rawId = c.id ?? c.cabinetId;
+      const value = Number(rawId);
+      const label = String(c.cabinetName ?? c.name ?? '').trim();
+      return { value, label: label || (Number.isFinite(value) ? `#${value}` : '') };
+    })
+    .filter((o) => Number.isFinite(o.value) && o.label);
+  const hasZero = mapped.some((o) => o.value === 0);
+  return hasZero ? mapped : [...mapped, { value: 0, label: '空' }];
+}
+
+/** 与旧版 coll/index.vue formatLoanDuration 一致 */
+function formatLoanDurationText(row: any): string {
+  const duration = String(firstDefined(row, ['loanDuration', 'duration', 'borrowDuration']) ?? '').trim();
+  if (!duration) return '—';
+  const s = duration.replace(/^0小时\s*/, '').replace(/^0分\s*/, '').trim();
+  return s || '—';
+}
+
+const LOAN_STATE_MAP: Record<number, { label: string; color: string }> = {
+  0: { label: '在柜', color: 'blue' },
+  1: { label: '已领用', color: 'processing' },
+  2: { label: '待归还', color: 'warning' },
+  3: { label: '已归还', color: 'success' },
+};
+
+/** 兼容 camelCase / snake_case / 字符串数字(与旧版 dict-tag + 数值状态一致) */
+function getLoanStateRaw(row: any): unknown {
+  if (!row || typeof row !== 'object') return undefined;
+  const code =
+    row.loanState ??
+    row.loan_state ??
+    row.status ??
+    row.borrowState ??
+    row.borrow_state ??
+    row.loanStatus ??
+    row.loan_status;
+  if (code !== undefined && code !== null && code !== '') return code;
+  return row.loanStateName ?? row.loan_state_name ?? row.loanStateStr;
+}
+
+function renderLoanStateCell(_: unknown, row: any) {
+  const raw = getLoanStateRaw(row);
+  if (raw === undefined || raw === null || raw === '') {
+    return <span className="text-gray-400">—</span>;
+  }
+  if (typeof raw === 'string' && raw.trim() !== '' && Number.isNaN(Number(raw))) {
+    const t = raw.trim();
+    const byLabel: Record<string, string> = {
+      在柜: 'blue',
+      已领用: 'processing',
+      待归还: 'warning',
+      已归还: 'success',
+    };
+    return <Tag color={byLabel[t] ?? 'default'}>{t}</Tag>;
+  }
+  const n = Number(raw);
+  if (!Number.isFinite(n)) {
+    return <Tag color="default">{String(raw)}</Tag>;
+  }
+  const item = LOAN_STATE_MAP[n];
+  return <Tag color={item?.color ?? 'default'}>{item?.label ?? String(raw)}</Tag>;
+}
+
+type LoanDraft = {
+  loanFromId?: number;
+  materialsName: string;
+  materialsTypeId?: number;
+  loanUserName: string;
+  restitutionUserName: string;
+  status?: string;
+  loanRange: [Dayjs, Dayjs] | null;
+  restitutionRange: [Dayjs, Dayjs] | null;
+};
+
+function emptyDraft(fixedCabinetId?: number): LoanDraft {
+  return {
+    materialsName: '',
+    loanUserName: '',
+    restitutionUserName: '',
+    status: undefined,
+    loanRange: null,
+    restitutionRange: null,
+    ...(fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))
+      ? { loanFromId: Number(fixedCabinetId) }
+      : {}),
+  };
+}
+
+/** 领取归还列表查询参数(与旧版 LoanApi.listLoan + coll/index.vue 对齐,并附带 cabinetId 等别名以兼容后端) */
+function buildLoanListParams(
+  pageNo: number,
+  pageSize: number,
+  applied: LoanDraft,
+  fixedCabinetId?: number,
+): Record<string, any> {
+  const q: Record<string, any> = { ...buildPageParams(pageNo, pageSize) };
+  if (applied.materialsName?.trim()) q.materialsName = applied.materialsName.trim();
+  if (applied.materialsTypeId != null && Number.isFinite(applied.materialsTypeId)) {
+    q.materialsTypeId = applied.materialsTypeId;
+  }
+  if (applied.loanUserName?.trim()) q.loanUserName = applied.loanUserName.trim();
+  if (applied.restitutionUserName?.trim()) q.restitutionUserName = applied.restitutionUserName.trim();
+  if (applied.status != null && applied.status !== '') q.status = applied.status;
+
+  const cabinet =
+    fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))
+      ? Number(fixedCabinetId)
+      : applied.loanFromId != null && Number.isFinite(Number(applied.loanFromId))
+        ? Number(applied.loanFromId)
+        : undefined;
+  if (cabinet != null) {
+    q.loanFromId = cabinet;
+    q.cabinetId = cabinet;
+  }
+
+  if (applied.loanRange?.[0] && applied.loanRange[1]) {
+    q.loanTimeStart = applied.loanRange[0].format('YYYY-MM-DD HH:mm:ss');
+    q.loanTimeEnd = applied.loanRange[1].format('YYYY-MM-DD HH:mm:ss');
+  }
+  if (applied.restitutionRange?.[0] && applied.restitutionRange[1]) {
+    q.restitutionTimeStart = applied.restitutionRange[0].format('YYYY-MM-DD HH:mm:ss');
+    q.restitutionTimeEnd = applied.restitutionRange[1].format('YYYY-MM-DD HH:mm:ss');
+  }
+  return q;
+}
+
+export default function MaterialLoanPage({ fixedCabinetId, embedded, embeddedTabBar }: MaterialLoanPageProps = {}) {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize] = useState(10);
+  const [draft, setDraft] = useState<LoanDraft>(() => emptyDraft(fixedCabinetId));
+  const [applied, setApplied] = useState<LoanDraft>(() => emptyDraft(fixedCabinetId));
+
+  const [cabinetOpts, setCabinetOpts] = useState<{ value: number; label: string }[]>([]);
+  const [materialTypeRows, setMaterialTypeRows] = useState<any[]>([]);
+
+  const typeTreeData = useMemo(() => buildMaterialTypeTreeData(materialTypeRows), [materialTypeRows]);
+
+  const cabinetNameMap = useMemo(() => {
+    const m = new Map<number, string>();
+    cabinetOpts.forEach((o) => m.set(o.value, o.label));
+    return m;
+  }, [cabinetOpts]);
+
+  const filterLabelCls =
+    'shrink-0 text-right text-sm font-medium text-gray-600 whitespace-nowrap w-[100px] sm:w-[108px]';
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const cabRes = await materialLockerApi.listMaterialsCabinet(MATERIALS_CABINET_LIST_PARAMS);
+        setCabinetOpts(mapCabinetListToOptions(pickRecords(cabRes)));
+      } catch {
+        try {
+          const cabRes2 = await materialLockerApi.listMaterialsCabinet(buildPageParams(1, 500));
+          setCabinetOpts(mapCabinetListToOptions(pickRecords(cabRes2)));
+        } catch {
+          setCabinetOpts([{ value: 0, label: '空' }]);
+        }
+      }
+      try {
+        const typeRows = await fetchAllMaterialTypes(materialTypeApi.listType);
+        const seen = new Set<number>();
+        const unique: any[] = [];
+        for (const x of typeRows) {
+          const id = Number(x.id ?? x.materialsTypeId);
+          if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
+          seen.add(id);
+          unique.push(x);
+        }
+        setMaterialTypeRows(unique);
+      } catch {
+        setMaterialTypeRows([]);
+      }
+    })();
+  }, []);
+
+  useEffect(() => {
+    if (fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))) {
+      const id = Number(fixedCabinetId);
+      setDraft((d) => ({ ...d, loanFromId: id }));
+      setApplied((a) => ({ ...a, loanFromId: id }));
+    }
+  }, [fixedCabinetId]);
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const params = buildLoanListParams(pageNo, pageSize, applied, fixedCabinetId);
+      const res = await materialLoanApi.listLoan(params);
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  }, [pageNo, pageSize, applied, fixedCabinetId, t]);
+
+  useEffect(() => {
+    void fetchList();
+  }, [fetchList]);
+
+  const setDraftField = <K extends keyof LoanDraft>(key: K, value: LoanDraft[K]) => {
+    setDraft((d) => ({ ...d, [key]: value }));
+  };
+
+  const handleQuery = () => {
+    setApplied({ ...draft });
+    setPageNo(1);
+  };
+
+  const resetQuery = () => {
+    const e = emptyDraft(fixedCabinetId);
+    setDraft(e);
+    setApplied(e);
+    setPageNo(1);
+  };
+
+  /** 与旧版 handleQueryStatus:筛选超时未还(status = '2') */
+  const handleOverdueNotReturned = () => {
+    setDraft((d) => ({ ...d, status: '2' }));
+    setApplied((a) => ({ ...a, status: '2' }));
+    setPageNo(1);
+  };
+
+  const hideCabinetColumn = fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId));
+
+  const columns: ColumnsType<any> = useMemo(() => {
+    const cols: ColumnsType<any> = [];
+    if (!hideCabinetColumn) {
+      cols.push({
+        title: '物资柜',
+        align: 'center',
+        width: 120,
+        render: (_, row) =>
+          String(firstDefined(row, ['loanFromName', 'cabinetName', 'materialsCabinetName', 'cabinet']) ?? '—'),
+      });
+    }
+    cols.push(
+      {
+        title: '物资编号',
+        dataIndex: 'materialsId',
+        align: 'center',
+        width: 96,
+        render: (v, row) => String(firstDefined(row, ['materialsId', 'materialsCode', 'id']) ?? v ?? '—'),
+      },
+      { title: '物资名称', dataIndex: 'materialsName', ellipsis: true, align: 'center', width: 120 },
+      {
+        title: '物资类型',
+        align: 'center',
+        width: 140,
+        ellipsis: true,
+        render: (_, row) => String(firstDefined(row, ['materialsTypeName', 'typeName']) ?? '—'),
+      },
+      {
+        title: '物资图片',
+        width: 96,
+        align: 'center',
+        render: (_, row) => {
+          const src = firstDefined(row, [
+            'materialsTypePicture',
+            'materialsPicture',
+            'materialsTypeIcon',
+            'materialsIcon',
+            'picture',
+            'imageUrl',
+          ]);
+          if (!src) return <span className="text-gray-400">—</span>;
+          return (
+            <div className="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 shadow-sm">
+              <Image
+                src={String(src)}
+                width={44}
+                height={44}
+                className="!rounded object-cover"
+                preview={{ mask: '预览' }}
+              />
+            </div>
+          );
+        },
+      },
+      { title: '领取人', dataIndex: 'loanUserName', width: 100, align: 'center' },
+      { title: '领取时间', dataIndex: 'loanTime', width: 170, align: 'center' },
+      {
+        title: '归还人',
+        width: 100,
+        align: 'center',
+        render: (_, row) => String(firstDefined(row, ['restitutionUserName', 'returnUserName']) ?? '—'),
+      },
+      {
+        title: '归还时间',
+        width: 170,
+        align: 'center',
+        render: (_, row) =>
+          String(
+            firstDefined(row, ['actualRestitutionTime', 'actualReturnTime', 'restitutionTime', 'returnTime']) ?? '—',
+          ),
+      },
+      {
+        title: '借出时长',
+        width: 120,
+        align: 'center',
+        render: (_, row) => formatLoanDurationText(row),
+      },
+      {
+        title: '状态',
+        key: 'loanState',
+        width: 110,
+        align: 'center',
+        render: (_, row) => renderLoanStateCell(_, row),
+      },
+    );
+    return cols;
+  }, [hideCabinetColumn]);
+
+  const toolbarCard = (
+    <div className="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">
+      {embeddedTabBar ? (
+        <div className="border-b border-gray-100/80 px-2 pb-3 pt-4 sm:px-3 sm:pb-4 sm:pt-5">{embeddedTabBar}</div>
+      ) : null}
+      <div className="box-border w-full min-w-0 px-5" style={{ paddingTop: 28, paddingBottom: 20 }}>
+        <div className="w-full min-w-0 space-y-4">
+          <Row gutter={[16, 16]}>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资柜</span>
+                <Select
+                  allowClear={fixedCabinetId == null}
+                  disabled={fixedCabinetId != null}
+                  placeholder="请选择物资柜"
+                  className="min-w-0 flex-1"
+                  options={
+                    fixedCabinetId != null
+                      ? [
+                          {
+                            value: Number(fixedCabinetId),
+                            label:
+                              cabinetNameMap.get(Number(fixedCabinetId))?.trim() ||
+                              `物资柜 #${fixedCabinetId}`,
+                          },
+                        ]
+                      : cabinetOpts
+                  }
+                  value={draft.loanFromId}
+                  onChange={(v) => setDraftField('loanFromId', v ?? undefined)}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资名称</span>
+                <Input
+                  allowClear
+                  placeholder="请输入物资名称"
+                  className="min-w-0 flex-1"
+                  value={draft.materialsName}
+                  onChange={(e) => setDraftField('materialsName', e.target.value)}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资类型</span>
+                <TreeSelect
+                  allowClear
+                  showSearch
+                  treeDefaultExpandAll
+                  treeLine={{ showLeafIcon: false }}
+                  placeholder="请选择物资类型"
+                  className="min-w-0 flex-1"
+                  treeData={typeTreeData}
+                  treeNodeFilterProp="title"
+                  value={draft.materialsTypeId}
+                  onChange={(v) =>
+                    setDraftField('materialsTypeId', v != null && v !== '' ? Number(v) : undefined)
+                  }
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>领取人</span>
+                <Input
+                  allowClear
+                  placeholder="请输入领取人"
+                  className="min-w-0 flex-1"
+                  value={draft.loanUserName}
+                  onChange={(e) => setDraftField('loanUserName', e.target.value)}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+          </Row>
+          <Row gutter={[16, 16]}>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>领取时间</span>
+                <DatePicker.RangePicker
+                  showTime
+                  className="min-w-0 flex-1 [&_.ant-picker-input]:min-w-0"
+                  value={draft.loanRange}
+                  onChange={(vals) => {
+                    const a = vals?.[0];
+                    const b = vals?.[1];
+                    setDraftField('loanRange', a && b ? [a, b] : null);
+                  }}
+                  placeholder={['开始日期', '结束日期']}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>归还人</span>
+                <Input
+                  allowClear
+                  placeholder="请输入归还人"
+                  className="min-w-0 flex-1"
+                  value={draft.restitutionUserName}
+                  onChange={(e) => setDraftField('restitutionUserName', e.target.value)}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>归还时间</span>
+                <DatePicker.RangePicker
+                  showTime
+                  className="min-w-0 flex-1 [&_.ant-picker-input]:min-w-0"
+                  value={draft.restitutionRange}
+                  onChange={(vals) => {
+                    const a = vals?.[0];
+                    const b = vals?.[1];
+                    setDraftField('restitutionRange', a && b ? [a, b] : null);
+                  }}
+                  placeholder={['开始日期', '结束日期']}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>状态</span>
+                <Select
+                  allowClear
+                  placeholder="请选择状态"
+                  className="min-w-0 flex-1"
+                  value={draft.status}
+                  onChange={(v) => setDraftField('status', v ?? undefined)}
+                  options={[
+                    { value: '0', label: '在柜' },
+                    { value: '1', label: '已领用' },
+                    { value: '2', label: '待归还' },
+                    { value: '3', label: '已归还' },
+                  ]}
+                />
+              </div>
+            </Col>
+          </Row>
+          <Row gutter={[16, 16]} align="middle">
+            <Col xs={24} sm={24} lg={12}>
+              <Space size="small" wrap>
+                <Button type="primary" icon={<Search className="h-4 w-4" />} onClick={handleQuery}>
+                  {t('common.search')}
+                </Button>
+                <Button icon={<RefreshCw className="h-4 w-4" />} onClick={resetQuery}>
+                  {t('common.reset')}
+                </Button>
+                {fixedCabinetId == null ? (
+                  <Button danger ghost icon={<Clock className="h-4 w-4" />} onClick={handleOverdueNotReturned}>
+                    超时未还
+                  </Button>
+                ) : null}
+              </Space>
+            </Col>
+          </Row>
+        </div>
+      </div>
+    </div>
+  );
+
+  const tableInner = (
+    <>
+      <MaterialTableCard flush={Boolean(embedded)}>
+        <Table
+          rowKey={(r) => r.materialsLoanId ?? r.id ?? `${r.materialsId}-${r.loanTime}`}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          scroll={{ x: 'max-content' }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        flushTop={Boolean(embedded)}
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        loading={loading}
+        listLength={list.length}
+      />
+    </>
+  );
+
+  const listSection = embedded ? (
+    <div className="rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">{tableInner}</div>
+  ) : (
+    tableInner
+  );
+
+  const pageBody = (
+    <>
+      {toolbarCard}
+      {listSection}
+    </>
+  );
+
+  return embedded ? <div className="space-y-4">{pageBody}</div> : <MaterialPageRoot>{pageBody}</MaterialPageRoot>;
+}

+ 130 - 0
src/components/material/pages/MaterialLockerDetailPage.tsx

@@ -0,0 +1,130 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { ArrowLeft } from 'lucide-react';
+import { materialLockerApi } from '../../../api/material/lockers';
+import { toast } from 'sonner';
+import MaterialInformationPage from './MaterialInformationPage';
+import MaterialLoanPage from './MaterialLoanPage';
+import MaterialInspectionPlanPage from './MaterialInspectionPlanPage';
+import MaterialInspectionRecordPage from './MaterialInspectionRecordPage';
+import MaterialReplacementRecordPage from './MaterialReplacementRecordPage';
+
+export interface MaterialLockerDetailPageProps {
+  cabinetId: number;
+  cabinetName?: string;
+  onBack: () => void;
+}
+
+type DetailTabKey = 'inventory' | 'loan' | 'plan' | 'record' | 'replace';
+
+const TAB_LABELS: Record<DetailTabKey, string> = {
+  inventory: '物资清单',
+  loan: '领取记录',
+  plan: '检查计划',
+  record: '检查记录',
+  replace: '更换记录',
+};
+
+/** 显式顺序,避免 Object.keys 与 activeKey 不一致导致面板一直处于 hidden */
+const TAB_KEYS: DetailTabKey[] = ['inventory', 'loan', 'plan', 'record', 'replace'];
+
+/** 与 Dashboard 二级菜单 Tab 一致:选中蓝渐变胶囊,未选中灰字 + hover 浅蓝底 */
+function subMenuPillClass(active: boolean) {
+  return [
+    'inline-flex shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-xl px-3 py-2 text-xs font-medium transition-all duration-200 sm:px-4 sm:py-2.5 sm:text-sm',
+    active
+      ? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg shadow-blue-400/40'
+      : 'text-gray-700 hover:bg-blue-50 hover:text-blue-600',
+  ].join(' ');
+}
+
+/**
+ * 物资柜详情:顶栏 Tab 与 Dashboard 二级菜单同款胶囊条;右侧物资柜名 + 返回同风格;下方独立挂载各面板
+ */
+export default function MaterialLockerDetailPage({ cabinetId, cabinetName, onBack }: MaterialLockerDetailPageProps) {
+  const [titleName, setTitleName] = useState(cabinetName?.trim() || '');
+  const [activeKey, setActiveKey] = useState<DetailTabKey>('inventory');
+
+  const loadTitle = useCallback(async () => {
+    if (cabinetName?.trim()) {
+      setTitleName(cabinetName.trim());
+      return;
+    }
+    try {
+      const res: any = await materialLockerApi.getMaterialsCabinetInfo(cabinetId);
+      const data = res?.data ?? res;
+      const n = data?.cabinetName ?? data?.name;
+      if (n) setTitleName(String(n));
+    } catch {
+      toast.error('加载物资柜信息失败');
+    }
+  }, [cabinetId, cabinetName]);
+
+  useEffect(() => {
+    loadTitle();
+  }, [loadTitle]);
+
+  const displayTitle = titleName.trim() || '物资柜详情';
+
+  const embeddedTabBar = useMemo(
+    () => (
+      <div className="flex w-full min-w-0 flex-nowrap items-center gap-2 sm:gap-3">
+        <div className="flex min-h-[40px] min-w-0 flex-1 flex-nowrap items-center gap-2 overflow-x-auto pb-0.5 [-webkit-overflow-scrolling:touch] sm:min-h-0">
+          {TAB_KEYS.map((key) => {
+            const active = activeKey === key;
+            return (
+              <button
+                key={key}
+                type="button"
+                onClick={() => setActiveKey(key)}
+                className={subMenuPillClass(active)}
+              >
+                <span className={active ? 'text-white' : ''}>{TAB_LABELS[key]}</span>
+              </button>
+            );
+          })}
+        </div>
+        <div className="flex shrink-0 items-center gap-2 border-l border-gray-200/80 pl-2 sm:pl-3">
+          <span
+            className={`${subMenuPillClass(false)} max-w-[36vw] cursor-default sm:max-w-[220px]`}
+            title={displayTitle}
+          >
+            <span className="truncate">{displayTitle}</span>
+          </span>
+          <button type="button" className={subMenuPillClass(false)} onClick={onBack}>
+            <ArrowLeft className="h-4 w-4 shrink-0 opacity-90" aria-hidden />
+            <span>返回</span>
+          </button>
+        </div>
+      </div>
+    ),
+    [activeKey, displayTitle, onBack],
+  );
+
+  return (
+    <div className="space-y-4">
+      {/* 同时挂载各面板、仅显示当前 Tab,切换不丢表单状态;Tab 与筛选同卡由各子页渲染 */}
+      <div className="min-h-[200px] w-full">
+        <div className={activeKey === 'inventory' ? 'block' : 'hidden'} aria-hidden={activeKey !== 'inventory'}>
+          <MaterialInformationPage
+            embedded
+            embeddedTabBar={embeddedTabBar}
+            fixedCabinetId={cabinetId}
+            fixedCabinetName={titleName || undefined}
+          />
+        </div>
+        <div className={activeKey === 'loan' ? 'block' : 'hidden'} aria-hidden={activeKey !== 'loan'}>
+          <MaterialLoanPage embedded embeddedTabBar={embeddedTabBar} fixedCabinetId={cabinetId} />
+        </div>
+        <div className={activeKey === 'plan' ? 'block' : 'hidden'} aria-hidden={activeKey !== 'plan'}>
+          <MaterialInspectionPlanPage embedded embeddedTabBar={embeddedTabBar} fixedCabinetId={cabinetId} />
+        </div>
+        <div className={activeKey === 'record' ? 'block' : 'hidden'} aria-hidden={activeKey !== 'record'}>
+          <MaterialInspectionRecordPage embedded embeddedTabBar={embeddedTabBar} fixedCabinetId={cabinetId} />
+        </div>
+        <div className={activeKey === 'replace' ? 'block' : 'hidden'} aria-hidden={activeKey !== 'replace'}>
+          <MaterialReplacementRecordPage embedded embeddedTabBar={embeddedTabBar} fixedCabinetId={cabinetId} />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 561 - 0
src/components/material/pages/MaterialLockersPage.tsx

@@ -0,0 +1,561 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Table, Button, Form, Input, Modal, Image, Row, Col, Tree, Select, Tag } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import type { DataNode } from 'antd/es/tree';
+import { Plus } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialLockerApi, CabinetVO } from '../../../api/material/lockers';
+import { workstationApi } from '../../../api/workstation';
+import { handleTree } from '../../../utils/tree';
+import UploadImg from '../../lockCabinet/UploadImg';
+import MaterialLockerDetailPage from './MaterialLockerDetailPage';
+import { pickRecords, pickTotal, buildPageParams, fetchAllWorkstations } from '../materialListUtils';
+import {
+  MaterialPageRoot,
+  MaterialToolbarCard,
+  MaterialTableCard,
+  MaterialPaginationBar,
+  MaterialEditButton,
+  MaterialDeleteButton,
+  MaterialViewButton,
+  materialConfirmDelete,
+  getMaterialFormModalProps,
+  materialFormLayout,
+} from '../MaterialPageLayout';
+
+/** 物资柜状态:0 正常 1 借出 2 异常 */
+const CABINET_STATUS_OPTIONS = [
+  { value: 0, label: '正常' },
+  { value: 1, label: '借出' },
+  { value: 2, label: '异常' },
+];
+
+const CABINET_STATUS_LABEL: Record<number, string> = {
+  0: '正常',
+  1: '借出',
+  2: '异常',
+};
+
+/** 异常类型:0 正常 1 物资异常 2 物资柜异常 3 错还柜子 4 物资借出 5 超时未关门 */
+const EXCEPTION_TYPE_LABEL: Record<number, string> = {
+  0: '正常',
+  1: '物资异常',
+  2: '物资柜异常',
+  3: '错还柜子',
+  4: '物资借出',
+  5: '超时未关门',
+};
+
+function firstDefined(row: Record<string, unknown>, keys: string[]) {
+  for (const k of keys) {
+    const v = row[k];
+    if (v !== undefined && v !== null && v !== '') return v;
+  }
+  return undefined;
+}
+
+function toFiniteInt(v: unknown): number | undefined {
+  if (v === undefined || v === null || v === '') return undefined;
+  if (typeof v === 'string' && v.trim() === '') return undefined;
+  const n = Number(v);
+  return Number.isFinite(n) ? Math.trunc(n) : undefined;
+}
+
+function getCabinetStatusValue(row: Record<string, unknown>): number | undefined {
+  const raw = firstDefined(row, ['status', 'cabinetStatus', 'runStatus', 'cabinetRunStatus']);
+  if (raw === '正常') return 0;
+  if (raw === '借出') return 1;
+  if (raw === '异常') return 2;
+  return toFiniteInt(raw);
+}
+
+function renderCabinetStatusTag(row: Record<string, unknown>) {
+  const raw = firstDefined(row, ['status', 'cabinetStatus', 'runStatus', 'cabinetRunStatus']);
+  const code = getCabinetStatusValue(row);
+  if (code !== undefined && CABINET_STATUS_LABEL[code] !== undefined) {
+    const color = code === 0 ? 'blue' : code === 1 ? 'processing' : 'error';
+    return <Tag color={color}>{CABINET_STATUS_LABEL[code]}</Tag>;
+  }
+  if (typeof raw === 'string' && raw.trim() !== '') {
+    return <Tag color="default">{raw}</Tag>;
+  }
+  return <Tag color="default">—</Tag>;
+}
+
+function getExceptionTypeCode(row: Record<string, unknown>): number | undefined {
+  return toFiniteInt(
+    firstDefined(row, [
+      'exReason',
+      'exceptionType',
+      'abnormalType',
+      'exceptionTypeCode',
+      'abnormalTypeCode',
+      'doorExceptionType',
+      'exceptionKind',
+    ]),
+  );
+}
+
+function exceptionTypeTagColor(code: number): string {
+  if (code === 0) return 'blue';
+  if (code === 1 || code === 2) return 'error';
+  if (code === 3 || code === 5) return 'warning';
+  if (code === 4) return 'processing';
+  return 'default';
+}
+
+function renderExceptionTypeTag(row: Record<string, unknown>) {
+  const code = getExceptionTypeCode(row);
+  if (code !== undefined && EXCEPTION_TYPE_LABEL[code] !== undefined) {
+    return (
+      <Tag color={exceptionTypeTagColor(code)}>{EXCEPTION_TYPE_LABEL[code]}</Tag>
+    );
+  }
+  const ex = firstDefined(row, [
+    'exceptionTypeName',
+    'exceptionName',
+    'exceptionDesc',
+    'abnormalTypeName',
+    'exceptionMsg',
+    'errorMessage',
+    'abnormalReason',
+  ]);
+  if (ex !== undefined && ex !== null && ex !== '') {
+    return <Tag color="default">{String(ex)}</Tag>;
+  }
+  return <Tag color="default">—</Tag>;
+}
+
+function filterWorkstationTree(nodes: any[], kw: string): any[] {
+  const q = kw.trim().toLowerCase();
+  if (!q) return nodes;
+  const match = (n: any) => {
+    const code = String(n.workstationCode ?? '').toLowerCase();
+    const name = String(n.workstationName ?? '').toLowerCase();
+    return code.includes(q) || name.includes(q);
+  };
+  return nodes
+    .map((n) => {
+      const children = n.children?.length ? filterWorkstationTree(n.children, kw) : [];
+      if (match(n) || children.length) return { ...n, children: children.length ? children : undefined };
+      return null;
+    })
+    .filter(Boolean) as any[];
+}
+
+function toAntdTreeData(nodes: any[]): DataNode[] {
+  return nodes.map((n) => ({
+    key: String(n.id),
+    title: (n.workstationCode || n.workstationName || String(n.id)) as React.ReactNode,
+    children: n.children?.length ? toAntdTreeData(n.children) : undefined,
+  }));
+}
+
+/** 区域树打平为下拉选项(value 为岗位/区域 id) */
+function flattenWorkstationOptions(nodes: any[]): { label: string; value: number }[] {
+  const out: { label: string; value: number }[] = [];
+  const walk = (arr: any[]) => {
+    for (const n of arr) {
+      const id = Number(n.id ?? n.workstationId);
+      if (Number.isFinite(id) && id > 0) {
+        const code = n.workstationCode ? String(n.workstationCode) : '';
+        const name = n.workstationName ? String(n.workstationName) : '';
+        const label = [code, name].filter(Boolean).join(' / ') || String(id);
+        out.push({ value: id, label });
+      }
+      if (n.children?.length) walk(n.children);
+    }
+  };
+  walk(nodes);
+  return out;
+}
+
+export default function MaterialLockersPage() {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  /** 与点位管理列表一致:固定每页条数,分页条不提供「每页几条 / 前往页」 */
+  const pageSize = 10;
+  const [draftQuery, setDraftQuery] = useState({ cabinetName: '', status: undefined as number | undefined });
+  const [appliedQuery, setAppliedQuery] = useState({ cabinetName: '', status: undefined as number | undefined });
+  const [modalOpen, setModalOpen] = useState(false);
+  const [editing, setEditing] = useState<CabinetVO | null>(null);
+  const [form] = Form.useForm();
+
+  const [areaSearch, setAreaSearch] = useState('');
+  const [areaTreeRaw, setAreaTreeRaw] = useState<any[]>([]);
+  const [selectedAreaKey, setSelectedAreaKey] = useState<string>('all');
+  /** 非空时展示物资柜详情页(由列表「查看」进入) */
+  const [lockerDetail, setLockerDetail] = useState<{ id: number; name: string } | null>(null);
+
+  const workstationIdFilter = selectedAreaKey === 'all' ? undefined : Number(selectedAreaKey);
+
+  const filteredAreaTree = useMemo(() => filterWorkstationTree(areaTreeRaw, areaSearch), [areaTreeRaw, areaSearch]);
+
+  const areaTreeData: DataNode[] = useMemo(
+    () => [{ key: 'all', title: '全部区域' }, ...toAntdTreeData(filteredAreaTree)],
+    [filteredAreaTree],
+  );
+
+  const workstationOptions = useMemo(() => {
+    const base = flattenWorkstationOptions(areaTreeRaw);
+    if (!editing) return base;
+    const wid = Number(editing.workstationId);
+    if (Number.isFinite(wid) && wid > 0 && !base.some((o) => o.value === wid)) {
+      const label =
+        firstDefined(editing as unknown as Record<string, unknown>, ['workstationCode', 'workstationName']) ??
+        `区域 #${wid}`;
+      return [{ value: wid, label: String(label) }, ...base];
+    }
+    return base;
+  }, [areaTreeRaw, editing]);
+
+  const loadAreaTree = useCallback(async () => {
+    try {
+      const flat = await fetchAllWorkstations(workstationApi.listMarsDept);
+      const normalized = flat.map((w: any) => {
+        const id = Number(w.workstationId ?? w.id);
+        return {
+          ...w,
+          id: Number.isFinite(id) ? id : 0,
+          parentId: w.parentId != null && w.parentId !== '' ? Number(w.parentId) : 0,
+        };
+      });
+      const tree = handleTree(normalized, 'id', 'parentId', 'children');
+      setAreaTreeRaw(tree);
+    } catch (e: any) {
+      toast.error(e?.message || '加载区域失败');
+      setAreaTreeRaw([]);
+    }
+  }, []);
+
+  useEffect(() => {
+    loadAreaTree();
+  }, [loadAreaTree]);
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await materialLockerApi.listMaterialsCabinet({
+        ...buildPageParams(pageNo, pageSize),
+        cabinetName: appliedQuery.cabinetName || undefined,
+        status: appliedQuery.status,
+        workstationId: workstationIdFilter,
+      });
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || '加载物资柜失败');
+      setList([]);
+    } finally {
+      setLoading(false);
+    }
+  }, [pageNo, appliedQuery.cabinetName, appliedQuery.status, workstationIdFilter]);
+
+  useEffect(() => {
+    fetchList();
+  }, [fetchList]);
+
+  const handleQuery = () => {
+    setAppliedQuery({ ...draftQuery });
+    setPageNo(1);
+  };
+
+  const resetQuery = () => {
+    const empty = { cabinetName: '', status: undefined as number | undefined };
+    setDraftQuery(empty);
+    setAppliedQuery(empty);
+    setPageNo(1);
+  };
+
+  const openCreate = () => {
+    setEditing(null);
+    form.resetFields();
+    form.setFieldsValue({
+      cabinetName: '',
+      remark: '',
+      cabinetPicture: '',
+      workstationId:
+        workstationIdFilter != null && Number.isFinite(workstationIdFilter) && workstationIdFilter > 0
+          ? workstationIdFilter
+          : undefined,
+    });
+    setModalOpen(true);
+  };
+
+  const openEdit = (row: any) => {
+    setEditing(row);
+    form.setFieldsValue({
+      cabinetName: row.cabinetName,
+      remark: row.remark ?? '',
+      cabinetPicture: row.cabinetPicture ?? '',
+      workstationId: row.workstationId != null && row.workstationId !== '' ? Number(row.workstationId) : undefined,
+    });
+    setModalOpen(true);
+  };
+
+  const openLockerDetail = (row: any) => {
+    const id = row.cabinetId ?? row.id;
+    if (id == null) return;
+    setLockerDetail({
+      id: Number(id),
+      name: String(row.cabinetName ?? ''),
+    });
+  };
+
+  const handleSubmit = async () => {
+    try {
+      const v = await form.validateFields();
+      setLoading(true);
+      const editId = editing ? (editing.cabinetId ?? editing.id) : undefined;
+      if (editId != null && editing) {
+        await materialLockerApi.updateMaterialsCabinet({
+          ...editing,
+          cabinetId: editId,
+          cabinetName: v.cabinetName,
+          cabinetCode: editing.cabinetCode || v.cabinetName,
+          cabinetType: editing.cabinetType || 'default',
+          remark: v.remark,
+          cabinetPicture: v.cabinetPicture || '',
+          workstationId: v.workstationId,
+        } as CabinetVO);
+        toast.success(t('common.updateSuccess'));
+      } else {
+        await materialLockerApi.addMaterialsCabinet({
+          cabinetName: v.cabinetName,
+          cabinetCode: v.cabinetName,
+          cabinetType: 'default',
+          status: 0,
+          remark: v.remark,
+          cabinetPicture: v.cabinetPicture || undefined,
+          workstationId: v.workstationId,
+        } as CabinetVO);
+        toast.success(t('common.addSuccess'));
+      }
+      setModalOpen(false);
+      fetchList();
+    } catch (e: any) {
+      if (e?.errorFields) return;
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleDelete = (row: any) => {
+    const id = row.cabinetId ?? row.id;
+    if (!id) return;
+    materialConfirmDelete(t, {
+      count: 1,
+      onOk: async () => {
+        await materialLockerApi.deleteMaterialsCabinet(id);
+        toast.success(t('common.deleteSuccess'));
+        fetchList();
+      },
+    });
+  };
+
+  const areaCol = (row: any) =>
+    firstDefined(row, ['workstationCode', 'workstationName', 'areaCode', 'areaName']) ?? '—';
+
+  const columns: ColumnsType<any> = [
+    { title: '物资柜编号', dataIndex: 'cabinetId', width: 110, align: 'center', render: (v, row) => v ?? row.id ?? '—' },
+    { title: '物资柜名称', dataIndex: 'cabinetName', ellipsis: true, align: 'center' },
+    {
+      title: '所属区域',
+      key: 'area',
+      ellipsis: true,
+      align: 'center',
+      render: (_, row) => areaCol(row),
+    },
+    {
+      title: '物资柜图片',
+      dataIndex: 'cabinetPicture',
+      width: 100,
+      align: 'center',
+      render: (url: string) =>
+        url ? <Image src={url} width={50} height={50} style={{ objectFit: 'cover' }} /> : '—',
+    },
+    {
+      title: '物资柜状态',
+      key: 'cabinetStatus',
+      width: 100,
+      align: 'center',
+      render: (_, row) => renderCabinetStatusTag(row),
+    },
+    {
+      title: '异常类型',
+      key: 'exceptionType',
+      width: 140,
+      align: 'center',
+      render: (_, row) => renderExceptionTypeTag(row),
+    },
+    {
+      title: '查看',
+      key: 'view',
+      width: 100,
+      align: 'center',
+      fixed: 'right',
+      render: (_, row) => <MaterialViewButton label="查看" onClick={() => openLockerDetail(row)} />,
+    },
+    {
+      title: t('table.operation'),
+      width: 160,
+      align: 'center',
+      fixed: 'right',
+      render: (_, row) => (
+        <div className="flex items-center gap-2 justify-center">
+          <MaterialEditButton label={t('common.edit')} onClick={() => openEdit(row)} />
+          <MaterialDeleteButton label={t('common.delete')} onClick={() => handleDelete(row)} />
+        </div>
+      ),
+    },
+  ];
+
+  const listBody = (
+    <div className="flex gap-4 items-start">
+      <div className="w-[260px] shrink-0 bg-white rounded-xl border border-gray-200/50 shadow-sm p-4 flex flex-col gap-3 min-h-[420px]">
+        <Input.Search allowClear placeholder="请输入区域名称" value={areaSearch} onChange={(e) => setAreaSearch(e.target.value)} />
+        <div className="flex-1 overflow-auto border border-gray-100 rounded-lg p-2">
+          <Tree
+            blockNode
+            showLine
+            selectedKeys={[selectedAreaKey]}
+            treeData={areaTreeData}
+            onSelect={(keys) => {
+              if (keys.length && typeof keys[0] === 'string') {
+                setSelectedAreaKey(keys[0]);
+                setPageNo(1);
+              }
+            }}
+          />
+        </div>
+      </div>
+      <div className="flex-1 min-w-0 space-y-4">
+        <MaterialToolbarCard
+          filterRow={
+            <div className="flex items-center gap-3 flex-wrap">
+              <div className="flex items-center gap-2">
+                <span className="text-sm font-medium text-gray-700 whitespace-nowrap">物资柜名称:</span>
+                <Input
+                  allowClear
+                  placeholder="请输入物资柜名称"
+                  className="w-[180px]"
+                  value={draftQuery.cabinetName}
+                  onChange={(e) => setDraftQuery((q) => ({ ...q, cabinetName: e.target.value }))}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+              <div className="flex items-center gap-2">
+                <span className="text-sm font-medium text-gray-700 whitespace-nowrap">物资柜状态:</span>
+                <Select
+                  allowClear
+                  placeholder="请选择物资柜状态"
+                  className="w-[180px]"
+                  options={CABINET_STATUS_OPTIONS}
+                  value={draftQuery.status}
+                  onChange={(v) => setDraftQuery((q) => ({ ...q, status: v ?? undefined }))}
+                />
+              </div>
+            </div>
+          }
+          onSearch={handleQuery}
+          onReset={resetQuery}
+          toolbarRight={
+            <Button type="primary" icon={<Plus />} onClick={openCreate}>
+              {t('common.addNew')}
+            </Button>
+          }
+        />
+        <MaterialTableCard>
+          <Table
+            rowKey={(r) => r.cabinetId ?? r.id}
+            loading={loading}
+            columns={columns}
+            dataSource={list}
+            pagination={false}
+            scroll={{ x: 'max-content' }}
+            rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+          />
+        </MaterialTableCard>
+        <MaterialPaginationBar
+          total={total}
+          current={pageNo}
+          pageSize={pageSize}
+          onPageChange={setPageNo}
+          loading={loading}
+          listLength={list.length}
+        />
+      </div>
+    </div>
+  );
+
+  if (lockerDetail) {
+    return (
+      <MaterialPageRoot>
+        <MaterialLockerDetailPage
+          cabinetId={lockerDetail.id}
+          cabinetName={lockerDetail.name}
+          onBack={() => setLockerDetail(null)}
+        />
+      </MaterialPageRoot>
+    );
+  }
+
+  return (
+    <MaterialPageRoot>
+      {listBody}
+
+      <Modal
+        title={editing ? '编辑物资柜信息' : '新增物资柜信息'}
+        open={modalOpen}
+        onOk={handleSubmit}
+        onCancel={() => setModalOpen(false)}
+        confirmLoading={loading}
+        {...getMaterialFormModalProps(t, 560)}
+      >
+        <Form form={form} {...materialFormLayout}>
+          <Row gutter={16}>
+            <Col span={24}>
+              <Form.Item
+                name="cabinetName"
+                label="物资柜名称"
+                rules={[{ required: true, message: '请输入物资柜名称' }]}
+              >
+                <Input allowClear placeholder="请输入物资柜名称" />
+              </Form.Item>
+            </Col>
+            <Col span={24}>
+              <Form.Item
+                name="workstationId"
+                label="所属区域"
+                rules={[{ required: true, message: '请选择所属区域' }]}
+              >
+                <Select
+                  allowClear
+                  showSearch
+                  optionFilterProp="label"
+                  placeholder="选择所属区域"
+                  options={workstationOptions}
+                />
+              </Form.Item>
+            </Col>
+            <Col span={24}>
+              <Form.Item name="cabinetPicture" label="物资柜图片">
+                <UploadImg width="120px" height="120px" />
+              </Form.Item>
+            </Col>
+            <Col span={24}>
+              <Form.Item name="remark" label="备注">
+                <Input.TextArea allowClear rows={4} placeholder="请输入内容" />
+              </Form.Item>
+            </Col>
+          </Row>
+        </Form>
+      </Modal>
+    </MaterialPageRoot>
+  );
+}

+ 507 - 0
src/components/material/pages/MaterialReplacementRecordPage.tsx

@@ -0,0 +1,507 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import type { Dayjs } from 'dayjs';
+import { Table, Button, Row, Col, Select, Input, TreeSelect, DatePicker, Space, Image } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Search, RefreshCw } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialReplaceApi } from '../../../api/material/replace';
+import { materialLockerApi } from '../../../api/material/lockers';
+import { materialTypeApi } from '../../../api/material/type';
+import {
+  pickRecords,
+  pickTotal,
+  buildPageParams,
+  fetchAllMaterialTypes,
+  buildMaterialTypeTreeData,
+} from '../materialListUtils';
+import { MATERIAL_REPLACEMENT_RECORD_PRESET_KEY } from '../materialPvNav';
+import { MaterialPageRoot, MaterialTableCard, MaterialPaginationBar } from '../MaterialPageLayout';
+
+const MATERIALS_CABINET_LIST_PARAMS = buildPageParams(1, -1);
+
+function firstDefined(row: Record<string, any>, keys: string[]) {
+  for (const k of keys) {
+    const v = row[k];
+    if (v !== undefined && v !== null && v !== '') return v;
+  }
+  return undefined;
+}
+
+function mapCabinetListToFilterOptions(rows: any[]): { value: number; label: string }[] {
+  const mapped = rows
+    .map((c: any) => {
+      const rawId = c.id ?? c.cabinetId;
+      const value = Number(rawId);
+      const label = String(c.cabinetName ?? c.name ?? '').trim();
+      return { value, label: label || (Number.isFinite(value) ? `#${value}` : '') };
+    })
+    .filter((o) => Number.isFinite(o.value) && o.label);
+  const hasZero = mapped.some((o) => o.value === 0);
+  return hasZero ? mapped : [...mapped, { value: 0, label: '空' }];
+}
+
+function digitsOnly(s: string) {
+  return s.replace(/\D/g, '');
+}
+
+type RecordDraft = {
+  cabinetId?: number;
+  materialsTypeId?: number;
+  oldMaterialsId: string;
+  oldMaterialsRfid: string;
+  newMaterialsId: string;
+  newMaterialsRfid: string;
+  changeUserName: string;
+  changeTimeRange: [Dayjs, Dayjs] | null;
+};
+
+function emptyDraft(fixedCabinetId?: number): RecordDraft {
+  return {
+    oldMaterialsId: '',
+    oldMaterialsRfid: '',
+    newMaterialsId: '',
+    newMaterialsRfid: '',
+    changeUserName: '',
+    changeTimeRange: null,
+    ...(fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId))
+      ? { cabinetId: Number(fixedCabinetId) }
+      : {}),
+  };
+}
+
+export interface MaterialReplacementRecordPageProps {
+  fixedCabinetId?: number;
+  embedded?: boolean;
+  embeddedTabBar?: React.ReactNode;
+}
+
+export default function MaterialReplacementRecordPage({
+  fixedCabinetId,
+  embedded,
+  embeddedTabBar,
+}: MaterialReplacementRecordPageProps = {}) {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize, setPageSize] = useState(10);
+  const [draft, setDraft] = useState<RecordDraft>(() => emptyDraft(fixedCabinetId));
+  const [applied, setApplied] = useState<RecordDraft>(() => emptyDraft(fixedCabinetId));
+  const [appliedCheckRecordId, setAppliedCheckRecordId] = useState<number | undefined>();
+  const [cabinetOpts, setCabinetOpts] = useState<{ value: number; label: string }[]>([]);
+  const [materialTypeRows, setMaterialTypeRows] = useState<any[]>([]);
+
+  const typeTreeData = useMemo(() => buildMaterialTypeTreeData(materialTypeRows), [materialTypeRows]);
+
+  const filterLabelCls =
+    'shrink-0 text-right text-sm font-medium text-gray-600 whitespace-nowrap w-[100px] sm:w-[108px]';
+
+  const cabinetLocked = fixedCabinetId != null && Number.isFinite(Number(fixedCabinetId));
+
+  useEffect(() => {
+    if (embedded) return;
+    try {
+      const raw = sessionStorage.getItem(MATERIAL_REPLACEMENT_RECORD_PRESET_KEY);
+      if (!raw) return;
+      const p = JSON.parse(raw);
+      sessionStorage.removeItem(MATERIAL_REPLACEMENT_RECORD_PRESET_KEY);
+      const rid = p?.recordId ?? p?.checkRecordId;
+      if (rid != null && Number.isFinite(Number(rid))) {
+        setAppliedCheckRecordId(Number(rid));
+        setPageNo(1);
+      }
+    } catch {
+      sessionStorage.removeItem(MATERIAL_REPLACEMENT_RECORD_PRESET_KEY);
+    }
+  }, [embedded]);
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const res = await materialLockerApi.listMaterialsCabinet(MATERIALS_CABINET_LIST_PARAMS);
+        setCabinetOpts(mapCabinetListToFilterOptions(pickRecords(res)));
+      } catch {
+        try {
+          const res2 = await materialLockerApi.listMaterialsCabinet(buildPageParams(1, 500));
+          setCabinetOpts(mapCabinetListToFilterOptions(pickRecords(res2)));
+        } catch {
+          setCabinetOpts([]);
+        }
+      }
+    })();
+  }, []);
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const typeRows = await fetchAllMaterialTypes(materialTypeApi.listType);
+        const seen = new Set<number>();
+        const unique: any[] = [];
+        for (const x of typeRows) {
+          const id = Number(x.id ?? x.materialsTypeId);
+          if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
+          seen.add(id);
+          unique.push(x);
+        }
+        setMaterialTypeRows(unique);
+      } catch {
+        setMaterialTypeRows([]);
+      }
+    })();
+  }, []);
+
+  useEffect(() => {
+    if (cabinetLocked) {
+      const id = Number(fixedCabinetId);
+      setDraft((d) => ({ ...d, cabinetId: id }));
+      setApplied((a) => ({ ...a, cabinetId: id }));
+    }
+  }, [fixedCabinetId, cabinetLocked]);
+
+  const buildListParams = useCallback(() => {
+    const q: Record<string, any> = {
+      ...buildPageParams(pageNo, pageSize),
+    };
+    const cab =
+      cabinetLocked && fixedCabinetId != null
+        ? Number(fixedCabinetId)
+        : applied.cabinetId != null && Number.isFinite(applied.cabinetId) && Number(applied.cabinetId) > 0
+          ? Number(applied.cabinetId)
+          : undefined;
+    if (cab != null) q.cabinetId = cab;
+    if (applied.materialsTypeId != null) q.materialsTypeId = applied.materialsTypeId;
+    const oldId = digitsOnly(applied.oldMaterialsId);
+    if (oldId) q.oldMaterialsId = Number(oldId);
+    if (applied.oldMaterialsRfid?.trim()) q.oldMaterialsRfid = applied.oldMaterialsRfid.trim();
+    const newId = digitsOnly(applied.newMaterialsId);
+    if (newId) q.newMaterialsId = Number(newId);
+    if (applied.newMaterialsRfid?.trim()) q.newMaterialsRfid = applied.newMaterialsRfid.trim();
+    if (applied.changeUserName?.trim()) q.changeUserName = applied.changeUserName.trim();
+    if (applied.changeTimeRange?.[0] && applied.changeTimeRange[1]) {
+      q.startTime = applied.changeTimeRange[0].format('YYYY-MM-DD HH:mm:ss');
+      q.endTime = applied.changeTimeRange[1].format('YYYY-MM-DD HH:mm:ss');
+    }
+    if (appliedCheckRecordId != null && Number.isFinite(appliedCheckRecordId)) {
+      q.recordId = appliedCheckRecordId;
+      q.checkRecordId = appliedCheckRecordId;
+      q.materialsCheckRecordId = appliedCheckRecordId;
+    }
+    return q;
+  }, [pageNo, pageSize, applied, cabinetLocked, fixedCabinetId, appliedCheckRecordId]);
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await materialReplaceApi.listChangeRecord(buildListParams());
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  }, [buildListParams, t]);
+
+  useEffect(() => {
+    void fetchList();
+  }, [fetchList]);
+
+  const setDraftField = <K extends keyof RecordDraft>(key: K, value: RecordDraft[K]) => {
+    setDraft((d) => ({ ...d, [key]: value }));
+  };
+
+  const handleQuery = () => {
+    setApplied({ ...draft });
+    setPageNo(1);
+  };
+
+  const resetQuery = () => {
+    const e = emptyDraft(fixedCabinetId);
+    setDraft(e);
+    setApplied(e);
+    setAppliedCheckRecordId(undefined);
+    setPageNo(1);
+  };
+
+  const columns: ColumnsType<any> = useMemo(
+    () => [
+      {
+        title: '物资柜',
+        width: 120,
+        align: 'center',
+        render: (_, row) => String(firstDefined(row, ['cabinetName', 'cabinet_name', 'materialsCabinetName']) ?? '—'),
+      },
+      {
+        title: '物资类型',
+        width: 120,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) => String(firstDefined(row, ['materialsTypeName', 'typeName']) ?? '—'),
+      },
+      {
+        title: '物资图片',
+        width: 88,
+        align: 'center',
+        render: (_, row) => {
+          const src = firstDefined(row, ['materialsTypePicture', 'materialsPicture', 'picture', 'imageUrl']);
+          if (!src) return <span className="text-gray-400">—</span>;
+          return (
+            <div className="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 shadow-sm">
+              <Image
+                src={String(src)}
+                width={44}
+                height={44}
+                className="!rounded object-cover"
+                preview={{ mask: '预览' }}
+              />
+            </div>
+          );
+        },
+      },
+      {
+        title: '原物资编号',
+        width: 100,
+        align: 'center',
+        render: (_, row) => String(firstDefined(row, ['oldMaterialsId', 'old_materials_id']) ?? '—'),
+      },
+      {
+        title: '原物资名称',
+        width: 120,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) => String(firstDefined(row, ['oldMaterialsName', 'old_materials_name']) ?? '—'),
+      },
+      {
+        title: '原RFID',
+        width: 120,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) => String(firstDefined(row, ['oldMaterialsRfid', 'oldMaterialsRFID', 'oldRfid']) ?? '—'),
+      },
+      {
+        title: '新物资编号',
+        width: 100,
+        align: 'center',
+        render: (_, row) => String(firstDefined(row, ['newMaterialsId', 'new_materials_id']) ?? '—'),
+      },
+      {
+        title: '新物资名称',
+        width: 120,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) => String(firstDefined(row, ['newMaterialsName', 'new_materials_name']) ?? '—'),
+      },
+      {
+        title: '新RFID',
+        width: 120,
+        align: 'center',
+        ellipsis: true,
+        render: (_, row) => String(firstDefined(row, ['newMaterialsRfid', 'newMaterialsRFID', 'newRfid']) ?? '—'),
+      },
+      {
+        title: '更换人',
+        width: 100,
+        align: 'center',
+        render: (_, row) =>
+          String(firstDefined(row, ['changeUserName', 'change_user_name', 'changeUser', 'operatorName']) ?? '—'),
+      },
+      {
+        title: '更换时间',
+        width: 172,
+        align: 'center',
+        render: (_, row) =>
+          String(firstDefined(row, ['changeDate', 'changeTime', 'change_time', 'createTime']) ?? '—'),
+      },
+    ],
+    [],
+  );
+
+  const filterCard = (
+    <div className="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">
+      {embeddedTabBar ? (
+        <div className="border-b border-gray-100/80 px-2 pb-3 pt-4 sm:px-3 sm:pb-4 sm:pt-5">{embeddedTabBar}</div>
+      ) : null}
+      <div className="box-border w-full min-w-0 px-5" style={{ paddingTop: 28, paddingBottom: 20 }}>
+        <div className="w-full min-w-0 space-y-4">
+          <Row gutter={[16, 16]}>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资柜</span>
+                <Select
+                  allowClear={!cabinetLocked}
+                  disabled={cabinetLocked}
+                  placeholder="请选择物资柜"
+                  className="min-w-0 flex-1"
+                  options={cabinetOpts}
+                  value={draft.cabinetId}
+                  onChange={(v) => setDraftField('cabinetId', v ?? undefined)}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>物资类型</span>
+                <TreeSelect
+                  allowClear
+                  showSearch
+                  treeDefaultExpandAll
+                  treeLine={{ showLeafIcon: false }}
+                  placeholder="请选择物资类型"
+                  className="min-w-0 flex-1"
+                  treeData={typeTreeData}
+                  treeNodeFilterProp="title"
+                  value={draft.materialsTypeId}
+                  onChange={(v) =>
+                    setDraftField('materialsTypeId', v != null && v !== '' ? Number(v) : undefined)
+                  }
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>原物资编号</span>
+                <Input
+                  allowClear
+                  placeholder="请输入原物资编号(数字格式)"
+                  className="min-w-0 flex-1"
+                  value={draft.oldMaterialsId}
+                  onChange={(e) => setDraftField('oldMaterialsId', digitsOnly(e.target.value))}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>原RFID</span>
+                <Input
+                  allowClear
+                  placeholder="请输入原RFID"
+                  className="min-w-0 flex-1"
+                  value={draft.oldMaterialsRfid}
+                  onChange={(e) => setDraftField('oldMaterialsRfid', e.target.value)}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+          </Row>
+          <Row gutter={[16, 16]}>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>新物资编号</span>
+                <Input
+                  allowClear
+                  placeholder="请输入新物资编号(数字格式)"
+                  className="min-w-0 flex-1"
+                  value={draft.newMaterialsId}
+                  onChange={(e) => setDraftField('newMaterialsId', digitsOnly(e.target.value))}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>新RFID</span>
+                <Input
+                  allowClear
+                  placeholder="请输入新RFID"
+                  className="min-w-0 flex-1"
+                  value={draft.newMaterialsRfid}
+                  onChange={(e) => setDraftField('newMaterialsRfid', e.target.value)}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={6}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>更换人</span>
+                <Input
+                  allowClear
+                  placeholder="请输入更换人"
+                  className="min-w-0 flex-1"
+                  value={draft.changeUserName}
+                  onChange={(e) => setDraftField('changeUserName', e.target.value)}
+                  onPressEnter={handleQuery}
+                />
+              </div>
+            </Col>
+            <Col xs={24} sm={12} lg={9}>
+              <div className="flex min-w-0 items-center gap-2.5">
+                <span className={filterLabelCls}>更换时间</span>
+                <DatePicker.RangePicker
+                  showTime
+                  className="min-w-0 flex-1 [&_.ant-picker-input]:min-w-0"
+                  value={draft.changeTimeRange}
+                  onChange={(vals) => {
+                    const a = vals?.[0];
+                    const b = vals?.[1];
+                    setDraftField('changeTimeRange', a && b ? [a, b] : null);
+                  }}
+                  placeholder={['开始日期', '结束日期']}
+                />
+              </div>
+            </Col>
+            <Col flex="auto" className="min-w-0">
+              <div className="ml-[20px] flex min-w-0 flex-wrap items-center">
+                <Space size="small" wrap>
+                  <Button type="primary" icon={<Search className="h-4 w-4" />} onClick={handleQuery}>
+                    {t('common.search')}
+                  </Button>
+                  <Button icon={<RefreshCw className="h-4 w-4" />} onClick={resetQuery}>
+                    {t('common.reset')}
+                  </Button>
+                </Space>
+              </div>
+            </Col>
+          </Row>
+        </div>
+      </div>
+    </div>
+  );
+
+  const tableInner = (
+    <>
+      <MaterialTableCard flush={Boolean(embedded)}>
+        <Table
+          rowKey={(r) => r.changeId ?? r.id ?? `${r.oldMaterialsId}-${r.newMaterialsId}-${r.changeDate}`}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          scroll={{ x: 'max-content' }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        flushTop={Boolean(embedded)}
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        loading={loading}
+        listLength={list.length}
+        onPageSizeChange={(size) => {
+          setPageSize(size);
+          setPageNo(1);
+        }}
+        showQuickJumper
+      />
+    </>
+  );
+
+  const listSection = embedded ? (
+    <div className="rounded-xl border border-gray-200/80 bg-white shadow-sm ring-1 ring-gray-100/80">{tableInner}</div>
+  ) : (
+    tableInner
+  );
+
+  const pageBody = (
+    <>
+      {filterCard}
+      {listSection}
+    </>
+  );
+
+  return embedded ? <div className="space-y-4">{pageBody}</div> : <MaterialPageRoot>{pageBody}</MaterialPageRoot>;
+}

+ 285 - 0
src/components/material/pages/MaterialStandardPage.tsx

@@ -0,0 +1,285 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import dayjs from 'dayjs';
+import { Button, Table, Modal, Form, Input } from 'antd';
+import type { FormProps } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Plus, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialStandardApi, PropertyVO } from '../../../api/material/standard';
+import { pickRecords, pickTotal } from '../materialListUtils';
+import {
+  MaterialPageRoot,
+  MaterialToolbarCard,
+  MaterialTableCard,
+  MaterialPaginationBar,
+  MaterialEditButton,
+  MaterialDeleteButton,
+  materialConfirmDelete,
+  getMaterialFormModalProps,
+} from '../MaterialPageLayout';
+import { setMaterialPvContext, switchMaterialSubMenu } from '../materialPvNav';
+
+/** 物资规格种类新增/编辑弹窗:与业务原型一致(两字段、标签右对齐) */
+const standardPropFormLayout: FormProps = {
+  layout: 'horizontal',
+  colon: false,
+  labelAlign: 'right',
+  labelCol: { flex: '0 0 132px' },
+  wrapperCol: { flex: '1 1 auto' },
+};
+
+function formatDateTimeCell(v: unknown): string {
+  if (v === undefined || v === null || v === '') return '—';
+  const n = typeof v === 'number' ? v : /^\d+$/.test(String(v).trim()) ? Number(String(v).trim()) : NaN;
+  const d = Number.isFinite(n) && n > 1e12 ? dayjs(n) : dayjs(v as string);
+  return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : '—';
+}
+
+export default function MaterialStandardPage() {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize] = useState(10);
+  const [draftName, setDraftName] = useState('');
+  const [appliedName, setAppliedName] = useState('');
+  const [modalOpen, setModalOpen] = useState(false);
+  const [form] = Form.useForm();
+  const [editing, setEditing] = useState<any | null>(null);
+  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await materialStandardApi.PropertyPage({
+        pageNo,
+        pageSize,
+        ...(appliedName.trim() ? { propertyName: appliedName.trim() } : {}),
+      });
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  }, [pageNo, pageSize, appliedName, t]);
+
+  useEffect(() => {
+    fetchList();
+  }, [fetchList]);
+
+  const openPropForm = (row?: any) => {
+    setEditing(row || null);
+    form.resetFields();
+    if (row) {
+      form.setFieldsValue({
+        propertyName: row.propertyName,
+        remark: row.remark,
+      });
+    }
+    setModalOpen(true);
+  };
+
+  const submitProp = async () => {
+    const v = await form.validateFields();
+    setLoading(true);
+    try {
+      if (editing) {
+        await materialStandardApi.updateProperty({
+          ...editing,
+          ...v,
+          propertyId: editing.propertyId ?? editing.id,
+        } as PropertyVO);
+        toast.success(t('common.updateSuccess'));
+      } else {
+        await materialStandardApi.addProperty({
+          propertyName: v.propertyName,
+          propertyCode: v.propertyName,
+          propertyType: 'text',
+          propertyUnit: undefined,
+          status: 0,
+          remark: v.remark,
+        } as PropertyVO);
+        toast.success(t('common.addSuccess'));
+      }
+      setModalOpen(false);
+      fetchList();
+    } catch (e: any) {
+      if (!e?.errorFields) toast.error(e?.message || t('common.operationFailed'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const deleteProp = (row: any) => {
+    const id = row.propertyId ?? row.id;
+    materialConfirmDelete(t, {
+      count: 1,
+      onOk: async () => {
+        await materialStandardApi.deleteProperty(id);
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys((prev) => prev.filter((k) => String(k) !== String(id)));
+        fetchList();
+      },
+    });
+  };
+
+  const batchDelete = () => {
+    if (selectedRowKeys.length === 0) {
+      toast.error(t('common.pleaseSelectDataToDelete'));
+      return;
+    }
+    const ids = selectedRowKeys
+      .map((k) => Number(k))
+      .filter((n) => Number.isFinite(n) && !Number.isNaN(n));
+    if (ids.length === 0) {
+      toast.error(t('common.pleaseSelectDataToDelete'));
+      return;
+    }
+    materialConfirmDelete(t, {
+      count: ids.length,
+      onOk: async () => {
+        await materialStandardApi.deleteProperty(ids.join(','));
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys([]);
+        fetchList();
+      },
+    });
+  };
+
+  const goPropertyValuesPage = (row: any) => {
+    const pid = Number(row.propertyId ?? row.id);
+    if (!Number.isFinite(pid)) {
+      toast.error('无效的种类编号');
+      return;
+    }
+    setMaterialPvContext({
+      propertyId: pid,
+      propertyName: String(row.propertyName ?? ''),
+    });
+    switchMaterialSubMenu('materialManagement', 'materialStandardPropertyValues');
+  };
+
+  const columns: ColumnsType<any> = [
+    { title: '编号', dataIndex: 'id', width: 90, align: 'center' },
+    { title: '物资规格种类', dataIndex: 'propertyName', align: 'center' },
+    { title: '备注', dataIndex: 'remark', ellipsis: true, align: 'center' },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      width: 170,
+      align: 'center',
+      render: (v) => formatDateTimeCell(v),
+    },
+    {
+      title: t('table.operation'),
+      width: 260,
+      align: 'center',
+      fixed: 'right',
+      render: (_, row) => (
+        <div className="flex items-center gap-2 justify-center flex-wrap">
+          <MaterialEditButton label={t('common.edit')} onClick={() => openPropForm(row)} />
+          <MaterialEditButton label="规格设置" onClick={() => goPropertyValuesPage(row)} />
+          <MaterialDeleteButton label={t('common.delete')} onClick={() => deleteProp(row)} />
+        </div>
+      ),
+    },
+  ];
+
+  return (
+    <MaterialPageRoot>
+      <MaterialToolbarCard
+        filterRow={
+          <div className="flex items-center gap-2">
+            <label className="text-sm font-medium text-gray-700 whitespace-nowrap">物资规格种类:</label>
+            <Input
+              allowClear
+              placeholder="请输入物资规格种类"
+              className="w-[180px]"
+              value={draftName}
+              onChange={(e) => setDraftName(e.target.value)}
+              onPressEnter={() => setAppliedName(draftName)}
+            />
+          </div>
+        }
+        onSearch={() => {
+          setAppliedName(draftName);
+          setPageNo(1);
+          setSelectedRowKeys([]);
+        }}
+        onReset={() => {
+          setDraftName('');
+          setAppliedName('');
+          setPageNo(1);
+          setSelectedRowKeys([]);
+        }}
+        toolbarRight={
+          <>
+            <Button type="primary" icon={<Plus />} onClick={() => openPropForm()}>
+              {t('common.addNew')}
+            </Button>
+            <Button
+              danger
+              icon={<Trash2 className="h-4 w-4" />}
+              disabled={selectedRowKeys.length === 0}
+              onClick={batchDelete}
+            >
+              {t('common.batchDelete')}
+            </Button>
+          </>
+        }
+      />
+      <MaterialTableCard>
+        <Table
+          rowKey={(r) => r.propertyId ?? r.id}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          scroll={{ x: 'max-content' }}
+          rowSelection={{
+            selectedRowKeys,
+            onChange: setSelectedRowKeys,
+            preserveSelectedRowKeys: true,
+            getCheckboxProps: (record) => ({ name: record.propertyName }),
+          }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        loading={loading}
+        listLength={list.length}
+      />
+
+      <Modal
+        title={editing ? '修改' : '新增'}
+        open={modalOpen}
+        onOk={submitProp}
+        onCancel={() => setModalOpen(false)}
+        confirmLoading={loading}
+        {...getMaterialFormModalProps(t, 520)}
+        styles={{ body: { paddingTop: 8 } }}
+      >
+        <Form form={form} {...standardPropFormLayout}>
+          <Form.Item
+            name="propertyName"
+            label="物资规格种类"
+            rules={[{ required: true, message: '请输入物资规格种类' }]}
+          >
+            <Input allowClear placeholder="请输入物资规格种类" />
+          </Form.Item>
+          <Form.Item name="remark" label="备注">
+            <Input.TextArea allowClear rows={4} placeholder="请输入内容" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </MaterialPageRoot>
+  );
+}

+ 394 - 0
src/components/material/pages/MaterialStandardPropertyValuesPage.tsx

@@ -0,0 +1,394 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import dayjs from 'dayjs';
+import { Button, Input, Space, Table, Modal, Form } from 'antd';
+import type { FormProps } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Plus, Trash2, ArrowLeft, Search, RefreshCw } from 'lucide-react';
+import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
+import { materialPropertyValueApi, PropertyValueVO } from '../../../api/material/propertyValue';
+import { pickRecords, pickTotal } from '../materialListUtils';
+import {
+  MaterialPageRoot,
+  MaterialToolbarCard,
+  MaterialTableCard,
+  MaterialPaginationBar,
+  materialConfirmDelete,
+  getMaterialFormModalProps,
+} from '../MaterialPageLayout';
+import { readMaterialPvContext, switchMaterialSubMenu } from '../materialPvNav';
+
+function formatDateTimeCell(v: unknown): string {
+  if (v === undefined || v === null || v === '') return '—';
+  const n = typeof v === 'number' ? v : /^\d+$/.test(String(v).trim()) ? Number(String(v).trim()) : NaN;
+  const d = Number.isFinite(n) && n > 1e12 ? dayjs(n) : dayjs(v as string);
+  return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : '—';
+}
+
+/** 与老系统「新增/修改」弹窗一致:标签右对齐、无冒号 */
+const valModalFormLayout: FormProps = {
+  layout: 'horizontal',
+  colon: false,
+  labelAlign: 'right',
+  labelCol: { flex: '0 0 160px' },
+  wrapperCol: { flex: '1 1 auto' },
+};
+
+export default function MaterialStandardPropertyValuesPage() {
+  const { t } = useTranslation();
+  const [ctx] = useState(() => readMaterialPvContext());
+  const [loading, setLoading] = useState(false);
+  const [list, setList] = useState<any[]>([]);
+  const [total, setTotal] = useState(0);
+  const [pageNo, setPageNo] = useState(1);
+  const [pageSize, setPageSize] = useState(10);
+  const [draftSpec, setDraftSpec] = useState('');
+  const [appliedSpec, setAppliedSpec] = useState('');
+  const [refreshTick, setRefreshTick] = useState(0);
+  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
+
+  const [valModalOpen, setValModalOpen] = useState(false);
+  const [valEditing, setValEditing] = useState<any | null>(null);
+  const [valForm] = Form.useForm();
+  const [valSaveLoading, setValSaveLoading] = useState(false);
+
+  const fetchList = useCallback(async () => {
+    const c = readMaterialPvContext();
+    if (!c) {
+      setList([]);
+      setTotal(0);
+      return;
+    }
+    setLoading(true);
+    try {
+      const res = await materialPropertyValueApi.PropertyValuePage({
+        pageNo,
+        pageSize,
+        propertyId: c.propertyId,
+        propertyName: c.propertyName ?? '',
+        valueName: appliedSpec.trim(),
+      });
+      setList(pickRecords(res));
+      setTotal(pickTotal(res));
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+      setList([]);
+      setTotal(0);
+    } finally {
+      setLoading(false);
+    }
+  }, [pageNo, pageSize, appliedSpec, t, refreshTick]);
+
+  useEffect(() => {
+    fetchList();
+  }, [fetchList]);
+
+  const handleSearch = () => {
+    setAppliedSpec(draftSpec);
+    setPageNo(1);
+    setSelectedRowKeys([]);
+    setRefreshTick((n) => n + 1);
+  };
+
+  const handleReset = () => {
+    setDraftSpec('');
+    setAppliedSpec('');
+    setPageNo(1);
+    setSelectedRowKeys([]);
+    setRefreshTick((n) => n + 1);
+  };
+
+  const goBackToStandard = () => {
+    switchMaterialSubMenu('materialManagement', 'materialStandard');
+  };
+
+  const openValModalAdd = () => {
+    const c = readMaterialPvContext();
+    if (!c) {
+      toast.error('缺少规格种类信息,请从物资规格种类进入');
+      return;
+    }
+    setValEditing(null);
+    valForm.resetFields();
+    valForm.setFieldsValue({ valueName: '', remark: '' });
+    setValModalOpen(true);
+  };
+
+  const openValModalEdit = (row: any) => {
+    const c = readMaterialPvContext();
+    if (!c) {
+      toast.error('缺少规格种类信息,请从物资规格种类进入');
+      return;
+    }
+    setValEditing(row);
+    valForm.resetFields();
+    valForm.setFieldsValue({
+      valueName: row.valueName ?? row.propertyValue ?? '',
+      remark: row.remark ?? '',
+    });
+    setValModalOpen(true);
+  };
+
+  const closeValModal = () => {
+    setValModalOpen(false);
+    setValEditing(null);
+    valForm.resetFields();
+  };
+
+  const submitValModal = async () => {
+    const c = readMaterialPvContext();
+    if (!c) {
+      toast.error('缺少规格种类信息');
+      return Promise.reject();
+    }
+    let v: { valueName?: string; remark?: string };
+    try {
+      v = await valForm.validateFields();
+    } catch (e) {
+      return Promise.reject(e);
+    }
+    const spec = String(v.valueName ?? '').trim();
+    if (!spec) {
+      toast.error('请输入物资规格');
+      return Promise.reject();
+    }
+    setValSaveLoading(true);
+    try {
+      if (valEditing) {
+        await materialPropertyValueApi.updatePropertyValue({
+          ...valEditing,
+          propertyValue: spec,
+          valueName: spec,
+          remark: v.remark,
+          propertyId: c.propertyId,
+          propertyName: c.propertyName,
+        } as PropertyValueVO);
+        toast.success(t('common.updateSuccess'));
+      } else {
+        await materialPropertyValueApi.addPropertyValue({
+          propertyId: c.propertyId,
+          propertyName: c.propertyName,
+          propertyValue: spec,
+          valueName: spec,
+          status: 0,
+          remark: v.remark,
+        } as PropertyValueVO);
+        toast.success(t('common.addSuccess'));
+      }
+      closeValModal();
+      setRefreshTick((n) => n + 1);
+    } catch (e: any) {
+      toast.error(e?.message || t('common.operationFailed'));
+      return Promise.reject(e);
+    } finally {
+      setValSaveLoading(false);
+    }
+  };
+
+  const deleteOne = (row: any) => {
+    const id = row.recordId ?? row.id;
+    materialConfirmDelete(t, {
+      count: 1,
+      onOk: async () => {
+        await materialPropertyValueApi.deletePropertyValue(id);
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys((prev) => prev.filter((k) => String(k) !== String(id)));
+        fetchList();
+      },
+    });
+  };
+
+  const batchDelete = () => {
+    if (selectedRowKeys.length === 0) {
+      toast.error(t('common.pleaseSelectDataToDelete'));
+      return;
+    }
+    const ids = selectedRowKeys.map((k) => Number(k)).filter((n) => Number.isFinite(n) && !Number.isNaN(n));
+    if (ids.length === 0) {
+      toast.error(t('common.pleaseSelectDataToDelete'));
+      return;
+    }
+    materialConfirmDelete(t, {
+      count: ids.length,
+      onOk: async () => {
+        for (const id of ids) {
+          await materialPropertyValueApi.deletePropertyValue(id);
+        }
+        toast.success(t('common.deleteSuccess'));
+        setSelectedRowKeys([]);
+        fetchList();
+      },
+    });
+  };
+
+  const displaySpec = (row: any) => row.valueName ?? row.propertyValue ?? '—';
+
+  const columns: ColumnsType<any> = [
+    { title: '编号', dataIndex: 'recordId', width: 90, align: 'center', render: (_v, row) => row.recordId ?? row.id },
+    {
+      title: '物资规格',
+      dataIndex: 'valueName',
+      align: 'center',
+      render: (_v, row) => displaySpec(row),
+    },
+    { title: '备注', dataIndex: 'remark', ellipsis: true, align: 'center' },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      width: 170,
+      align: 'center',
+      render: (v) => formatDateTimeCell(v),
+    },
+    {
+      title: t('table.operation'),
+      width: 140,
+      align: 'center',
+      fixed: 'right',
+      render: (_, row) => (
+        <div className="flex items-center justify-center gap-3">
+          <button type="button" className="text-sm text-blue-600 hover:underline" onClick={() => openValModalEdit(row)}>
+            {t('common.edit')}
+          </button>
+          <button type="button" className="text-sm text-red-600 hover:underline" onClick={() => deleteOne(row)}>
+            {t('common.delete')}
+          </button>
+        </div>
+      ),
+    },
+  ];
+
+  if (!ctx) {
+    return (
+      <MaterialPageRoot>
+        <MaterialTableCard>
+          <div className="flex flex-col items-center justify-center gap-4 py-16 text-gray-600">
+            <p>未找到规格种类上下文,请从「物资规格种类」点击「规格设置」进入。</p>
+            <Button type="primary" className="!bg-orange-500 hover:!bg-orange-600" icon={<ArrowLeft className="h-4 w-4" />} onClick={goBackToStandard}>
+              返回
+            </Button>
+          </div>
+        </MaterialTableCard>
+      </MaterialPageRoot>
+    );
+  }
+
+  return (
+    <MaterialPageRoot>
+      <MaterialToolbarCard
+        hideSearchButtons
+        filterRow={
+          <div className="flex flex-wrap items-center gap-2">
+            <label className="text-sm font-medium text-gray-700 whitespace-nowrap">物资规格种类:</label>
+            <Input
+              disabled
+              value={ctx.propertyName}
+              className="w-[180px] !cursor-not-allowed !bg-gray-100 !text-gray-600 !opacity-100"
+              title={ctx.propertyName}
+            />
+            <label className="text-sm font-medium text-gray-700 whitespace-nowrap">物资规格:</label>
+            <Input
+              allowClear
+              placeholder="请输入物资规格"
+              className="w-[180px]"
+              value={draftSpec}
+              onChange={(e) => setDraftSpec(e.target.value)}
+              onPressEnter={handleSearch}
+            />
+            <Space className="flex-shrink-0" size="small">
+              <Button type="primary" icon={<Search className="h-4 w-4" />} onClick={handleSearch}>
+                {t('common.search')}
+              </Button>
+              <Button icon={<RefreshCw className="h-4 w-4" />} onClick={handleReset}>
+                {t('common.reset')}
+              </Button>
+            </Space>
+          </div>
+        }
+        toolbarRight={
+          <Space wrap>
+            <Button type="primary" icon={<Plus className="h-4 w-4" />} onClick={openValModalAdd}>
+              {t('common.addNew')}
+            </Button>
+            <Button
+              danger
+              icon={<Trash2 className="h-4 w-4" />}
+              disabled={selectedRowKeys.length === 0}
+              onClick={batchDelete}
+            >
+              {t('common.batchDelete')}
+            </Button>
+            <Button
+              type="default"
+              className="!border-orange-500 !bg-orange-500 !text-white hover:!bg-orange-600 hover:!border-orange-600"
+              icon={<ArrowLeft className="h-4 w-4" />}
+              onClick={goBackToStandard}
+            >
+              返回
+            </Button>
+          </Space>
+        }
+      />
+      <MaterialTableCard>
+        <Table
+          rowKey={(r) => r.recordId ?? r.id}
+          loading={loading}
+          columns={columns}
+          dataSource={list}
+          pagination={false}
+          scroll={{ x: 'max-content' }}
+          rowSelection={{
+            selectedRowKeys,
+            onChange: setSelectedRowKeys,
+            preserveSelectedRowKeys: true,
+            getCheckboxProps: (record) => ({ name: String(displaySpec(record)) }),
+          }}
+          rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+        />
+      </MaterialTableCard>
+      <MaterialPaginationBar
+        total={total}
+        current={pageNo}
+        pageSize={pageSize}
+        onPageChange={setPageNo}
+        onPageSizeChange={(size) => {
+          setPageSize(size);
+          setPageNo(1);
+        }}
+        loading={loading}
+        listLength={list.length}
+        showQuickJumper
+      />
+
+      <Modal
+        title={valEditing ? '修改' : '新增'}
+        open={valModalOpen}
+        onOk={submitValModal}
+        onCancel={closeValModal}
+        confirmLoading={valSaveLoading}
+        {...getMaterialFormModalProps(t, 520)}
+        styles={{ body: { paddingTop: 8 } }}
+      >
+        <Form form={valForm} {...valModalFormLayout}>
+          <Form.Item label="物资规格种类编号">
+            <Input
+              disabled
+              readOnly
+              value={String(ctx.propertyId)}
+              className="!cursor-not-allowed !bg-gray-100 !text-gray-600 !opacity-100"
+            />
+          </Form.Item>
+          <Form.Item
+            name="valueName"
+            label="物资规格"
+            rules={[{ required: true, message: '请输入物资规格' }]}
+          >
+            <Input allowClear placeholder="请输入物资规格" />
+          </Form.Item>
+          <Form.Item name="remark" label="备注">
+            <Input.TextArea allowClear rows={4} placeholder="请输入内容" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </MaterialPageRoot>
+  );
+}

+ 162 - 0
src/components/material/pages/MaterialTypePage.tsx

@@ -0,0 +1,162 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Input, Table, Image } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { toast } from 'sonner';
+import { handleTree } from '../../../utils/tree';
+import { materialTypeApi } from '../../../api/material/type';
+import { materialStandardApi } from '../../../api/material/standard';
+import { pickRecords, fetchAllMaterialTypes } from '../materialListUtils';
+import { MaterialPageRoot, MaterialToolbarCard, MaterialTableCard } from '../MaterialPageLayout';
+
+export default function MaterialTypePage() {
+  const [loading, setLoading] = useState(false);
+  const [tree, setTree] = useState<any[]>([]);
+  const [draftName, setDraftName] = useState('');
+  const [appliedName, setAppliedName] = useState('');
+  const [propertyMap, setPropertyMap] = useState<Record<string, string>>({});
+
+  const loadPropertyMap = async () => {
+    try {
+      const res = await materialStandardApi.PropertyPage({ pageNo: 1, pageSize: -1 });
+      const records = pickRecords(res);
+      const map: Record<string, string> = {};
+      records.forEach((item: any) => {
+        const id = String(item.propertyId ?? item.id ?? '');
+        const nm = item.propertyName ?? item.name ?? id;
+        if (id) {
+          map[id] = nm;
+        }
+      });
+      setPropertyMap(map);
+    } catch {
+      setPropertyMap({});
+    }
+  };
+
+  const fetchList = useCallback(async () => {
+    setLoading(true);
+    try {
+      const flat = await fetchAllMaterialTypes(materialTypeApi.listType, {
+        materialsTypeName: appliedName || undefined,
+      });
+      setTree(handleTree(flat as any, 'id', 'parentId', 'children'));
+    } catch (e: any) {
+      toast.error(e?.message || '加载失败');
+      setTree([]);
+    } finally {
+      setLoading(false);
+    }
+  }, [appliedName]);
+
+  useEffect(() => {
+    loadPropertyMap();
+  }, []);
+
+  useEffect(() => {
+    fetchList();
+  }, [fetchList]);
+
+  const displayProps = (ids: string | undefined) => {
+    if (!ids) return '—';
+    const parts = String(ids)
+      .split(',')
+      .map((x) => x.trim())
+      .filter(Boolean);
+    return parts.map((id) => propertyMap[id] || id).join(', ') || '—';
+  };
+
+  const imgCell = (url: string | undefined) =>
+    url ? (
+      <Image
+        src={url}
+        width={48}
+        height={48}
+        className="rounded-md border border-gray-200"
+        style={{ objectFit: 'cover' }}
+        preview={{ mask: '预览' }}
+      />
+    ) : (
+      <span className="text-gray-400">—</span>
+    );
+
+  const columns: ColumnsType<any> = [
+    {
+      title: '物资类型名称',
+      dataIndex: 'materialsTypeName',
+      key: 'materialsTypeName',
+      width: 240,
+      ellipsis: { showTitle: true },
+      align: 'left',
+    },
+    {
+      title: '物资规格',
+      dataIndex: 'propertyIds',
+      key: 'propertyIds',
+      width: 260,
+      ellipsis: { showTitle: true },
+      align: 'left',
+      render: (v: string) => displayProps(v),
+    },
+    {
+      title: '物资类型图标',
+      dataIndex: 'materialsTypeIcon',
+      key: 'materialsTypeIcon',
+      width: 108,
+      align: 'center',
+      className: '!align-middle',
+      render: (u: string) => imgCell(u),
+    },
+    {
+      title: '物资类型缩略图',
+      dataIndex: 'materialsTypePicture',
+      key: 'materialsTypePicture',
+      width: 108,
+      align: 'center',
+      className: '!align-middle',
+      render: (u: string) => imgCell(u),
+    },
+  ];
+
+  return (
+    <MaterialPageRoot>
+      <MaterialToolbarCard
+        filterRow={
+          <div className="flex items-center gap-2">
+            <label className="text-sm font-medium text-gray-700 whitespace-nowrap">物资类型名称:</label>
+            <Input
+              allowClear
+              placeholder="请输入类型名称"
+              className="w-[180px]"
+              value={draftName}
+              onChange={(e) => setDraftName(e.target.value)}
+              onPressEnter={() => setAppliedName(draftName)}
+            />
+          </div>
+        }
+        onSearch={() => setAppliedName(draftName)}
+        onReset={() => {
+          setDraftName('');
+          setAppliedName('');
+        }}
+      />
+      <MaterialTableCard>
+        <div className="material-type-tree-table">
+          <Table
+            rowKey={(r) => r.id}
+            loading={loading}
+            columns={columns}
+            dataSource={tree}
+            pagination={false}
+            tableLayout="fixed"
+            scroll={{ x: 748 }}
+            expandable={{
+              defaultExpandAllRows: true,
+              indentSize: 22,
+            }}
+            rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
+          />
+        </div>
+      </MaterialTableCard>
+    </MaterialPageRoot>
+  );
+}

+ 16 - 0
src/locales/en.json

@@ -62,6 +62,7 @@
     "logout": "Logout",
     "notifications": "Notifications",
     "dashboard": "Dashboard",
+    "materialManagement": "Materials",
     "switchToEnglish": "EN",
     "switchToChinese": "中文"
   },
@@ -73,6 +74,20 @@
     "dictionaryManagement": "Dictionary",
     "cabinetManagement": "Cabinet"
   },
+  "materialManagement": {
+    "materialLockers": "Cabinets",
+    "materialInventory": "Inventory",
+    "materialType": "Types",
+    "materialStandard": "Specifications",
+    "materialStandardPropertyValues": "Specification values",
+    "materialInformation": "Material List",
+    "materialLoan": "Loan & Return",
+    "materialInspectionPlan": "Inspection Plans",
+    "materialInspectionRecord": "Inspection Records",
+    "materialReplacementRecord": "Replacement Records",
+    "materialBlacklist": "Cabinet Blacklist",
+    "materialInstructions": "Instructions"
+  },
   "userManagement": {
     "userList": "User List",
     "notificationManagement": "Notifications"
@@ -354,6 +369,7 @@
     "online": "Online",
     "offline": "Offline",
     "batchDelete": "Batch Delete",
+    "pleaseSelectDataToDelete": "Please select data to delete",
     "detail": "Detail",
     "view": "View",
     "items": "items",

+ 16 - 0
src/locales/zh.json

@@ -62,6 +62,7 @@
     "logout": "退出登录",
     "notifications": "通知",
     "dashboard": "驾驶舱",
+    "materialManagement": "物资管理",
     "switchToEnglish": "EN",
     "switchToChinese": "中文"
   },
@@ -73,6 +74,20 @@
     "dictionaryManagement": "字典管理",
     "cabinetManagement": "机柜管理"
   },
+  "materialManagement": {
+    "materialLockers": "物资柜",
+    "materialInventory": "物资盘点",
+    "materialType": "物资类型",
+    "materialStandard": "物资规格种类",
+    "materialStandardPropertyValues": "规格设置",
+    "materialInformation": "物资清单",
+    "materialLoan": "物资领取归还",
+    "materialInspectionPlan": "物资检查计划",
+    "materialInspectionRecord": "物资检查记录",
+    "materialReplacementRecord": "物资更换记录",
+    "materialBlacklist": "物资柜黑名单",
+    "materialInstructions": "物资使用说明"
+  },
   "userManagement": {
     "userList": "用户列表",
     "notificationManagement": "通知管理"
@@ -354,6 +369,7 @@
     "online": "在线",
     "offline": "离线",
     "batchDelete": "批量删除",
+    "pleaseSelectDataToDelete": "请选择要删除的数据",
     "detail": "详情",
     "view": "查看",
     "items": "条数据",

+ 25 - 0
src/utils/permission.ts

@@ -1,5 +1,7 @@
 // 权限管理工具函数
 
+import { mapMaterialRoutePathToSubKey } from '../components/material/materialSubMenu';
+
 const PERMISSION_KEY = 'permissionInfo';
 const USER_KEY = 'user';
 const ROLES_KEY = 'roles';
@@ -252,6 +254,13 @@ export const mapMenuPathToKey = (path: string): string | null => {
     return 'isolationWork';
   }
   
+  // 物资管理
+  if (path === '/material') return 'materialManagement';
+  if (path.startsWith('/material/')) {
+    const k = mapMaterialRoutePathToSubKey(path);
+    if (k) return k;
+  }
+
   // 点位管理
   if (path === '/points' || path.startsWith('/points/')) {
     return 'locationManagement';
@@ -307,6 +316,22 @@ export const getMenuAndSubMenuFromKey = (key: string): { menu: string; subMenu:
   if (key === 'locationManagement') return { menu: 'locationManagement', subMenu: 'locationManagement' };
   const iw = ['processDesign', 'sopManagement', 'workManagement', 'formManagement', 'processTemplate'];
   if (key === 'isolationWork' || iw.includes(key)) return { menu: 'isolationWork', subMenu: iw.includes(key) ? key : 'processDesign' };
+  const mat = [
+    'materialLockers',
+    'materialInventory',
+    'materialType',
+    'materialStandard',
+    'materialStandardPropertyValues',
+    'materialInformation',
+    'materialLoan',
+    'materialInspectionPlan',
+    'materialInspectionRecord',
+    'materialReplacementRecord',
+    'materialBlacklist',
+    'materialInstructions',
+  ];
+  if (key === 'materialManagement' || mat.includes(key))
+    return { menu: 'materialManagement', subMenu: mat.includes(key) ? key : 'materialLockers' };
   if (key === 'taskManagement') return { menu: 'taskManagement', subMenu: 'taskManagement' };
   if (key === 'myTask') return { menu: 'isolationWork', subMenu: 'myTask' };
   if (key === 'dashboard') return { menu: 'dashboard', subMenu: 'dashboard' };