qaz 6 часов назад
Родитель
Сommit
b401752261

+ 1 - 1
.env.development

@@ -5,7 +5,7 @@ ENV = 'development'
 #线下:http://192.168.100.187:8083
 # 本地环境接口地址 121.36.251.245
 
-#VITE_API_URL = 'http://192.168.100.121:8086'
+#VITE_API_URL = 'http://192.168.100.179:8085'
 VITE_API_URL = 'http://1.94.168.85:8086'
 # VITE_API_URL = 'https://backend.qicai321.com'
 VITE_API_WX_URL='https://api.weixin.qq.com/'

+ 2 - 2
dist/index.html

@@ -11,9 +11,9 @@
 		<link rel="icon" href="/assets/facivon.BnFzlJG6.ico" />
 		<title>设备借还系统</title>
 		<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
-		<script type="module" crossorigin src="/assets/index.C5fyFuaC.js"></script>
+		<script type="module" crossorigin src="/assets/index.Bp0VaCKQ.js"></script>
 		<link rel="modulepreload" crossorigin href="/assets/vue.ClL-MbRM.js">
-		<link rel="stylesheet" crossorigin href="/assets/index.DiUONsJl.css">
+		<link rel="stylesheet" crossorigin href="/assets/index.BZnPnkqX.css">
 	</head>
 	<body>
     <div id="app"></div>


+ 1 - 1
dist/version-build

@@ -1 +1 @@
-3.0.4.1773372708340
+3.0.4.1776076622887

+ 123 - 0
docs/borrow/列表加载优化方案.md

@@ -0,0 +1,123 @@
+# 借用列表加载速度优化方案
+
+## 一、问题分析
+
+列表加载慢可能来自以下方面:
+
+| 来源 | 可能原因 |
+|------|----------|
+| **后端 API** | 数据库查询慢、N+1 查询、缺少索引、返回字段过多 |
+| **前端请求** | 首次加载时并发请求多、请求参数冗余 |
+| **前端渲染** | 列过多、复杂 formatter/cellRender、compute 计算量大 |
+
+---
+
+## 二、后端优化(需后端配合)
+
+### 2.1 数据库层面
+
+- **索引**:为常用筛选字段建索引(如 `status`、`create_datetime`、`borrow_type`)
+- **分页**:确保使用 `limit/offset` 或游标分页,避免全表扫描
+
+### 2.2 接口层面(Django 示例)
+
+```python
+# 使用 select_related / prefetch_related 减少 N+1 查询
+queryset = BorrowApplication.objects.select_related(
+    'borrower_info', 'creator'
+).prefetch_related(
+    'items'
+).only('id', 'application_no', 'status', ...)  # 只返回列表需要的字段
+```
+
+### 2.3 返回字段精简
+
+- 列表接口只返回表格展示所需字段
+- 详情接口再返回完整数据
+
+---
+
+## 三、前端优化(可立即实施)
+
+### 3.1 移除调试日志
+
+`crud.tsx` 中 `pageRequest` 的 `console.log(query)` 会在每次请求时执行,生产环境应移除。
+
+### 3.2 减少首屏列数
+
+- 将非核心列默认 `show: false`,通过列设置由用户按需显示
+- 当前已有部分列隐藏,可继续评估是否可再精简
+
+### 3.3 优化 formatter
+
+- 将 `dayjs` 等格式化逻辑尽量复用,避免每行重复创建
+- 简单格式化可考虑后端直接返回格式化字符串
+
+### 3.4 延迟加载弹窗组件
+
+- 使用 `defineAsyncComponent` 或路由懒加载,减少首屏 bundle
+- 弹窗在首次打开时再加载对应组件
+
+### 3.5 请求参数优化
+
+- 确保 `transformQuery` 只传递有值的搜索条件,减少无效参数
+
+### 3.6 避免重复刷新
+
+- `handleBorrowSubmit` 中已去除重复的 `doRefresh` 调用,仅保留 `crudExpose.doRefresh()`
+
+### 3.7 首屏加载与 doSearch
+
+- 若通过 URL 的 `status` 做初始筛选,`onMounted` 中会调用 `doSearch`。若 fast-crud 默认也会做一次初始请求,可能造成首屏请求两次,可考虑在 crud 配置中关闭默认首次加载,仅依赖 `doSearch` 触发
+
+---
+
+## 四、排查步骤
+
+1. **定位瓶颈**:用浏览器 DevTools → Network 查看 `/api/system/borrow/application/` 请求耗时
+   - 若接口耗时 > 1s,重点优化后端
+   - 若接口很快但页面渲染慢,重点优化前端
+
+2. **后端慢**:检查数据库慢查询、接口是否有 N+1、是否缺少索引
+
+3. **前端慢**:检查列数量、formatter 复杂度、是否有大量 compute
+
+---
+
+## 五、列表缓存(已实现)
+
+### 5.1 实现方式
+
+- **工具**:`src/utils/requestCache.ts` 提供通用 `createRequestCache`
+- **接入**:`crud.tsx` 中 `pageRequest` 使用缓存,相同查询参数在 TTL 内直接返回缓存
+- **失效**:增删改、领取、归还等操作后调用 `invalidateBorrowListCache()` 再 `doRefresh()`,确保展示最新数据
+
+### 5.2 配置
+
+- **TTL**:默认 30 秒,可在 `crud.tsx` 中调整 `createRequestCache({ ttl: 30 * 1000 })`
+- **keyPrefix**:`borrow:list:`,用于按业务隔离
+
+### 5.3 适用场景
+
+- 分页来回切换(如 1→2→1)时,相同页可立即展示
+- 短时间内重复搜索相同条件时,减少后端请求
+
+### 5.4 其他列表复用
+
+其他 crud 列表如需缓存,可参考借用列表:
+
+```ts
+import { createRequestCache } from '/@/utils/requestCache';
+const cache = createRequestCache({ ttl: 30 * 1000, keyPrefix: 'xxx:list:' });
+// pageRequest 中:先 cache.get(query),命中则返回;否则请求后 cache.set(query, res)
+// 变更后:export invalidateXxxListCache,在刷新前调用
+```
+
+---
+
+## 六、快速见效项(优先级)
+
+1. 移除 `console.log`(立即)
+2. 后端:为 `status`、`create_datetime` 等建索引
+3. 后端:列表接口使用 `select_related`/`prefetch_related` 并精简返回字段
+4. 前端:确认 `pageSize` 合理(当前 10 条/页已较小)

+ 2 - 2
src/views/system/borrow/approval/curd.tsx

@@ -473,7 +473,7 @@ export const createCrudOptions = function ({ crudExpose ,context, showRejectDial
 					form: { show: false }
 				}, */
 				// 审批进度详情
-				approval_progress: {
+				/* approval_progress: {
 					title: '审批进度',
 					search: { show: false },
 					type: 'input',
@@ -482,7 +482,7 @@ export const createCrudOptions = function ({ crudExpose ,context, showRejectDial
 						formatter: ({ row }: any) => getApprovalProgressText(row.admin_approver_info)
 					},
 					form: { show: false }
-				},
+				}, */
 				"items[0].device_category_name": {
 					title: '设备分类',
 					search: { show: false },

+ 189 - 83
src/views/system/borrow/component/ClassroomBorrow/index.vue

@@ -180,7 +180,11 @@
 			<el-button v-if="!isViewMode" type="primary" @click="onSave">提交</el-button>
 		</template>
 		<!-- <SelectDeviceDialog v-model:visible="showSelectDeviceDialog" @confirm="onDeviceSelected" /> -->
-		<SelectCatgory v-model:visible="showSelectDeviceDialog" @confirm="onDeviceSelected" />
+		<SelectCatgory
+			v-model:visible="showSelectDeviceDialog"
+			:disabled-ids="selectedDeviceCategoryIds"
+			@confirm="onDeviceSelected"
+		/>
 	</el-dialog>
 	 <el-dialog v-model="dialogVisible">
 		<img w-full :src="dialogImageUrl" alt="Preview Image" />
@@ -530,6 +534,100 @@ const form = ref({
 	classroom: ''
 });
 const showSelectDeviceDialog = ref(false);
+/** 详情回填后的应用用户借用人 id;编辑态下仅在与该 id 一致时不从用户列表覆盖 borrower_dept(列表侧组织字段可能不准) */
+const lastHydratedAppUserBorrowerId = ref<number | null>(null);
+
+function normalizeAppUserBorrowerId(raw: any): number | null {
+	if (raw == null || raw === '') return null;
+	if (typeof raw === 'object' && raw.id != null && raw.id !== '') {
+		const n = Number(raw.id);
+		return Number.isFinite(n) ? n : null;
+	}
+	const n = Number(raw);
+	return Number.isFinite(n) ? n : null;
+}
+
+/** 与用户列表项、详情 borrower_info 等结构对齐,从组织字段推导展示用借用部门 */
+function computeBorrowerDeptFromUserLike(selectedUser: any): string {
+	if (!selectedUser || typeof selectedUser !== 'object') return '';
+	let deptName = '';
+	const orgDetail: any = selectedUser.organization_detail || {};
+	const parentChain: any[] = Array.isArray(orgDetail.parent_chain) ? orgDetail.parent_chain : [];
+	const isValid = (val: any) => {
+		const s = String(val).trim();
+		return val !== null && val !== undefined && s !== '' && s.toLowerCase() !== 'nan' && !Number.isNaN(val);
+	};
+	if (selectedUser.user_type === 0) {
+		const chainNames: string[] = parentChain
+			.filter((n: any) => Number(n?.type) !== 1)
+			.map((n: any) => n?.name)
+			.filter((n: any) => isValid(n));
+		const college = chainNames[1] || '';
+		const major = chainNames[2] || selectedUser.sub_organization || '';
+		const clazz = orgDetail.name || selectedUser.class_or_group || '';
+		const parts = [college, major, clazz].filter((p) => isValid(p));
+		deptName = parts.join('/');
+		if (!deptName) {
+			const full = selectedUser.full_organization_name || '';
+			deptName = full.replace(/^学校\s*\/\s*/, '');
+			deptName = deptName
+				.split('/')
+				.map((s: string) => s.trim())
+				.filter((s: string) => isValid(s))
+				.join('/');
+		}
+		deptName = deptName
+			.split('/')
+			.map((s: string) => s.trim())
+			.filter((s: string) => isValid(s))
+			.join('/');
+	} else if (selectedUser.user_type === 1 || selectedUser.user_type === 3) {
+		deptName = orgDetail?.name || selectedUser.organization || selectedUser.full_organization_name || '';
+	} else {
+		deptName = selectedUser.full_organization_name || '';
+	}
+	return isValid(deptName) ? deptName : '';
+}
+
+/** 详情接口有时不落 borrower_dept 字段,仅从 borrower_info 带组织;不能用当前登录人部门回填否则会误显「Admin团队」等 */
+function applyBorrowerDeptFromDetailPayload(data: any) {
+	const f: any = form.value;
+	const root = data?.borrower_dept;
+	if (root != null && String(root).trim() !== '') {
+		f.borrower_dept = String(root).trim();
+		return;
+	}
+	const bi = data?.borrower_info;
+	if (!bi || typeof bi !== 'object') return;
+	const nestedDept = bi.dept_info?.dept_name;
+	if (nestedDept != null && String(nestedDept).trim() !== '') {
+		f.borrower_dept = String(nestedDept).trim();
+		return;
+	}
+	const fromProfile = computeBorrowerDeptFromUserLike(bi);
+	if (fromProfile) f.borrower_dept = fromProfile;
+}
+
+/** 将详情中的借用人统一为 el-select 可用的数字 id,并同步 lastHydratedAppUserBorrowerId */
+function normalizeBorrowerFieldsAfterDetailAssign(data: any) {
+	const f: any = form.value;
+	let bid = normalizeAppUserBorrowerId(f.app_user_borrower);
+	if (bid == null && data?.borrower_info?.id != null) {
+		f.app_user_borrower = Number(data.borrower_info.id);
+		bid = normalizeAppUserBorrowerId(f.app_user_borrower);
+	} else if (bid != null) {
+		f.app_user_borrower = bid;
+	}
+	lastHydratedAppUserBorrowerId.value = normalizeAppUserBorrowerId(f.app_user_borrower);
+	applyBorrowerDeptFromDetailPayload(data);
+}
+
+/** 已在借用列表中的设备分类 id,选择弹窗中不可重复勾选 */
+const selectedDeviceCategoryIds = computed(() =>
+	(form.value.items || [])
+		.map((it: any) => it.device_category ?? it.device)
+		.filter((id: any) => id !== null && id !== undefined && id !== '')
+);
 const isViewMode = computed(() => props.modelValue?.mode === 'view');
 
 
@@ -562,9 +660,9 @@ const rules = {
   expected_end_time: [
     { required: true, message: '请输选择归还时间', trigger: 'change' },
   ],
-  team_members:[
+  /* team_members:[
     { required: true, message: '请输入团队其他人员名单', trigger: 'change' },
-  ],
+  ], */
   team_type: [
     { required: true, message: '请选择个人或者团队', trigger: 'change' },
   ],
@@ -584,7 +682,7 @@ onMounted(() => {
 			form.value.external_borrower_name = userInfo.username || '';
 			// app_user_borrower 初始化不选择,由用户手动选择
 			form.value.external_borrower_phone = userInfo.mobile || '';
-			form.value.borrower_dept = userInfo.dept_info?.dept_name || '';
+			// 不写入 borrower_dept:操作员(如管理员)的 dept_name 常为「Admin团队」,详情接口若未带 borrower_dept 会残留误显
 		}
 	} catch (e) {console.error(e)}
 	fetchWeeklySchedule();
@@ -594,63 +692,31 @@ onMounted(() => {
 
 // 监听借用人选择变化
 watch(() => form.value.app_user_borrower, (newValue) => {
-  if (newValue && allUserList.value.length > 0) {
-    const selectedUser = allUserList.value.find(user => Number(user.id) === newValue);
-    if (selectedUser) {
-      form.value.user_code = selectedUser.user_code || '';
-	  form.value.mobile = selectedUser.mobile || '';
-	  // 根据用户类型动态设置借用部门:
-	  // 学生(user_type===0):学院/专业/班级;教师或领导(user_type===1或3):学院
-	  let deptName: string = '';
-	  const orgDetail: any = (selectedUser as any).organization_detail || {};
-	  const parentChain: any[] = Array.isArray(orgDetail.parent_chain) ? orgDetail.parent_chain : [];
-	  const isValid = (val: any) => {
-		const s = String(val).trim();
-		return val !== null && val !== undefined && s !== '' && s.toLowerCase() !== 'nan' && !Number.isNaN(val);
-	  };
-	  if ((selectedUser as any).user_type === 0) {
-		// 学生:优先按 parent_chain 顺序拼接“学院/专业/班级”,过滤掉“学校”层级
-		const chainNames: string[] = parentChain
-		  .filter((n: any) => Number(n?.type) !== 1) // 1=学校,过滤
-		  .map((n: any) => n?.name)
-		  .filter((n: any) => isValid(n));
-		// 取前3级:学院(0)/专业(1)/班级(2)
-		const college = chainNames[1] || '';
-		const major = chainNames[2] || (selectedUser as any).sub_organization || '';
-		const clazz = orgDetail.name || (selectedUser as any).class_or_group || '';
-		const parts = [college, major, clazz].filter((p) => isValid(p));
-		deptName = parts.join('/');
-		if (!deptName) {
-		  // 兜底:若 parent_chain 不可用,则使用已有汇总字段但尽量去掉“学校/”前缀
-		  const full = (selectedUser as any).full_organization_name || '';
-		  deptName = full.replace(/^学校\s*\/\s*/,'');
-		  // 兜底后继续清洗 'nan' 与空白段
-		  deptName = deptName
-		    .split('/')
-		    .map((s: string) => s.trim())
-		    .filter((s: string) => isValid(s))
-		    .join('/');
-		}
-		// 最终统一清洗,避免出现重复斜杠或 'nan'
-		deptName = deptName
-		  .split('/')
-		  .map((s: string) => s.trim())
-		  .filter((s: string) => isValid(s))
-		  .join('/');
-	  } else if ((selectedUser as any).user_type === 1 || (selectedUser as any).user_type === 3) {
-		// 教师或学院领导:显示学院名称
-		deptName = orgDetail?.name || (selectedUser as any).organization || (selectedUser as any).full_organization_name || '';
-	  } else {
-		// 其他类型兜底
-		deptName = (selectedUser as any).full_organization_name || '';
-	  }
-	  form.value.borrower_dept = isValid(deptName)?deptName:'';
-	  
-	  // 如果是新创建的用户,提示用户填写完整信息
-	  if (selectedUser.isNew) {
-		ElMessage.info('请为新用户填写学号/工号和联系电话');
-	  }
+  // 查看态:借用部门、学号等一律以详情接口为准,避免用户列表组织信息不完整覆盖已保存值(如误显「admin团队」等)
+  if (isView.value) return;
+  const nid = normalizeAppUserBorrowerId(newValue);
+  if (nid == null || !allUserList.value.length) return;
+  const selectedUser = allUserList.value.find(user => Number(user.id) === nid);
+  if (!selectedUser) return;
+
+  form.value.user_code = selectedUser.user_code || '';
+  form.value.mobile = selectedUser.mobile || '';
+
+  // 编辑态且仍为详情刚回填的同一借用人:若已从详情得到非空部门则不再用列表覆盖(列表组织字段可能不准)
+  const hasDeptFromDetail = String(form.value.borrower_dept || '').trim() !== '';
+  const skipDeptFromUserList =
+    isEdit.value && nid === lastHydratedAppUserBorrowerId.value && hasDeptFromDetail;
+  if (skipDeptFromUserList) {
+    if (selectedUser.isNew) {
+      ElMessage.info('请为新用户填写学号/工号和联系电话');
     }
+    return;
+  }
+
+  form.value.borrower_dept = computeBorrowerDeptFromUserLike(selectedUser);
+
+  if (selectedUser.isNew) {
+    ElMessage.info('请为新用户填写学号/工号和联系电话');
   }
 });
 
@@ -694,42 +760,79 @@ function onUserCodeBlur() {
   queryUserByCode(userCode);
 }
 
+function hasDraftSnapshot(snap: Record<string, any> | undefined | null): boolean {
+	return !!(snap && typeof snap === 'object' && Object.keys(snap).length > 0);
+}
+
 watch(() => props.visible, (v) => {
-  visible.value = v;
-//   console.log("快照visible.value::",visible.value)
-  if (v) {
-    if (props.lastFormSnapshot) {
-		console.log("快照lastFormSnapshot.value::",props.lastFormSnapshot )
-		Object.assign((form.value as any), (props.lastFormSnapshot as any) || {});
-    } else {
-      resetForm(); // 如果没有快照,初始化表单
-    }
-  }
+	visible.value = v;
+	if (!v) return;
+	lastHydratedAppUserBorrowerId.value = null;
+	const mode = props.modelValue?.mode;
+	// 编辑/查看:表单以详情接口为准,不合并 lastFormSnapshot,避免上次新增草稿或编辑数据混入
+	if (mode === 'edit' || mode === 'view') {
+		resetForm();
+		return;
+	}
+	// 仅新增:有非空快照时恢复用户未提交前填写的内容(空对象 {} 不再误判为「有快照」)
+	if (hasDraftSnapshot(props.lastFormSnapshot as Record<string, any>)) {
+		Object.assign(form.value as any, props.lastFormSnapshot as any);
+	} else {
+		resetForm();
+	}
 }, { immediate: true });
 
 
 
-watch(() => props.modelValue, async (val) => {
-  if (val && val.id && isView.value) {
-			try {
+watch(() => props.modelValue, async (val, oldVal) => {
+	// 新增且无 id:不拉详情;若刚从带 id 的查看/编辑上下文切过来,清掉误注入的详情(父组件曾晚一帧更新 modelValue 时的兜底)
+	if (val && typeof val === 'object' && val.mode === 'add' && !val.id) {
+		lastHydratedAppUserBorrowerId.value = null;
+		const ov: any = oldVal;
+		const wasDetailContext = !!(
+			ov &&
+			typeof ov === 'object' &&
+			(ov.id || ov.mode === 'view' || ov.mode === 'edit')
+		);
+		if (wasDetailContext) {
+			if (hasDraftSnapshot(props.lastFormSnapshot as Record<string, any>)) {
+				resetForm();
+				Object.assign(form.value as any, props.lastFormSnapshot as any);
+			} else {
+				resetForm();
+			}
+		}
+		return;
+	}
+
+	if (val && val.id && isView.value) {
+		const expectId = val.id;
+		const expectMode = val.mode;
+		try {
 			const res = await api.GetApplicationDetail(val.id);
+			const mv: any = props.modelValue;
+			if (!mv || mv.id !== expectId || mv.mode !== expectMode) return;
 			if (res.code === 2000 && res.data) {
-				const data = res.data;
-				Object.assign(form.value, data);
+				Object.assign(form.value, res.data);
+				normalizeBorrowerFieldsAfterDetailAssign(res.data);
 			}
-			} catch (e) {
+		} catch (e) {
 			ElMessage.error('获取详细信息失败');
-			}
-	}else if(val&& isEdit.value){
+		}
+	} else if (val && isEdit.value && val.id) {
+		const expectId = val.id;
+		const expectMode = val.mode;
 		try {
 			const res = await api.GetApplicationDetail(val.id);
+			const mv: any = props.modelValue;
+			if (!mv || mv.id !== expectId || mv.mode !== expectMode) return;
 			if (res.code === 2000 && res.data) {
-				const data = res.data;
-				Object.assign(form.value, data);
+				Object.assign(form.value, res.data);
+				normalizeBorrowerFieldsAfterDetailAssign(res.data);
 			}
-			} catch (e) {
+		} catch (e) {
 			ElMessage.error('获取详细信息失败');
-			}
+		}
 	}
 }, { immediate: true });
 
@@ -783,7 +886,10 @@ function onSave() {
 			
 			emit('submit', submitData);
 			emit('update:visible', false);
-			emit('update:lastFormSnapshot', cloneDeep(form.value));
+			// 仅保存「新增」草稿;编辑提交不回写快照,避免下次新增带出编辑单数据
+			if (!isEdit.value) {
+				emit('update:lastFormSnapshot', cloneDeep(form.value));
+			}
 		}
 	});
 }

+ 74 - 131
src/views/system/borrow/component/SelectCatgory/index.vue

@@ -7,11 +7,8 @@
 		<el-table :data="tableData" border style="width: 100%" @selection-change="onSelectionChange" ref="tableRef" row-key="id">
 			<el-table-column type="selection" width="50" :reserve-selection="true" :selectable="isRowSelectable" />
 			<el-table-column type="index" label="序号" width="60" />
-            <!-- <el-table-column prop="code" label="分类编号" /> -->
 			<el-table-column prop="name" label="设备分类" />
 			<el-table-column prop="description" label="描述" min-width="160" />
-		<!-- 	<el-table-column prop="category_name" label="设备分类" /> 
-			<el-table-column prop="warehouse" label="存放仓库" />-->
 		</el-table>
 		<el-pagination
 			style="margin-top: 12px; text-align: right"
@@ -29,21 +26,18 @@
 
 <script setup lang="ts">
 import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
+import { ElMessage } from 'element-plus';
 import * as deviceApi from '../../../deviceclass/api';
 import * as storeapi from '../../../storelist/api';
-import { status } from 'nprogress';
 
-const props = defineProps<{ 
+const props = defineProps<{
 	visible: boolean;
+	/** 已在借用单中的分类 id,列表中不可再勾选(支持 number / string 混用) */
 	disabledIds?: (number | string)[];
 }>();
 const emit = defineEmits(['update:visible', 'confirm']);
 
 const visible = ref(props.visible);
-watch(
-	() => props.visible,
-	(v) => (visible.value = v)
-);
 
 const search = ref('');
 const page = ref(1);
@@ -52,198 +46,147 @@ const total = ref(0);
 const tableData = ref<any[]>([]);
 const selectedRows = ref<any[]>([]);
 const warehouseMap = ref<Record<number, string>>({});
-// 用于存储所有已选中的项(跨页)
 const selectedMap = ref<Map<number | string, any>>(new Map());
-// 用于存储已禁用项的Map(这些项不能取消选择)
-const disabledMap = ref<Map<number | string, any>>(new Map());
 const tableRef = ref();
-// 标志位:是否正在恢复选中状态(避免触发 onSelectionChange 时误删数据)
 const isRestoring = ref(false);
 
+function isCategoryIdDisabled(rowId: any): boolean {
+	const list = props.disabledIds;
+	if (!list?.length) return false;
+	return list.some((d) => d == rowId);
+}
 
 function fetchWarehouseMap() {
-    storeapi.GetPermission().then((res: any) => {
-        const map: Record<number, string> = {};
-        res.data.forEach((item: any) => {
-            map[item.id] = item.name;
-        });
-        warehouseMap.value = map;
-    });
+	storeapi.GetPermission().then((res: any) => {
+		const map: Record<number, string> = {};
+		res.data.forEach((item: any) => {
+			map[item.id] = item.name;
+		});
+		warehouseMap.value = map;
+	});
 }
 
-
 async function fetchData(resetPage = false) {
 	if (resetPage) page.value = 1;
-	await fetchWarehouseMap(); 
-	deviceApi.GetList({ page: page.value, limit: pageSize.value, code: search.value,status:1 })
-	.then((res: any) => {
-		const devices = res.data
-		/* .filter(device => device.available_quantity > 0)
-		.map((device) => ({
-            ...device,
-            warehouse: `${warehouseMap.value[device.warehouse] || `ID:${device.warehouse}`} (${device.available_quantity})`
-        })); */
-		tableData.value = devices;
-		// tableData.value = [...res.data];
+	await fetchWarehouseMap();
+	deviceApi.GetList({ page: page.value, limit: pageSize.value, code: search.value, status: 1 }).then((res: any) => {
+		const devices = Array.isArray(res.data) ? res.data : [];
+		// 本页内将「已在借用单中的分类」排在前面,便于一眼看到
+		tableData.value = [...devices].sort((a: any, b: any) => {
+			const da = isCategoryIdDisabled(a.id) ? 0 : 1;
+			const db = isCategoryIdDisabled(b.id) ? 0 : 1;
+			return da - db;
+		});
 		total.value = res.total;
-		// 数据加载完成后,初始化禁用项并回显选中状态
 		nextTick(() => {
-			initDisabledItemsFromCurrentPage();
 			restoreSelection();
 		});
 	});
 }
-// 判断行是否可选择(禁用的行不能取消选择)
+
 function isRowSelectable(row: any) {
-	// 如果该行在禁用列表中,则不可取消选择
-	return !disabledMap.value.has(row.id);
+	return !isCategoryIdDisabled(row.id);
 }
 
-// 恢复当前页的选中状态
 function restoreSelection() {
 	if (!tableRef.value) return;
-	// 设置标志位,避免 onSelectionChange 误删数据
 	isRestoring.value = true;
-	
-	// 先清空当前页的所有选中
+
 	tableData.value.forEach((row: any) => {
 		tableRef.value.toggleRowSelection(row, false);
 	});
-	// 根据 selectedMap 设置当前页的选中状态
 	tableData.value.forEach((row: any) => {
-		if (selectedMap.value.has(row.id)) {
+		const inSession = selectedMap.value.has(row.id);
+		const alreadyInForm = isCategoryIdDisabled(row.id);
+		if (inSession || alreadyInForm) {
 			tableRef.value.toggleRowSelection(row, true);
 		}
 	});
-	
-	// 恢复标志位
+
 	isRestoring.value = false;
-	// 更新当前页的选中数组
 	updateSelectedRows();
 }
 
-// 更新当前页的选中数组
 function updateSelectedRows() {
 	selectedRows.value = tableData.value.filter((row: any) => selectedMap.value.has(row.id));
 }
 
 function onSelectionChange(val: any[]) {
-	// 如果正在恢复选中状态,不更新 selectedMap(避免误删数据)
 	if (isRestoring.value) {
 		return;
 	}
-	
-	// 获取当前页所有行的 id
+
 	const currentPageIds = new Set(tableData.value.map((row: any) => row.id));
-	
-	// 移除当前页中未选中的项(但不能移除禁用的项)
+
 	currentPageIds.forEach((id) => {
+		if (isCategoryIdDisabled(id)) {
+			return;
+		}
 		const isSelected = val.some((item: any) => item.id === id);
-		if (!isSelected && !disabledMap.value.has(id)) {
-			// 只有非禁用的项才能被移除
+		if (!isSelected) {
 			selectedMap.value.delete(id);
-		} else if (!isSelected && disabledMap.value.has(id)) {
-			// 如果禁用的项被取消选择,强制重新选中
-			nextTick(() => {
-				const row = tableData.value.find((r: any) => r.id === id);
-				if (row && tableRef.value) {
-					tableRef.value.toggleRowSelection(row, true);
-				}
-			});
 		}
 	});
-	
-	// 添加当前页中新选中的项
+
 	val.forEach((item: any) => {
-		if (!selectedMap.value.has(item.id)) {
-			selectedMap.value.set(item.id, item);
-		} else {
-			// 如果已存在,更新数据(防止数据过期)
-			selectedMap.value.set(item.id, item);
+		if (isCategoryIdDisabled(item.id)) {
+			return;
 		}
+		selectedMap.value.set(item.id, item);
 	});
-	
-	// 更新当前页的选中数组
+
 	updateSelectedRows();
 }
+
 function onPageChange(val: number) {
 	page.value = val;
 	fetchData();
 }
+
 function onConfirm() {
-	// 返回所有选中的项(跨页),但排除已禁用的项(避免重复添加)
-	const allSelected = Array.from(selectedMap.value.values()).filter((item: any) => {
-		// 只返回新选中的项,不在 disabledIds 中的项
-		return !props.disabledIds?.includes(item.id);
-	});
-	
-	// 如果没有新选中的项,提示用户
+	const allSelected = Array.from(selectedMap.value.values()).filter((item: any) => !isCategoryIdDisabled(item.id));
+
 	if (allSelected.length === 0) {
-		emit('update:visible', false);
+		ElMessage.warning('请选择设备分类(已在列表中的分类不可重复添加)');
 		return;
 	}
-	
+
 	emit('confirm', allSelected);
 	emit('update:visible', false);
 }
+
 function onCancel() {
-	// 取消时只清空非禁用的选中状态,保留禁用的项
-	selectedMap.value.forEach((value, key) => {
-		if (!disabledMap.value.has(key)) {
-			selectedMap.value.delete(key);
-		}
-	});
+	selectedMap.value.clear();
 	selectedRows.value = [];
 	emit('update:visible', false);
 }
-// 从当前页数据中初始化禁用项
-function initDisabledItemsFromCurrentPage() {
-	if (!props.disabledIds || props.disabledIds.length === 0) {
-		return;
-	}
-	
-	// 将当前页中 disabledIds 的项添加到 disabledMap 和 selectedMap
-	tableData.value.forEach((row: any) => {
-		if (props.disabledIds?.includes(row.id)) {
-			disabledMap.value.set(row.id, row);
-			selectedMap.value.set(row.id, row);
-		}
-	});
-}
 
-watch([visible, page], (v) => {
-	if (visible.value) {
-		// 清空禁用Map,等待数据加载后重新初始化
-		disabledMap.value.clear();
-		// 如果 disabledIds 存在,先标记这些ID为禁用(数据加载后会在 initDisabledItemsFromCurrentPage 中完善)
-		if (props.disabledIds && props.disabledIds.length > 0) {
-			props.disabledIds.forEach((id) => {
-				disabledMap.value.set(id, { id });
-			});
-		}
-		fetchData();
-	} else {
-		// 对话框关闭时不清空选中状态,以便下次打开时保留
-		// 但清空禁用Map,下次打开时重新初始化
-		disabledMap.value.clear();
-	}
-});
-
-// 监听 disabledIds 变化
-watch(() => props.disabledIds, () => {
-	if (visible.value) {
-		// 清空并重新初始化禁用项
-		disabledMap.value.clear();
-		if (props.disabledIds && props.disabledIds.length > 0) {
-			props.disabledIds.forEach((id) => {
-				disabledMap.value.set(id, { id });
-			});
+watch(
+	() => props.visible,
+	(v) => {
+		visible.value = v;
+		if (v) {
+			fetchData();
 		}
-		initDisabledItemsFromCurrentPage();
+	},
+	{ immediate: true }
+);
+
+watch(
+	() => props.disabledIds,
+	() => {
+		if (!visible.value) return;
+		const toDel: (number | string)[] = [];
+		selectedMap.value.forEach((_v, key) => {
+			if (isCategoryIdDisabled(key)) {
+				toDel.push(key);
+			}
+		});
+		toDel.forEach((k) => selectedMap.value.delete(k));
 		nextTick(() => {
 			restoreSelection();
 		});
-	}
-}, { deep: true });
+	},
+	{ deep: true }
+);
 </script>
-

+ 0 - 1
src/views/system/borrow/component/SpecialBorrow/index.vue

@@ -807,7 +807,6 @@ function onSubmit() {
 			form.value.mode = isEdit.value ? 'edit' : 'add';
 			// 确保 borrow_type 设置为 2(特殊借用)
 			form.value.borrow_type = 2;
-			console.log(form.value);
 			// return
 			emit('submit', { ...form.value });
 			emit('update:visible', false);

+ 8 - 13
src/views/system/borrow/index.vue

@@ -57,29 +57,24 @@ const lastFormSnapshot = ref({});
 
 function onBorrowTypeSelected(type: number, mode: 'view' | 'edit' | 'add' |'collect',record?: any) {
 	showBorrowTypeDialog.value = false;
-	nextTick(() => {
-		borrowForm.value = { ...(record || {}), borrow_type: type, mode };
-		console.log("borrowForm.value:::",borrowForm.value);
-	});
+	// 必须先同步写入 borrowForm,再打开子弹窗;若在 nextTick 中赋值,子组件挂载时仍拿到上一次的查看/编辑数据,会误拉详情并残留在表单中
+	borrowForm.value = { ...(record || {}), borrow_type: type, mode };
 	if (type === 1) {
-		console.log("mode:::",mode);
-		if(mode==='view'){
+		if (mode === 'view') {
 			showCollectDialog.value = true;
-		}else{
+		} else {
 			showClassroomBorrowDialog.value = true;
 		}
 	} else if (type === 2) {
-		console.log("mode:::",mode);
-		if(mode==='view'){
+		if (mode === 'view') {
 			showCollectDialog.value = true;
-		}else{
-			borrowForm.value={}
+		} else {
 			showSpecialBorrowDialog.value = true;
 		}
 	} else if (type === 0) {
-		if(mode==='view'){
+		if (mode === 'view') {
 			showCollectDialog.value = true;
-		}else{
+		} else {
 			showCommonBorrowDialog.value = true;
 		}
 	}

+ 21 - 5
src/views/system/devicemaintenance/crud.tsx

@@ -6,6 +6,7 @@ import {getBaseURL} from '/@/utils/baseUrl';
 import Cookies from 'js-cookie';
 import {request} from '/@/utils/service';
 import {Ref, shallowRef} from 'vue';
+import dayjs from 'dayjs';
 import deviceSelector from '/@/components/deviceSelector/index.vue';
 
 export const createCrudOptions = function ({ crudExpose,  dialogId,
@@ -771,12 +772,17 @@ export const createCrudOptions = function ({ crudExpose,  dialogId,
 					title: '维修开始时间',
 					type: 'input',
 					column: {
-						minWidth: 120,
+						minWidth: 160,
+						formatter: ({ value }: { value: unknown }) => {
+							if (value == null || value === '') return '';
+							const d = dayjs(value as string);
+							return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : String(value);
+						},
 					},
 					form: {
 						show:false,
-						component: { placeholder: '创建时间' },
-						rules: [{ required: false, message: '创建时间' }],
+						component: { placeholder: '维修开始时间' },
+						rules: [{ required: false, message: '维修开始时间' }],
 					},
 					viewForm:{
 						component: { placeholder: '' },
@@ -787,7 +793,12 @@ export const createCrudOptions = function ({ crudExpose,  dialogId,
 					title: '维修完成时间',
 					type: 'input',
 					column: {
-						minWidth: 120,
+						minWidth: 160,
+						formatter: ({ value }: { value: unknown }) => {
+							if (value == null || value === '') return '';
+							const d = dayjs(value as string);
+							return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : String(value);
+						},
 					},
 					form: {
 						show:false,
@@ -806,7 +817,12 @@ export const createCrudOptions = function ({ crudExpose,  dialogId,
 					title: '创建时间',
 					type: 'input',
 					column: {
-						minWidth: 120,
+						minWidth: 160,
+						formatter: ({ value }: { value: unknown }) => {
+							if (value == null || value === '') return '';
+							const d = dayjs(value as string);
+							return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : String(value);
+						},
 					},
 					form: {
 						show:false,